Skip to content

Commit 57fcfbc

Browse files
authored
Use scrypt for api keys (#33)
1 parent 1beced8 commit 57fcfbc

File tree

10 files changed

+219
-113
lines changed

10 files changed

+219
-113
lines changed

apps/web/prisma/migrations/20240619211232_init/migration.sql renamed to apps/web/prisma/migrations/20240626213358_init/migration.sql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED
88
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
99

1010
-- CreateEnum
11-
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED');
11+
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'DELIVERY_DELAYED', 'BOUNCED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERED', 'OPENED', 'CLICKED', 'COMPLAINED', 'FAILED');
1212

1313
-- CreateTable
1414
CREATE TABLE "AppSetting" (
@@ -132,6 +132,7 @@ CREATE TABLE "Domain" (
132132
-- CreateTable
133133
CREATE TABLE "ApiKey" (
134134
"id" SERIAL NOT NULL,
135+
"clientId" TEXT NOT NULL,
135136
"tokenHash" TEXT NOT NULL,
136137
"partialToken" TEXT NOT NULL,
137138
"name" TEXT NOT NULL,
@@ -180,6 +181,9 @@ CREATE TABLE "EmailEvent" (
180181
-- CreateIndex
181182
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");
182183

184+
-- CreateIndex
185+
CREATE UNIQUE INDEX "SesSetting_idPrefix_key" ON "SesSetting"("idPrefix");
186+
183187
-- CreateIndex
184188
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
185189

@@ -202,7 +206,7 @@ CREATE UNIQUE INDEX "TeamUser_teamId_userId_key" ON "TeamUser"("teamId", "userId
202206
CREATE UNIQUE INDEX "Domain_name_key" ON "Domain"("name");
203207

204208
-- CreateIndex
205-
CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash");
209+
CREATE UNIQUE INDEX "ApiKey_clientId_key" ON "ApiKey"("clientId");
206210

207211
-- CreateIndex
208212
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");

apps/web/prisma/schema.prisma

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ model AppSetting {
2222
model SesSetting {
2323
id String @id @default(cuid())
2424
region String @unique
25-
idPrefix String
25+
idPrefix String @unique
2626
topic String
2727
topicArn String?
2828
callbackUrl String
@@ -149,7 +149,8 @@ enum ApiPermission {
149149

150150
model ApiKey {
151151
id Int @id @default(autoincrement())
152-
tokenHash String @unique
152+
clientId String @unique
153+
tokenHash String
153154
partialToken String
154155
name String
155156
permission ApiPermission @default(SENDING)

apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,52 @@ import { api } from "~/trpc/react";
1616
import { useState } from "react";
1717
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
1818
import { toast } from "@unsend/ui/src/toaster";
19+
import { z } from "zod";
20+
import { useForm } from "react-hook-form";
21+
import { zodResolver } from "@hookform/resolvers/zod";
22+
import {
23+
Form,
24+
FormControl,
25+
FormDescription,
26+
FormField,
27+
FormItem,
28+
FormLabel,
29+
FormMessage,
30+
} from "@unsend/ui/src/form";
31+
32+
const apiKeySchema = z.object({
33+
name: z.string({ required_error: "Name is required" }).min(1, {
34+
message: "Name is required",
35+
}),
36+
});
1937

2038
export default function AddApiKey() {
2139
const [open, setOpen] = useState(false);
22-
const [name, setName] = useState("");
2340
const [apiKey, setApiKey] = useState("");
24-
const addDomainMutation = api.apiKey.createToken.useMutation();
41+
const createApiKeyMutation = api.apiKey.createToken.useMutation();
2542
const [isCopied, setIsCopied] = useState(false);
2643
const [showApiKey, setShowApiKey] = useState(false);
2744

2845
const utils = api.useUtils();
2946

30-
function handleSave() {
31-
addDomainMutation.mutate(
47+
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
48+
resolver: zodResolver(apiKeySchema),
49+
defaultValues: {
50+
name: "",
51+
},
52+
});
53+
54+
function handleSave(values: z.infer<typeof apiKeySchema>) {
55+
createApiKeyMutation.mutate(
3256
{
33-
name,
57+
name: values.name,
3458
permission: "FULL",
3559
},
3660
{
3761
onSuccess: (data) => {
3862
utils.apiKey.invalidate();
3963
setApiKey(data);
64+
apiKeyForm.reset();
4065
},
4166
}
4267
);
@@ -53,8 +78,8 @@ export default function AddApiKey() {
5378
function copyAndClose() {
5479
handleCopy();
5580
setApiKey("");
56-
setName("");
5781
setOpen(false);
82+
setShowApiKey(false);
5883
toast.success("API key copied to clipboard");
5984
}
6085

@@ -70,7 +95,7 @@ export default function AddApiKey() {
7095
</Button>
7196
</DialogTrigger>
7297
{apiKey ? (
73-
<DialogContent>
98+
<DialogContent key={apiKey}>
7499
<DialogHeader>
75100
<DialogTitle>Copy API key</DialogTitle>
76101
</DialogHeader>
@@ -80,7 +105,7 @@ export default function AddApiKey() {
80105
<p className="text-sm">{apiKey}</p>
81106
) : (
82107
<div className="flex gap-1">
83-
{Array.from({ length: 30 }).map((_, index) => (
108+
{Array.from({ length: 40 }).map((_, index) => (
84109
<div
85110
key={index}
86111
className="w-1 h-1 bg-muted-foreground rounded-lg"
@@ -120,7 +145,7 @@ export default function AddApiKey() {
120145
<Button
121146
type="submit"
122147
onClick={copyAndClose}
123-
disabled={addDomainMutation.isPending}
148+
disabled={createApiKeyMutation.isPending}
124149
>
125150
Close
126151
</Button>
@@ -132,27 +157,42 @@ export default function AddApiKey() {
132157
<DialogTitle>Create a new API key</DialogTitle>
133158
</DialogHeader>
134159
<div className="py-2">
135-
<Label htmlFor="name" className="text-right">
136-
API key name
137-
</Label>
138-
<Input
139-
id="name"
140-
placeholder="prod key"
141-
defaultValue=""
142-
className="col-span-3 mt-1"
143-
onChange={(e) => setName(e.target.value)}
144-
value={name}
145-
/>
160+
<Form {...apiKeyForm}>
161+
<form
162+
onSubmit={apiKeyForm.handleSubmit(handleSave)}
163+
className="space-y-8"
164+
>
165+
<FormField
166+
control={apiKeyForm.control}
167+
name="name"
168+
render={({ field, formState }) => (
169+
<FormItem>
170+
<FormLabel>API key name</FormLabel>
171+
<FormControl>
172+
<Input placeholder="prod key" {...field} />
173+
</FormControl>
174+
{formState.errors.name ? (
175+
<FormMessage />
176+
) : (
177+
<FormDescription>
178+
Use a name to easily identify this API key.
179+
</FormDescription>
180+
)}
181+
</FormItem>
182+
)}
183+
/>
184+
<div className="flex justify-end">
185+
<Button
186+
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
187+
type="submit"
188+
disabled={createApiKeyMutation.isPending}
189+
>
190+
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
191+
</Button>
192+
</div>
193+
</form>
194+
</Form>
146195
</div>
147-
<DialogFooter>
148-
<Button
149-
type="submit"
150-
onClick={handleSave}
151-
disabled={addDomainMutation.isPending}
152-
>
153-
Save changes
154-
</Button>
155-
</DialogFooter>
156196
</DialogContent>
157197
)}
158198
</Dialog>

apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,56 @@
22

33
import { Button } from "@unsend/ui/src/button";
44
import { Input } from "@unsend/ui/src/input";
5-
import { Label } from "@unsend/ui/src/label";
65
import {
76
Dialog,
87
DialogContent,
98
DialogDescription,
10-
DialogFooter,
119
DialogHeader,
1210
DialogTitle,
1311
DialogTrigger,
1412
} from "@unsend/ui/src/dialog";
1513
import { api } from "~/trpc/react";
1614
import React, { useState } from "react";
17-
import { ApiKey, Domain } from "@prisma/client";
15+
import { ApiKey } from "@prisma/client";
1816
import { toast } from "@unsend/ui/src/toaster";
1917
import { Trash2 } from "lucide-react";
18+
import { z } from "zod";
19+
import { useForm } from "react-hook-form";
20+
import { zodResolver } from "@hookform/resolvers/zod";
21+
import {
22+
Form,
23+
FormControl,
24+
FormDescription,
25+
FormField,
26+
FormItem,
27+
FormLabel,
28+
FormMessage,
29+
} from "@unsend/ui/src/form";
30+
31+
const apiKeySchema = z.object({
32+
name: z.string(),
33+
});
2034

2135
export const DeleteApiKey: React.FC<{
2236
apiKey: Partial<ApiKey> & { id: number };
2337
}> = ({ apiKey }) => {
2438
const [open, setOpen] = useState(false);
25-
const [domainName, setDomainName] = useState("");
2639
const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation();
2740

2841
const utils = api.useUtils();
2942

30-
function handleSave() {
43+
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
44+
resolver: zodResolver(apiKeySchema),
45+
});
46+
47+
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
48+
if (values.name !== apiKey.name) {
49+
apiKeyForm.setError("name", {
50+
message: "Name does not match",
51+
});
52+
return;
53+
}
54+
3155
deleteApiKeyMutation.mutate(
3256
{
3357
id: apiKey.id,
@@ -42,6 +66,8 @@ export const DeleteApiKey: React.FC<{
4266
);
4367
}
4468

69+
const name = apiKeyForm.watch("name");
70+
4571
return (
4672
<Dialog
4773
open={open}
@@ -62,29 +88,44 @@ export const DeleteApiKey: React.FC<{
6288
</DialogDescription>
6389
</DialogHeader>
6490
<div className="py-2">
65-
<Label htmlFor="name" className="text-right">
66-
Type <span className="text-primary">{apiKey.name}</span> to confirm
67-
</Label>
68-
<Input
69-
id="name"
70-
defaultValue=""
71-
className="mt-2"
72-
onChange={(e) => setDomainName(e.target.value)}
73-
value={domainName}
74-
/>
91+
<Form {...apiKeyForm}>
92+
<form
93+
onSubmit={apiKeyForm.handleSubmit(onDomainDelete)}
94+
className="space-y-4"
95+
>
96+
<FormField
97+
control={apiKeyForm.control}
98+
name="name"
99+
render={({ field, formState }) => (
100+
<FormItem>
101+
<FormLabel>name</FormLabel>
102+
<FormControl>
103+
<Input {...field} />
104+
</FormControl>
105+
{formState.errors.name ? (
106+
<FormMessage />
107+
) : (
108+
<FormDescription className=" text-transparent">
109+
.
110+
</FormDescription>
111+
)}
112+
</FormItem>
113+
)}
114+
/>
115+
<div className="flex justify-end">
116+
<Button
117+
type="submit"
118+
variant="destructive"
119+
disabled={
120+
deleteApiKeyMutation.isPending || apiKey.name !== name
121+
}
122+
>
123+
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
124+
</Button>
125+
</div>
126+
</form>
127+
</Form>
75128
</div>
76-
<DialogFooter>
77-
<Button
78-
type="submit"
79-
variant="destructive"
80-
onClick={handleSave}
81-
disabled={
82-
deleteApiKeyMutation.isPending || apiKey.name !== domainName
83-
}
84-
>
85-
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
86-
</Button>
87-
</DialogFooter>
88129
</DialogContent>
89130
</Dialog>
90131
);

apps/web/src/server/auth.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { PrismaAdapter } from "@auth/prisma-adapter";
22
import {
3-
AuthOptions,
43
getServerSession,
54
type DefaultSession,
65
type NextAuthOptions,
@@ -9,7 +8,9 @@ import { type Adapter } from "next-auth/adapters";
98
import GitHubProvider from "next-auth/providers/github";
109
import EmailProvider from "next-auth/providers/email";
1110
import GoogleProvider from "next-auth/providers/google";
11+
import { Provider } from "next-auth/providers/index";
1212

13+
import { sendSignUpEmail } from "~/server/mailer";
1314
import { env } from "~/env";
1415
import { db } from "~/server/db";
1516

@@ -116,17 +117,3 @@ export const authOptions: NextAuthOptions = {
116117
* @see https://next-auth.js.org/configuration/nextjs
117118
*/
118119
export const getServerAuthSession = () => getServerSession(authOptions);
119-
120-
import { createHash } from "crypto";
121-
import { sendSignUpEmail } from "./mailer";
122-
import { Provider } from "next-auth/providers/index";
123-
124-
/**
125-
* Hashes a token using SHA-256.
126-
*
127-
* @param {string} token - The token to be hashed.
128-
* @returns {string} The hashed token.
129-
*/
130-
export function hashToken(token: string) {
131-
return createHash("sha256").update(token).digest("hex");
132-
}

0 commit comments

Comments
 (0)