Add MVP version
This commit is contained in:
1
apps/web/next-env.d.ts
vendored
1
apps/web/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
@@ -7,20 +7,32 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint . --max-warnings 0"
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"db:post-install": "prisma generate",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push --skip-generate",
|
||||||
|
"db:migrate-dev": "prisma migrate dev",
|
||||||
|
"db:migrate-deploy": "prisma migrate deploy",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.4.0",
|
"@auth/prisma-adapter": "^1.4.0",
|
||||||
"@unsend/db": "workspace:*",
|
"@aws-sdk/client-sesv2": "^3.535.0",
|
||||||
"@unsend/ui": "workspace:*",
|
"@aws-sdk/client-sns": "^3.540.0",
|
||||||
|
"@prisma/client": "^5.11.0",
|
||||||
"@t3-oss/env-nextjs": "^0.9.2",
|
"@t3-oss/env-nextjs": "^0.9.2",
|
||||||
"@tanstack/react-query": "^5.25.0",
|
"@tanstack/react-query": "^5.25.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
|
"@unsend/ui": "workspace:*",
|
||||||
|
"install": "^0.13.0",
|
||||||
|
"lucide-react": "^0.359.0",
|
||||||
"next": "^14.1.3",
|
"next": "^14.1.3",
|
||||||
"next-auth": "^4.24.6",
|
"next-auth": "^4.24.6",
|
||||||
|
"pnpm": "^8.15.5",
|
||||||
|
"prisma": "^5.11.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
159
apps/web/prisma/schema.prisma
Normal file
159
apps/web/prisma/schema.prisma
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
|
||||||
|
// Further reading:
|
||||||
|
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
|
||||||
|
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppSetting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
}
|
||||||
|
|
||||||
|
// Necessary for Next auth
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId Int
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? // @db.Text
|
||||||
|
access_token String? // @db.Text
|
||||||
|
refresh_token_expires_in Int?
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? // @db.Text
|
||||||
|
session_state String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId Int
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String?
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
teamUsers TeamUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
teamUsers TeamUser[]
|
||||||
|
domains Domain[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
emails Email[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
model TeamUser {
|
||||||
|
teamId Int
|
||||||
|
userId Int
|
||||||
|
role Role
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DomainStatus {
|
||||||
|
NOT_STARTED
|
||||||
|
PENDING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
TEMPORARY_FAILURE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Domain {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
teamId Int
|
||||||
|
status DomainStatus @default(PENDING)
|
||||||
|
region String @default("us-east-1")
|
||||||
|
publicKey String
|
||||||
|
dkimStatus String?
|
||||||
|
spfDetails String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiPermission {
|
||||||
|
FULL
|
||||||
|
SENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
tokenHash String @unique
|
||||||
|
partialToken String
|
||||||
|
name String
|
||||||
|
permission ApiPermission @default(SENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastUsed DateTime?
|
||||||
|
teamId Int
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Email {
|
||||||
|
id String @id
|
||||||
|
to String
|
||||||
|
from String
|
||||||
|
subject String
|
||||||
|
text String?
|
||||||
|
html String?
|
||||||
|
latestStatus String?
|
||||||
|
teamId Int
|
||||||
|
domainId Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
emailEvents EmailEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmailEvent {
|
||||||
|
emailId String
|
||||||
|
status String
|
||||||
|
data Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([emailId, status])
|
||||||
|
}
|
104
apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx
Normal file
104
apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import { Label } from "@unsend/ui/src/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function AddApiKey() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const addDomainMutation = api.apiKey.createToken.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
addDomainMutation.mutate(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
permission: "FULL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.apiKey.invalidate();
|
||||||
|
setApiKey(data);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
navigator.clipboard.writeText(apiKey);
|
||||||
|
setApiKey("");
|
||||||
|
setName("");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Add API Key</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
{apiKey ? (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Copy API key</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2 bg-gray-200 rounded-lg">{apiKey}</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={addDomainMutation.isPending}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
) : (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create a new API key</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
API key name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="prod key"
|
||||||
|
defaultValue=""
|
||||||
|
className="col-span-3"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={addDomainMutation.isPending}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
26
apps/web/src/app/(dashboard)/api-keys/api-list.tsx
Normal file
26
apps/web/src/app/(dashboard)/api-keys/api-list.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export default function ApiList() {
|
||||||
|
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{!apiKeysQuery.isLoading && apiKeysQuery.data?.length ? (
|
||||||
|
apiKeysQuery.data?.map((apiKey) => (
|
||||||
|
<div className="p-2 px-4 border rounded-lg flex justify-between">
|
||||||
|
<p>{apiKey.name}</p>
|
||||||
|
<p>{apiKey.permission}</p>
|
||||||
|
<p>{apiKey.partialToken}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div>No API keys added</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
15
apps/web/src/app/(dashboard)/api-keys/page.tsx
Normal file
15
apps/web/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ApiList from "./api-list";
|
||||||
|
import AddApiKey from "./add-api-key";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">API Keys</h1>
|
||||||
|
<AddApiKey />
|
||||||
|
</div>
|
||||||
|
<ApiList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
return <div>Hello world</div>;
|
||||||
|
}
|
62
apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
Normal file
62
apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export default function DomainItemPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { domainId: string };
|
||||||
|
}) {
|
||||||
|
const domainQuery = api.domain.getDomain.useQuery({
|
||||||
|
id: Number(params.domainId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{domainQuery.isLoading ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">{domainQuery.data?.name}</h1>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs capitalize bg-gray-200 rounded px-2 py-1 w-[80px] text-center">
|
||||||
|
{domainQuery.data?.status.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 border rounded p-4">
|
||||||
|
<p className="font-semibold">DNS records</p>
|
||||||
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p>TXT</p>
|
||||||
|
<p>{`unsend._domainkey.${domainQuery.data?.name}`}</p>
|
||||||
|
<p className=" w-[200px] overflow-hidden text-ellipsis">{`p=${domainQuery.data?.publicKey}`}</p>
|
||||||
|
<p className=" capitalize">
|
||||||
|
{domainQuery.data?.dkimStatus?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p>TXT</p>
|
||||||
|
<p>{`send.${domainQuery.data?.name}`}</p>
|
||||||
|
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`"v=spf1 include:amazonses.com ~all"`}</p>
|
||||||
|
<p className=" capitalize">
|
||||||
|
{domainQuery.data?.spfDetails?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p>MX</p>
|
||||||
|
<p>{`send.${domainQuery.data?.name}`}</p>
|
||||||
|
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}</p>
|
||||||
|
<p className=" capitalize">
|
||||||
|
{domainQuery.data?.spfDetails?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
78
apps/web/src/app/(dashboard)/domains/add-domain.tsx
Normal file
78
apps/web/src/app/(dashboard)/domains/add-domain.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import { Label } from "@unsend/ui/src/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function AddDomain() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [domainName, setDomainName] = useState("");
|
||||||
|
const addDomainMutation = api.domain.createDomain.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
addDomainMutation.mutate(
|
||||||
|
{
|
||||||
|
name: domainName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.domain.domains.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Add domain</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add a new domain</DialogTitle>
|
||||||
|
<DialogDescription>This creates a new domain</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Domain Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="subdomain.example.com"
|
||||||
|
defaultValue=""
|
||||||
|
className="col-span-3"
|
||||||
|
onChange={(e) => setDomainName(e.target.value)}
|
||||||
|
value={domainName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={addDomainMutation.isPending}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
27
apps/web/src/app/(dashboard)/domains/domain-list.tsx
Normal file
27
apps/web/src/app/(dashboard)/domains/domain-list.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export default function DomainsList() {
|
||||||
|
const domainsQuery = api.domain.domains.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{!domainsQuery.isLoading && domainsQuery.data?.length ? (
|
||||||
|
domainsQuery.data?.map((domain) => (
|
||||||
|
<Link key={domain.id} href={`/domains/${domain.id}`}>
|
||||||
|
<div className="p-2 px-4 border rounded-lg flex justify-between">
|
||||||
|
<p>{domain.name}</p>
|
||||||
|
<p className=" capitalize">{domain.status.toLowerCase()}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div>No domains</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
15
apps/web/src/app/(dashboard)/domains/page.tsx
Normal file
15
apps/web/src/app/(dashboard)/domains/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import DomainsList from "./domain-list";
|
||||||
|
import AddDomain from "./add-domain";
|
||||||
|
|
||||||
|
export default async function DomainsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">Domains</h1>
|
||||||
|
<AddDomain />
|
||||||
|
</div>
|
||||||
|
<DomainsList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
31
apps/web/src/app/(dashboard)/emails/email-list.tsx
Normal file
31
apps/web/src/app/(dashboard)/emails/email-list.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export default function DomainsList() {
|
||||||
|
const emailsQuery = api.email.emails.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{!emailsQuery.isLoading && emailsQuery.data?.length ? (
|
||||||
|
emailsQuery.data?.map((email) => (
|
||||||
|
<Link key={email.id} href={`/email/${email.id}`} className="w-full">
|
||||||
|
<div className="p-2 px-4 border rounded-lg flex justify-between w-full">
|
||||||
|
<p>{email.to}</p>
|
||||||
|
<p className=" capitalize">
|
||||||
|
{email.latestStatus?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
<p>{email.subject}</p>
|
||||||
|
<p>{email.createdAt.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div>No domains</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
13
apps/web/src/app/(dashboard)/emails/page.tsx
Normal file
13
apps/web/src/app/(dashboard)/emails/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import EmailList from "./email-list";
|
||||||
|
|
||||||
|
export default async function EmailsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">Emails</h1>
|
||||||
|
</div>
|
||||||
|
<EmailList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
42
apps/web/src/app/(dashboard)/layout.tsx
Normal file
42
apps/web/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NextAuthProvider } from "~/providers/next-auth";
|
||||||
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Unsend",
|
||||||
|
description: "Generated by create-t3-app",
|
||||||
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AuthenticatedDashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await getServerAuthSession();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextAuthProvider session={session}>
|
||||||
|
<div className="h-screen flex">
|
||||||
|
<nav className="w-[200px] border border-r p-4">
|
||||||
|
<div className=" font-semibold text-xl">Unsend</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 mt-10">
|
||||||
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
|
<Link href="/domains">Domains</Link>
|
||||||
|
<Link href="/emails">Emails</Link>
|
||||||
|
<Link href="/api-keys">API Keys</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className=" max-w-4xl mx-auto py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NextAuthProvider>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
export function CreatePost() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
|
|
||||||
const createPost = api.post.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
router.refresh();
|
|
||||||
setName("");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
createPost.mutate({ name });
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Title"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full rounded-full px-4 py-2 text-black"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
|
|
||||||
disabled={createPost.isPending}
|
|
||||||
>
|
|
||||||
{createPost.isPending ? "Submitting..." : "Submit"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
36
apps/web/src/app/api/email/route.ts
Normal file
36
apps/web/src/app/api/email/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { hashToken } from "~/server/auth";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { sendEmail } from "~/server/service/email-service";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ data: "Hello" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const token = headers().get("authorization")?.split(" ")[1];
|
||||||
|
console.log(token);
|
||||||
|
if (!token) {
|
||||||
|
return new Response("authorization token is required", {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hashedToken = hashToken(token);
|
||||||
|
const team = await db.team.findFirst({
|
||||||
|
where: {
|
||||||
|
apiKeys: {
|
||||||
|
some: {
|
||||||
|
tokenHash: hashedToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await req.json();
|
||||||
|
try {
|
||||||
|
const email = await sendEmail({ ...data, teamId: team?.id });
|
||||||
|
return Response.json({ data: email });
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ error: (e as Error).message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
6
apps/web/src/app/api/health/route.ts
Normal file
6
apps/web/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { setupAws } from "~/server/aws/setup";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
await setupAws();
|
||||||
|
return Response.json({ data: "Healthy" });
|
||||||
|
}
|
82
apps/web/src/app/api/ses_callback/route.ts
Normal file
82
apps/web/src/app/api/ses_callback/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { hashToken } from "~/server/auth";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
console.log("GET", req);
|
||||||
|
return Response.json({ data: "Hello" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
if (data.Type === "SubscriptionConfirmation") {
|
||||||
|
return handleSubscription(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(data, data.Message);
|
||||||
|
|
||||||
|
let message = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(data.Message || "{}");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailId = message?.mail.messageId;
|
||||||
|
|
||||||
|
console.log(emailId, message);
|
||||||
|
|
||||||
|
if (!emailId) {
|
||||||
|
return Response.json({ data: "Email not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = await db.email.findUnique({
|
||||||
|
where: {
|
||||||
|
id: emailId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!email || !message.mail) {
|
||||||
|
return Response.json({ data: "Email not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("FOund email", email);
|
||||||
|
|
||||||
|
await db.email.update({
|
||||||
|
where: {
|
||||||
|
id: email.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
latestStatus: message.eventType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.emailEvent.upsert({
|
||||||
|
where: {
|
||||||
|
emailId_status: {
|
||||||
|
emailId,
|
||||||
|
status: message.eventType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
data: message[message.eventType.toLowerCase()],
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
emailId,
|
||||||
|
status: message.eventType,
|
||||||
|
data: message[message.eventType.toLowerCase()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ data: "Hello" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscription(message: any) {
|
||||||
|
const subResponse = await fetch(message.SubscribeURL, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ data: "Hello" });
|
||||||
|
}
|
@@ -1,84 +1,34 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { CreatePost } from "~/app/_components/create-post";
|
|
||||||
import { getServerAuthSession } from "~/server/auth";
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
import { api } from "~/trpc/server";
|
import { api } from "~/trpc/server";
|
||||||
import { Button } from "@unsend/ui/src/button";
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { SendHorizonal } from "lucide-react";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const hello = await api.post.hello({ text: "from tRPC" });
|
|
||||||
const session = await getServerAuthSession();
|
const session = await getServerAuthSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
|
<main className="h-screen">
|
||||||
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
|
<h1 className="text-center text-4xl mt-20 flex gap-4 justify-center items-center">
|
||||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
<SendHorizonal />
|
||||||
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
|
Send emails in minutes. Completely open source
|
||||||
</h1>
|
</h1>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
|
<div className="flex justify-center mt-10">
|
||||||
<Link
|
{session?.user ? (
|
||||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
|
<Button className="mx-auto">
|
||||||
href="https://create.t3.gg/en/usage/first-steps"
|
<Link href="/dashboard" className="mx-auto">
|
||||||
target="_blank"
|
Send email
|
||||||
>
|
|
||||||
<h3 className="text-2xl font-bold">First Steps →</h3>
|
|
||||||
<div className="text-lg">
|
|
||||||
Just the basics - Everything you need to know to set up your
|
|
||||||
database and authentication.
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
</Button>
|
||||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
|
) : (
|
||||||
href="https://create.t3.gg/en/introduction"
|
<Button className="mx-auto">
|
||||||
target="_blank"
|
<Link href="api/auth/signin" className="mx-auto">
|
||||||
>
|
Signin
|
||||||
<h3 className="text-2xl font-bold">Documentation →</h3>
|
|
||||||
<div className="text-lg">
|
|
||||||
Learn more about Create T3 App, the libraries it uses, and how to
|
|
||||||
deploy it.
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
<Button>Hello</Button>
|
)}
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<p className="text-2xl text-white">
|
|
||||||
{hello ? hello.greeting : "Loading tRPC query..."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
|
||||||
<p className="text-center text-2xl text-white">
|
|
||||||
{session && <span>Logged in as {session.user?.name}</span>}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={session ? "/api/auth/signout" : "/api/auth/signin"}
|
|
||||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{session ? "Sign out" : "Sign in"}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CrudShowcase />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function CrudShowcase() {
|
|
||||||
const session = await getServerAuthSession();
|
|
||||||
if (!session?.user) return null;
|
|
||||||
|
|
||||||
const latestPost = await api.post.getLatest();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-xs">
|
|
||||||
{latestPost ? (
|
|
||||||
<p className="truncate">Your most recent post: {latestPost.name}</p>
|
|
||||||
) : (
|
|
||||||
<p>You have no posts yet.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreatePost />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@@ -28,6 +28,12 @@ export const env = createEnv({
|
|||||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
||||||
process.env.VERCEL ? z.string() : z.string().url()
|
process.env.VERCEL ? z.string() : z.string().url()
|
||||||
),
|
),
|
||||||
|
GITHUB_ID: z.string(),
|
||||||
|
GITHUB_SECRET: z.string(),
|
||||||
|
AWS_ACCESS_KEY: z.string(),
|
||||||
|
AWS_SECRET_KEY: z.string(),
|
||||||
|
APP_URL: z.string().optional(),
|
||||||
|
SNS_TOPIC: z.string(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +54,12 @@ export const env = createEnv({
|
|||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||||
|
GITHUB_ID: process.env.GITHUB_ID,
|
||||||
|
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||||
|
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
|
||||||
|
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
|
||||||
|
APP_URL: process.env.APP_URL,
|
||||||
|
SNS_TOPIC: process.env.SNS_TOPIC,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
18
apps/web/src/providers/next-auth.tsx
Normal file
18
apps/web/src/providers/next-auth.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export type NextAuthProviderProps = {
|
||||||
|
session?: Session | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NextAuthProvider = ({
|
||||||
|
session,
|
||||||
|
children,
|
||||||
|
}: NextAuthProviderProps) => {
|
||||||
|
return <SessionProvider session={session}>{children}</SessionProvider>;
|
||||||
|
};
|
@@ -1,5 +1,7 @@
|
|||||||
import { postRouter } from "~/server/api/routers/post";
|
import { domainRouter } from "~/server/api/routers/domain";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
import { apiRouter } from "./routers/api";
|
||||||
|
import { emailRouter } from "./routers/email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -7,7 +9,9 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
|||||||
* All routers added in /api/routers should be manually added here.
|
* All routers added in /api/routers should be manually added here.
|
||||||
*/
|
*/
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
post: postRouter,
|
domain: domainRouter,
|
||||||
|
apiKey: apiRouter,
|
||||||
|
email: emailRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
42
apps/web/src/server/api/routers/api.ts
Normal file
42
apps/web/src/server/api/routers/api.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
teamProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { addApiKey } from "~/server/service/api-service";
|
||||||
|
import { createDomain, getDomain } from "~/server/service/domain-service";
|
||||||
|
|
||||||
|
export const apiRouter = createTRPCRouter({
|
||||||
|
createToken: teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({ name: z.string(), permission: z.enum(["FULL", "SENDING"]) })
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return addApiKey({
|
||||||
|
name: input.name,
|
||||||
|
permission: input.permission,
|
||||||
|
teamId: ctx.team.id,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
getApiKeys: teamProcedure.query(async ({ ctx }) => {
|
||||||
|
const keys = await ctx.db.apiKey.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: ctx.team.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
permission: true,
|
||||||
|
partialToken: true,
|
||||||
|
lastUsed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}),
|
||||||
|
});
|
34
apps/web/src/server/api/routers/domain.ts
Normal file
34
apps/web/src/server/api/routers/domain.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
teamProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { createDomain, getDomain } from "~/server/service/domain-service";
|
||||||
|
|
||||||
|
export const domainRouter = createTRPCRouter({
|
||||||
|
createDomain: teamProcedure
|
||||||
|
.input(z.object({ name: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return createDomain(ctx.team.id, input.name);
|
||||||
|
}),
|
||||||
|
|
||||||
|
domains: teamProcedure.query(async ({ ctx }) => {
|
||||||
|
const domains = await db.domain.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: ctx.team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return domains;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDomain: teamProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return getDomain(input.id);
|
||||||
|
}),
|
||||||
|
});
|
41
apps/web/src/server/api/routers/email.ts
Normal file
41
apps/web/src/server/api/routers/email.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
teamProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { createDomain, getDomain } from "~/server/service/domain-service";
|
||||||
|
|
||||||
|
export const emailRouter = createTRPCRouter({
|
||||||
|
emails: teamProcedure.query(async ({ ctx }) => {
|
||||||
|
const emails = await db.email.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: ctx.team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return emails;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getEmail: teamProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const email = await db.email.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emailEvents: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return email;
|
||||||
|
}),
|
||||||
|
});
|
@@ -1,42 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createTRPCRouter,
|
|
||||||
protectedProcedure,
|
|
||||||
publicProcedure,
|
|
||||||
} from "~/server/api/trpc";
|
|
||||||
|
|
||||||
export const postRouter = createTRPCRouter({
|
|
||||||
hello: publicProcedure
|
|
||||||
.input(z.object({ text: z.string() }))
|
|
||||||
.query(({ input }) => {
|
|
||||||
return {
|
|
||||||
greeting: `Hello ${input.text}`,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
create: protectedProcedure
|
|
||||||
.input(z.object({ name: z.string().min(1) }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
// simulate a slow db call
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
return ctx.db.post.create({
|
|
||||||
data: {
|
|
||||||
name: input.name,
|
|
||||||
createdBy: { connect: { id: ctx.session.user.id } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
getLatest: protectedProcedure.query(({ ctx }) => {
|
|
||||||
return ctx.db.post.findFirst({
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
where: { createdBy: { id: ctx.session.user.id } },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
getSecretMessage: protectedProcedure.query(() => {
|
|
||||||
return "you can now see this secret message!";
|
|
||||||
}),
|
|
||||||
});
|
|
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
|
|
||||||
import { getServerAuthSession } from "~/server/auth";
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
@@ -106,3 +106,21 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const teamProcedure = protectedProcedure.use(
|
||||||
|
async ({ ctx, next, input }) => {
|
||||||
|
const teamUser = await db.teamUser.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id },
|
||||||
|
include: { team: true },
|
||||||
|
});
|
||||||
|
if (!teamUser) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
team: teamUser.team,
|
||||||
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@@ -5,7 +5,7 @@ import {
|
|||||||
type NextAuthOptions,
|
type NextAuthOptions,
|
||||||
} from "next-auth";
|
} from "next-auth";
|
||||||
import { type Adapter } from "next-auth/adapters";
|
import { type Adapter } from "next-auth/adapters";
|
||||||
import DiscordProvider from "next-auth/providers/discord";
|
import GitHubProvider from "next-auth/providers/github";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
@@ -19,16 +19,15 @@ import { db } from "~/server/db";
|
|||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session extends DefaultSession {
|
interface Session extends DefaultSession {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: number;
|
||||||
// ...other properties
|
// ...other properties
|
||||||
// role: UserRole;
|
// role: UserRole;
|
||||||
} & DefaultSession["user"];
|
} & DefaultSession["user"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// interface User {
|
interface User {
|
||||||
// // ...other properties
|
id: number;
|
||||||
// // role: UserRole;
|
}
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +56,10 @@ export const authOptions: NextAuthOptions = {
|
|||||||
*
|
*
|
||||||
* @see https://next-auth.js.org/providers/github
|
* @see https://next-auth.js.org/providers/github
|
||||||
*/
|
*/
|
||||||
|
GitHubProvider({
|
||||||
|
clientId: env.GITHUB_ID,
|
||||||
|
clientSecret: env.GITHUB_SECRET,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,3 +69,15 @@ export const authOptions: NextAuthOptions = {
|
|||||||
* @see https://next-auth.js.org/configuration/nextjs
|
* @see https://next-auth.js.org/configuration/nextjs
|
||||||
*/
|
*/
|
||||||
export const getServerAuthSession = () => getServerSession(authOptions);
|
export const getServerAuthSession = () => getServerSession(authOptions);
|
||||||
|
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a token using SHA-256.
|
||||||
|
*
|
||||||
|
* @param {string} token - The token to be hashed.
|
||||||
|
* @returns {string} The hashed token.
|
||||||
|
*/
|
||||||
|
export function hashToken(token: string) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
95
apps/web/src/server/aws/setup.ts
Normal file
95
apps/web/src/server/aws/setup.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { JsonValue } from "@prisma/client/runtime/library";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { APP_SETTINGS } from "~/utils/constants";
|
||||||
|
import { createTopic, subscribeEndpoint } from "./sns";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { AppSettingsService } from "~/server/service/app-settings-service";
|
||||||
|
import { addWebhookConfiguration } from "../ses";
|
||||||
|
import { EventType } from "@aws-sdk/client-sesv2";
|
||||||
|
|
||||||
|
const GENERAL_EVENTS: EventType[] = [
|
||||||
|
"BOUNCE",
|
||||||
|
"COMPLAINT",
|
||||||
|
"DELIVERY",
|
||||||
|
"DELIVERY_DELAY",
|
||||||
|
"REJECT",
|
||||||
|
"RENDERING_FAILURE",
|
||||||
|
"SEND",
|
||||||
|
"SUBSCRIPTION",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function setupAws() {
|
||||||
|
AppSettingsService.initializeCache();
|
||||||
|
let snsTopicArn = await AppSettingsService.getSetting(
|
||||||
|
APP_SETTINGS.SNS_TOPIC_ARN
|
||||||
|
);
|
||||||
|
console.log("Setting up AWS");
|
||||||
|
|
||||||
|
if (!snsTopicArn) {
|
||||||
|
console.log("SNS topic not present, creating...");
|
||||||
|
snsTopicArn = await createUnsendSNSTopic();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupSESConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUnsendSNSTopic() {
|
||||||
|
const topicArn = await createTopic(env.SNS_TOPIC);
|
||||||
|
if (!topicArn) {
|
||||||
|
console.error("Failed to create SNS topic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscribeEndpoint(
|
||||||
|
topicArn,
|
||||||
|
`${env.APP_URL ?? env.NEXTAUTH_URL}/api/ses_callback`
|
||||||
|
);
|
||||||
|
|
||||||
|
return await AppSettingsService.setSetting(
|
||||||
|
APP_SETTINGS.SNS_TOPIC_ARN,
|
||||||
|
topicArn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupSESConfiguration() {
|
||||||
|
const topicArn = (
|
||||||
|
await AppSettingsService.getSetting(APP_SETTINGS.SNS_TOPIC_ARN)
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
|
if (!topicArn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Setting up SES webhook configuration");
|
||||||
|
|
||||||
|
await setWebhookConfiguration(
|
||||||
|
APP_SETTINGS.SES_CONFIGURATION_GENERAL,
|
||||||
|
topicArn,
|
||||||
|
GENERAL_EVENTS
|
||||||
|
);
|
||||||
|
|
||||||
|
await setWebhookConfiguration(
|
||||||
|
APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING,
|
||||||
|
topicArn,
|
||||||
|
[...GENERAL_EVENTS, "CLICK"]
|
||||||
|
);
|
||||||
|
|
||||||
|
await setWebhookConfiguration(
|
||||||
|
APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING,
|
||||||
|
topicArn,
|
||||||
|
[...GENERAL_EVENTS, "OPEN"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setWebhookConfiguration(
|
||||||
|
setting: string,
|
||||||
|
topicArn: string,
|
||||||
|
eventTypes: EventType[]
|
||||||
|
) {
|
||||||
|
const sesConfigurationGeneral = await AppSettingsService.getSetting(setting);
|
||||||
|
|
||||||
|
if (!sesConfigurationGeneral) {
|
||||||
|
console.log(`Setting up SES webhook configuration for ${setting}`);
|
||||||
|
const status = await addWebhookConfiguration(setting, topicArn, eventTypes);
|
||||||
|
await AppSettingsService.setSetting(setting, status.toString());
|
||||||
|
}
|
||||||
|
}
|
39
apps/web/src/server/aws/sns.ts
Normal file
39
apps/web/src/server/aws/sns.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
SNSClient,
|
||||||
|
CreateTopicCommand,
|
||||||
|
SubscribeCommand,
|
||||||
|
} from "@aws-sdk/client-sns";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
function getSnsClient(region = "us-east-1") {
|
||||||
|
return new SNSClient({
|
||||||
|
region: region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.AWS_ACCESS_KEY,
|
||||||
|
secretAccessKey: env.AWS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTopic(topic: string) {
|
||||||
|
const client = getSnsClient();
|
||||||
|
const command = new CreateTopicCommand({
|
||||||
|
Name: topic,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await client.send(command);
|
||||||
|
return data.TopicArn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeEndpoint(topicArn: string, endpointUrl: string) {
|
||||||
|
const subscribeCommand = new SubscribeCommand({
|
||||||
|
Protocol: "https",
|
||||||
|
TopicArn: topicArn,
|
||||||
|
Endpoint: endpointUrl,
|
||||||
|
});
|
||||||
|
const client = getSnsClient();
|
||||||
|
|
||||||
|
const data = await client.send(subscribeCommand);
|
||||||
|
console.log(data.SubscriptionArn);
|
||||||
|
return data.SubscriptionArn;
|
||||||
|
}
|
59
apps/web/src/server/service/api-service.ts
Normal file
59
apps/web/src/server/service/api-service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ApiPermission } from "@prisma/client";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { hashToken } from "../auth";
|
||||||
|
|
||||||
|
export async function addApiKey({
|
||||||
|
name,
|
||||||
|
permission,
|
||||||
|
teamId,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
permission: ApiPermission;
|
||||||
|
teamId: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const token = `us_${randomBytes(20).toString("hex")}`;
|
||||||
|
const hashedToken = hashToken(token);
|
||||||
|
|
||||||
|
await db.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
permission: permission,
|
||||||
|
teamId,
|
||||||
|
tokenHash: hashedToken,
|
||||||
|
partialToken: `${token.slice(0, 8)}...${token.slice(-5)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding API key:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retrieveApiKey(token: string) {
|
||||||
|
const hashedToken = hashToken(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = await db.apiKey.findUnique({
|
||||||
|
where: {
|
||||||
|
tokenHash: hashedToken,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
permission: true,
|
||||||
|
teamId: true,
|
||||||
|
partialToken: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("API Key not found");
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error retrieving API key:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
38
apps/web/src/server/service/app-settings-service.ts
Normal file
38
apps/web/src/server/service/app-settings-service.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import { JsonValue } from "@prisma/client/runtime/library";
|
||||||
|
|
||||||
|
export class AppSettingsService {
|
||||||
|
private static cache: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
|
public static async getSetting(key: string) {
|
||||||
|
if (!this.cache[key]) {
|
||||||
|
const setting = await db.appSetting.findUnique({
|
||||||
|
where: { key },
|
||||||
|
});
|
||||||
|
if (setting) {
|
||||||
|
this.cache[key] = setting.value;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.cache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async setSetting(key: string, value: string) {
|
||||||
|
await db.appSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value },
|
||||||
|
create: { key, value },
|
||||||
|
});
|
||||||
|
this.cache[key] = value;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async initializeCache(): Promise<void> {
|
||||||
|
const settings = await db.appSetting.findMany();
|
||||||
|
settings.forEach((setting) => {
|
||||||
|
this.cache[setting.key] = setting.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
69
apps/web/src/server/service/domain-service.ts
Normal file
69
apps/web/src/server/service/domain-service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { addDomain, getDomainIdentity } from "~/server/ses";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
export async function createDomain(teamId: number, name: string) {
|
||||||
|
console.log("Creating domain:", name);
|
||||||
|
const publicKey = await addDomain(name);
|
||||||
|
|
||||||
|
const domain = await db.domain.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
publicKey,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDomain(id: number) {
|
||||||
|
let domain = await db.domain.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error("Domain not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.status !== "SUCCESS") {
|
||||||
|
const domainIdentity = await getDomainIdentity(domain.name, domain.region);
|
||||||
|
|
||||||
|
const dkimStatus = domainIdentity.DkimAttributes?.Status;
|
||||||
|
const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
|
||||||
|
const verificationError = domainIdentity.VerificationInfo?.ErrorType;
|
||||||
|
const verificationStatus = domainIdentity.VerificationStatus;
|
||||||
|
const lastCheckedTime =
|
||||||
|
domainIdentity.VerificationInfo?.LastCheckedTimestamp;
|
||||||
|
|
||||||
|
console.log(domainIdentity);
|
||||||
|
|
||||||
|
if (
|
||||||
|
domain.dkimStatus !== dkimStatus ||
|
||||||
|
domain.spfDetails !== spfDetails ||
|
||||||
|
domain.status !== verificationStatus
|
||||||
|
) {
|
||||||
|
domain = await db.domain.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
dkimStatus,
|
||||||
|
spfDetails,
|
||||||
|
status: verificationStatus ?? "NOT_STARTED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...domain,
|
||||||
|
dkimStatus,
|
||||||
|
spfDetails,
|
||||||
|
verificationError,
|
||||||
|
lastCheckedTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
49
apps/web/src/server/service/email-service.ts
Normal file
49
apps/web/src/server/service/email-service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { EmailContent } from "~/types";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { sendEmailThroughSes } from "../ses";
|
||||||
|
|
||||||
|
export async function sendEmail(
|
||||||
|
emailContent: EmailContent & { teamId: number }
|
||||||
|
) {
|
||||||
|
const { to, from, subject, text, html, teamId } = emailContent;
|
||||||
|
|
||||||
|
const domains = await db.domain.findMany({ where: { teamId } });
|
||||||
|
|
||||||
|
const fromDomain = from.split("@")[1];
|
||||||
|
if (!fromDomain) {
|
||||||
|
throw new Error("From email is not valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = domains.find((domain) => domain.name === fromDomain);
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error("Domain not found. Add domain to unsend first");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.status !== "SUCCESS") {
|
||||||
|
throw new Error("Domain is not verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await sendEmailThroughSes({
|
||||||
|
to,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
region: domain.region,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
return await db.email.create({
|
||||||
|
data: {
|
||||||
|
to,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
id: messageId,
|
||||||
|
teamId,
|
||||||
|
domainId: domain.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
176
apps/web/src/server/ses.ts
Normal file
176
apps/web/src/server/ses.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
SESv2Client,
|
||||||
|
CreateEmailIdentityCommand,
|
||||||
|
DeleteEmailIdentityCommand,
|
||||||
|
GetEmailIdentityCommand,
|
||||||
|
PutEmailIdentityMailFromAttributesCommand,
|
||||||
|
SendEmailCommand,
|
||||||
|
CreateConfigurationSetEventDestinationCommand,
|
||||||
|
CreateConfigurationSetCommand,
|
||||||
|
EventType,
|
||||||
|
} from "@aws-sdk/client-sesv2";
|
||||||
|
import { generateKeyPairSync } from "crypto";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { EmailContent } from "~/types";
|
||||||
|
import { APP_SETTINGS } from "~/utils/constants";
|
||||||
|
|
||||||
|
function getSesClient(region = "us-east-1") {
|
||||||
|
return new SESv2Client({
|
||||||
|
region: region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.AWS_ACCESS_KEY,
|
||||||
|
secretAccessKey: env.AWS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateKeyPair() {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048, // Length of your key in bits
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: "spki", // Recommended to be 'spki' by the Node.js docs
|
||||||
|
format: "pem",
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: "pkcs8", // Recommended to be 'pkcs8' by the Node.js docs
|
||||||
|
format: "pem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const base64PrivateKey = privateKey
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replace(/\n/g, "");
|
||||||
|
|
||||||
|
const base64PublicKey = publicKey
|
||||||
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replace(/\n/g, "");
|
||||||
|
|
||||||
|
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addDomain(domain: string, region = "us-east-1") {
|
||||||
|
const sesClient = getSesClient(region);
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = generateKeyPair();
|
||||||
|
const command = new CreateEmailIdentityCommand({
|
||||||
|
EmailIdentity: domain,
|
||||||
|
DkimSigningAttributes: {
|
||||||
|
DomainSigningSelector: "unsend",
|
||||||
|
DomainSigningPrivateKey: privateKey,
|
||||||
|
},
|
||||||
|
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
|
||||||
|
});
|
||||||
|
const response = await sesClient.send(command);
|
||||||
|
|
||||||
|
const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({
|
||||||
|
EmailIdentity: domain,
|
||||||
|
MailFromDomain: `send.${domain}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailIdentityResponse = await sesClient.send(emailIdentityCommand);
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.$metadata.httpStatusCode !== 200 ||
|
||||||
|
emailIdentityResponse.$metadata.httpStatusCode !== 200
|
||||||
|
) {
|
||||||
|
throw new Error("Failed to create email identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDomainIdentity(domain: string, region = "us-east-1") {
|
||||||
|
const sesClient = getSesClient(region);
|
||||||
|
const command = new GetEmailIdentityCommand({
|
||||||
|
EmailIdentity: domain,
|
||||||
|
});
|
||||||
|
const response = await sesClient.send(command);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailThroughSes({
|
||||||
|
to,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
region = "us-east-1",
|
||||||
|
}: EmailContent & {
|
||||||
|
region?: string;
|
||||||
|
}) {
|
||||||
|
const sesClient = getSesClient(region);
|
||||||
|
const command = new SendEmailCommand({
|
||||||
|
FromEmailAddress: from,
|
||||||
|
Destination: {
|
||||||
|
ToAddresses: [to],
|
||||||
|
},
|
||||||
|
Content: {
|
||||||
|
// EmailContent
|
||||||
|
Simple: {
|
||||||
|
// Message
|
||||||
|
Subject: {
|
||||||
|
// Content
|
||||||
|
Data: subject, // required
|
||||||
|
Charset: "UTF-8",
|
||||||
|
},
|
||||||
|
Body: {
|
||||||
|
// Body
|
||||||
|
Text: {
|
||||||
|
Data: text, // required
|
||||||
|
Charset: "UTF-8",
|
||||||
|
},
|
||||||
|
Html: {
|
||||||
|
Data: html, // required
|
||||||
|
Charset: "UTF-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sesClient.send(command);
|
||||||
|
console.log("Email sent! Message ID:", response.MessageId);
|
||||||
|
return response.MessageId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email", error);
|
||||||
|
throw new Error("Failed to send email");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addWebhookConfiguration(
|
||||||
|
configName: string,
|
||||||
|
topicArn: string,
|
||||||
|
eventTypes: EventType[],
|
||||||
|
region = "us-east-1"
|
||||||
|
) {
|
||||||
|
const sesClient = getSesClient(region);
|
||||||
|
|
||||||
|
const configSetCommand = new CreateConfigurationSetCommand({
|
||||||
|
ConfigurationSetName: configName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configSetResponse = await sesClient.send(configSetCommand);
|
||||||
|
|
||||||
|
if (configSetResponse.$metadata.httpStatusCode !== 200) {
|
||||||
|
throw new Error("Failed to create configuration set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new CreateConfigurationSetEventDestinationCommand({
|
||||||
|
ConfigurationSetName: configName, // required
|
||||||
|
EventDestinationName: "unsend_destination", // required
|
||||||
|
EventDestination: {
|
||||||
|
// EventDestinationDefinition
|
||||||
|
Enabled: true,
|
||||||
|
MatchingEventTypes: eventTypes,
|
||||||
|
SnsDestination: {
|
||||||
|
TopicArn: topicArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await sesClient.send(command);
|
||||||
|
return response.$metadata.httpStatusCode === 200;
|
||||||
|
}
|
7
apps/web/src/types/index.ts
Normal file
7
apps/web/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type EmailContent = {
|
||||||
|
to: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
};
|
8
apps/web/src/utils/constants.ts
Normal file
8
apps/web/src/utils/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export const APP_SETTINGS = {
|
||||||
|
SNS_TOPIC_ARN: "SNS_TOPIC_ARN",
|
||||||
|
SES_CONFIGURATION_GENERAL: `SES_CONFIGURATION_GENERAL_${env.NODE_ENV}`,
|
||||||
|
SES_CONFIGURATION_CLICK_TRACKING: `SES_CONFIGURATION_CLICK_TRACKING_${env.NODE_ENV}`,
|
||||||
|
SES_CONFIGURATION_OPEN_TRACKING: `SES_CONFIGURATION_OPEN_TRACKING_${env.NODE_ENV}`,
|
||||||
|
};
|
@@ -3,15 +3,17 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"strictNullChecks": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
@@ -20,5 +22,7 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
14
package.json
14
package.json
@@ -3,15 +3,15 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"dev": "turbo dev --filter=web",
|
"dev": "pnpm load-env -- turbo dev --filter=web",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"db:generate": "pnpm db generate",
|
"db:generate": "pnpm db db:generate",
|
||||||
"db:push": "pnpm db push",
|
"db:push": "pnpm db db:push",
|
||||||
"db:migrate-dev": "pnpm db migrate-dev",
|
"db:migrate-dev": "pnpm db db:migrate-dev",
|
||||||
"db:migrate-deploy": "pnpm db migrate-deploy",
|
"db:migrate-deploy": "pnpm db db:migrate-deploy",
|
||||||
"db:studio": "pnpm db studio",
|
"db:studio": "pnpm db db:studio",
|
||||||
"db": "pnpm load-env -- pnpm --filter @unsend/db",
|
"db": "pnpm load-env -- pnpm --filter=web",
|
||||||
"load-env": "dotenv -e .env"
|
"load-env": "dotenv -e .env"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
3
packages/db/.gitignore
vendored
3
packages/db/.gitignore
vendored
@@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
# Keep environment variables out of version control
|
|
||||||
.env
|
|
@@ -1 +0,0 @@
|
|||||||
export * from "@prisma/client";
|
|
@@ -1,21 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// We need `var` to declare a global variable in TypeScript
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var prisma: PrismaClient | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!globalThis.prisma) {
|
|
||||||
globalThis.prisma = new PrismaClient({
|
|
||||||
datasourceUrl: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma =
|
|
||||||
globalThis.prisma ||
|
|
||||||
new PrismaClient({
|
|
||||||
datasourceUrl: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPrismaClient = () => prisma;
|
|
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@unsend/db",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"main": "./index.ts",
|
|
||||||
"types": "./index.ts",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/client": "^5.11.0",
|
|
||||||
"prisma": "^5.11.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"post-install": "prisma generate",
|
|
||||||
"generate": "prisma generate",
|
|
||||||
"push": "prisma db push --skip-generate",
|
|
||||||
"migrate-dev": "prisma migrate dev",
|
|
||||||
"migrate-deploy": "prisma migrate deploy",
|
|
||||||
"studio": "prisma studio"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,73 +0,0 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
|
|
||||||
// Further reading:
|
|
||||||
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
|
|
||||||
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Post {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
createdBy User @relation(fields: [createdById], references: [id])
|
|
||||||
createdById String
|
|
||||||
|
|
||||||
@@index([name])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Necessary for Next auth
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
refresh_token String? // @db.Text
|
|
||||||
access_token String? // @db.Text
|
|
||||||
expires_at Int?
|
|
||||||
token_type String?
|
|
||||||
scope String?
|
|
||||||
id_token String? // @db.Text
|
|
||||||
session_state String?
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
sessionToken String @unique
|
|
||||||
userId String
|
|
||||||
expires DateTime
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String?
|
|
||||||
email String? @unique
|
|
||||||
emailVerified DateTime?
|
|
||||||
image String?
|
|
||||||
accounts Account[]
|
|
||||||
sessions Session[]
|
|
||||||
posts Post[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model VerificationToken {
|
|
||||||
identifier String
|
|
||||||
token String @unique
|
|
||||||
expires DateTime
|
|
||||||
|
|
||||||
@@unique([identifier, token])
|
|
||||||
}
|
|
@@ -27,6 +27,8 @@
|
|||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
122
packages/ui/src/dialog.tsx
Normal file
122
packages/ui/src/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
25
packages/ui/src/input.tsx
Normal file
25
packages/ui/src/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
26
packages/ui/src/label.tsx
Normal file
26
packages/ui/src/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
1595
pnpm-lock.yaml
generated
1595
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user