Skip to content

Commit 7d329db

Browse files
committed
chore: implement next-auth for authentication
- Add `next-auth` to drizzle schema in `packages/database` - Remove `declaration` and `declarationMap` due to `next-auth` type errors (see nextauthjs/next-auth#10568) - Add `auth.ts` to `apps/client/src/lib` for `next-auth` initialization and config - Add `api/auth/[...nextauth]/route.ts` to `apps/client/src/app` for `next-auth` API routes - Add `auth` route group and pages to `apps/client/src/app` for `next-auth` pages (signin, signup, verify-request) - Add `OAuthButton` and `OAuthIcon` components to `apps/client/src/app/(auth)/_components` for `next-auth` OAuth providers
1 parent 70e72e5 commit 7d329db

39 files changed

+1605
-473
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@ DB_PORT=5432
1010
DB_NAME=db_name
1111
DB_URL=postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME # Using `@next/env` to reference other variables
1212

13+
## Auth
14+
AUTH_SECRET=N0T+4+REAL+S3CR3T+0123456789ABCDEFGHIJKLMNOP # Generated by `openssl rand -base64 64`
15+
AUTH_GITHUB_ID=Ov23liN0tR34lID
16+
AUTH_GITHUB_SECRET=secret
17+
AUTH_DISCORD_ID=123456789012345678
18+
AUTH_DISCORD_SECRET=secret
19+
AUTH_SENDGRID_KEY=SG.N0T4R34LAP1V4L1Dk3Y.0NLY4N3X4MPL3K3Y
20+
1321
# Client
1422
NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY=1x00000000000000000000AA

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v22.2.0
1+
v22.3.0

apps/client/next.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const componentDirectories = (
99
await readdir("src/components", { withFileTypes: true })
1010
).reduce(
1111
(acc, d) => (d.isDirectory() ? [...acc, `@/components/${d.name}`] : acc),
12-
[],
12+
/** @type {string[]} */ ([]),
1313
);
1414

1515
/** @type {import('next').NextConfig} */

apps/client/package.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,28 @@
1010
"lint": "eslint --cache --cache-location .next/cache/eslint/ ."
1111
},
1212
"dependencies": {
13+
"@auth/drizzle-adapter": "^1.4.1",
1314
"@defraud/database": "workspace:*",
1415
"@defraud/ui": "workspace:*",
1516
"@next/bundle-analyzer": "rc",
16-
"@radix-ui/react-accessible-icon": "^1.0.3",
17-
"@radix-ui/react-switch": "^1.0.3",
17+
"@radix-ui/react-accessible-icon": "^1.1.0",
18+
"@radix-ui/react-switch": "^1.1.0",
1819
"@t3-oss/env-nextjs": "^0.10.1",
1920
"@vercel/analytics": "^1.3.1",
20-
"@vercel/speed-insights": "^1.0.11",
21+
"@vercel/speed-insights": "^1.0.12",
22+
"class-variance-authority": "^0.7.0",
2123
"dayjs": "^1.11.11",
2224
"immer": "^10.1.1",
2325
"keyv": "^4.5.4",
2426
"ky": "^1.3.0",
25-
"lucide-react": "^0.390.0",
27+
"lucide-react": "^0.396.0",
2628
"next": "rc",
29+
"next-auth": "beta",
2730
"next-themes": "^0.3.0",
31+
"nodemailer": "^6.9.14",
2832
"react": "rc",
2933
"react-dom": "rc",
30-
"react-hook-form": "^7.51.5",
34+
"react-hook-form": "^7.52.0",
3135
"swr": "beta",
3236
"zod": "^3.23.8",
3337
"zustand": "^4.5.2"
@@ -37,13 +41,13 @@
3741
"@defraud/tsconfig": "workspace:*",
3842
"@keyv/redis": "^2.8.5",
3943
"@types/cloudflare-turnstile": "^0.1.5",
40-
"@types/node": "^20.14.2",
44+
"@types/node": "^20.14.7",
4145
"@types/react": "npm:types-react@rc",
4246
"@types/react-dom": "npm:types-react-dom@rc",
4347
"babel-plugin-react-compiler": "0.0.0-experimental-938cd9a-20240601",
4448
"cross-env": "^7.0.3",
4549
"eslint": "^8.57.0",
4650
"tailwindcss": "^3.4.4",
47-
"typescript": "rc"
51+
"typescript": "^5.5.2"
4852
}
4953
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { cva, type VariantProps } from "class-variance-authority";
2+
3+
import { Button, type ButtonProps } from "@defraud/ui/components";
4+
import { type ProviderDetail } from "@/lib/auth";
5+
import { OAuthIcon } from "./OAuthIcon";
6+
7+
const oAuthButtonVariants = cva<{
8+
id: Record<ProviderDetail["id"], string>;
9+
}>(undefined, {
10+
variants: {
11+
id: {
12+
github:
13+
"bg-gray-950 text-background hover:bg-gray-800 dark:bg-gray-50 dark:hover:bg-gray-200",
14+
discord: "bg-[#5865F2] text-white hover:bg-[#8891F2]",
15+
},
16+
},
17+
});
18+
19+
type OAuthButtonProps = ButtonProps & VariantProps<typeof oAuthButtonVariants>;
20+
21+
export const OAuthButton = ({
22+
id,
23+
className,
24+
children,
25+
...rest
26+
}: OAuthButtonProps) => {
27+
return (
28+
<Button className={oAuthButtonVariants({ id, className })} {...rest}>
29+
<OAuthIcon id={id} />
30+
{children}
31+
</Button>
32+
);
33+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { SVGProps } from "react";
2+
import { cva, type VariantProps } from "class-variance-authority";
3+
4+
import type { ProviderDetail } from "@/lib/auth";
5+
6+
const oAuthIconVariants = cva<{
7+
id: Record<ProviderDetail["id"], string>;
8+
}>("mr-2 size-5", {
9+
variants: {
10+
id: {
11+
github: "fill-gray-50 dark:fill-gray-950",
12+
discord: "fill-white",
13+
},
14+
},
15+
});
16+
17+
type OAuthIconProps = VariantProps<typeof oAuthIconVariants> &
18+
SVGProps<SVGSVGElement>;
19+
20+
export const OAuthIcon = ({ id, className, ...props }: OAuthIconProps) => {
21+
switch (id) {
22+
case "github":
23+
return (
24+
// Taken from https://github.com/logos
25+
<svg
26+
viewBox="0 0 98 96"
27+
className={oAuthIconVariants({ id, className })}
28+
{...props}
29+
>
30+
<path
31+
fillRule="evenodd"
32+
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
33+
clipRule="evenodd"
34+
/>
35+
</svg>
36+
);
37+
case "discord":
38+
return (
39+
// Taken from https://discord.com/branding
40+
<svg
41+
xmlns="http://www.w3.org/2000/svg"
42+
className={oAuthIconVariants({ id, className })}
43+
viewBox="0 0 127.14 96.36"
44+
>
45+
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
46+
</svg>
47+
);
48+
49+
default:
50+
throw new Error(`Unknown provider type: ${id}`);
51+
}
52+
};

apps/client/src/app/(auth)/layout.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { ReactNode } from "react";
2+
3+
import { Navbar } from "@/components/layout";
4+
5+
const AuthLayout = ({ children }: { children: ReactNode }) => {
6+
return (
7+
<>
8+
<Navbar />
9+
{children}
10+
</>
11+
);
12+
};
13+
14+
export default AuthLayout;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Link from "next/link";
2+
3+
import {
4+
Button,
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
Input,
11+
Label,
12+
} from "@defraud/ui/components";
13+
import { Logo } from "@/components/misc/Logo";
14+
import { providerDetails, signIn, type ProviderDetail } from "@/lib/auth";
15+
import { OAuthButton } from "../_components/OAuthButton";
16+
17+
const SignInForm = ({ id, name, type }: ProviderDetail) => {
18+
switch (type) {
19+
case "email":
20+
return (
21+
<>
22+
<form
23+
className="flex flex-col gap-2"
24+
action={async (formData) => {
25+
"use server";
26+
await signIn(id, formData);
27+
}}
28+
>
29+
<Label htmlFor="email">Email</Label>
30+
<Input
31+
type="email"
32+
name="email"
33+
placeholder="postel@example.com"
34+
required
35+
/>
36+
37+
<Button type="submit" className="my-1">
38+
Sign in with Email
39+
</Button>
40+
</form>
41+
42+
<div
43+
role="separator"
44+
className="flex items-center text-muted-foreground before:grow before:border-b before:content-[''] after:grow after:border-b after:content-['']"
45+
>
46+
<span className="mx-4">OR</span>
47+
</div>
48+
</>
49+
);
50+
case "oauth":
51+
return (
52+
<form
53+
className="flex flex-col"
54+
action={async (formData) => {
55+
"use server";
56+
await signIn(id, formData);
57+
}}
58+
>
59+
<OAuthButton type="submit" id={id}>
60+
Sign in with {name}
61+
</OAuthButton>
62+
</form>
63+
);
64+
default:
65+
throw new Error(`Unknown provider type: ${type}`);
66+
}
67+
};
68+
69+
const SignIn = () => {
70+
return (
71+
<main className="grid place-content-center gap-8">
72+
<Logo className="mx-auto size-14" />
73+
<Card className="w-full max-w-sm">
74+
<CardHeader>
75+
<CardTitle className="text-2xl">Sign in</CardTitle>
76+
<CardDescription>
77+
Enter your email below to sign in to your account.
78+
</CardDescription>
79+
</CardHeader>
80+
<CardContent className="flex flex-col gap-4">
81+
{providerDetails.map((provider) => (
82+
<SignInForm key={provider.id} {...provider} />
83+
))}
84+
</CardContent>
85+
</Card>
86+
<div className="text-center text-sm">
87+
{"Don't have an account? "}
88+
<Link href="/signup" className="text-primary underline">
89+
Sign up
90+
</Link>
91+
</div>
92+
</main>
93+
);
94+
};
95+
96+
export default SignIn;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Link from "next/link";
2+
3+
import {
4+
Button,
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
Input,
11+
Label,
12+
} from "@defraud/ui/components";
13+
import { Logo } from "@/components/misc/Logo";
14+
import { providerDetails, signIn, type ProviderDetail } from "@/lib/auth";
15+
import { OAuthButton } from "../_components/OAuthButton";
16+
17+
const SignUpForm = ({ id, name, type }: ProviderDetail) => {
18+
switch (type) {
19+
case "email":
20+
return (
21+
<>
22+
<form
23+
className="flex flex-col gap-2"
24+
action={async (formData) => {
25+
"use server";
26+
await signIn(id, formData);
27+
}}
28+
>
29+
<Label htmlFor="email">Email</Label>
30+
<Input
31+
type="email"
32+
name="email"
33+
placeholder="postel@example.com"
34+
required
35+
/>
36+
37+
<Button type="submit" className="my-1">
38+
Sign up with Email
39+
</Button>
40+
</form>
41+
42+
<div
43+
role="separator"
44+
className="flex items-center text-muted-foreground before:grow before:border-b before:content-[''] after:grow after:border-b after:content-['']"
45+
>
46+
<span className="mx-4">OR</span>
47+
</div>
48+
</>
49+
);
50+
case "oauth":
51+
return (
52+
<form
53+
className="flex flex-col"
54+
action={async (formData) => {
55+
"use server";
56+
await signIn(id, formData);
57+
}}
58+
>
59+
<OAuthButton type="submit" id={id}>
60+
Sign up with {name}
61+
</OAuthButton>
62+
</form>
63+
);
64+
default:
65+
throw new Error(`Unknown provider type: ${type}`);
66+
}
67+
};
68+
69+
const SignUp = () => {
70+
return (
71+
<main className="grid place-content-center gap-8">
72+
<Logo className="mx-auto size-14" />
73+
<Card className="w-full max-w-sm">
74+
<CardHeader>
75+
<CardTitle className="text-2xl">Sign up</CardTitle>
76+
<CardDescription>
77+
Enter your email below to create your new account.
78+
</CardDescription>
79+
</CardHeader>
80+
<CardContent className="flex flex-col gap-4">
81+
{providerDetails.map((provider) => (
82+
<SignUpForm key={provider.id} {...provider} />
83+
))}
84+
</CardContent>
85+
</Card>
86+
<div className="text-center text-sm">
87+
{"Already have an account? "}
88+
<Link href="/signin" className="text-primary underline">
89+
Sign in
90+
</Link>
91+
</div>
92+
</main>
93+
);
94+
};
95+
96+
export default SignUp;

0 commit comments

Comments
 (0)