Skip to content

Commit 1a5ad11

Browse files
committed
feat (auth): implement Google and GitHub sign up and log in supporting multi dev environments
1 parent 26101cd commit 1a5ad11

18 files changed

Lines changed: 2710 additions & 334 deletions

File tree

frontend/app/[locale]/login/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
77
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
88
import { Button } from "@/components/ui/button";
9+
import { OAuthButtons } from '@/components/auth/OAuthButtons';
910

1011
export default function LoginPage() {
1112
const searchParams = useSearchParams();
@@ -82,6 +83,14 @@ export default function LoginPage() {
8283
<div className="mx-auto max-w-sm py-12">
8384
<h1 className="mb-6 text-2xl font-semibold">Log in</h1>
8485

86+
<OAuthButtons />
87+
88+
<div className="my-4 flex items-center gap-3">
89+
<div className="h-px flex-1 bg-gray-200" />
90+
<span className="text-xs text-gray-500">or</span>
91+
<div className="h-px flex-1 bg-gray-200" />
92+
</div>
93+
8594
<form onSubmit={onSubmit} className="space-y-4">
8695
<input
8796
name="email"

frontend/app/[locale]/signup/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
77
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
88
import { Button } from "@/components/ui/button";
9+
import { OAuthButtons } from '@/components/auth/OAuthButtons';
910

1011
type FormError = string | Record<string, string[]>;
1112

@@ -94,6 +95,14 @@ export default function SignupPage() {
9495
<div className="mx-auto max-w-sm py-12">
9596
<h1 className="mb-6 text-2xl font-semibold">Create account</h1>
9697

98+
<OAuthButtons />
99+
100+
<div className="my-4 flex items-center gap-3">
101+
<div className="h-px flex-1 bg-gray-200" />
102+
<span className="text-xs text-gray-500">or</span>
103+
<div className="h-px flex-1 bg-gray-200" />
104+
</div>
105+
97106
<form onSubmit={onSubmit} className="space-y-4">
98107
<input
99108
name="name"
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { eq } from "drizzle-orm";
3+
4+
import { db } from "@/db";
5+
import { users } from "@/db/schema/users";
6+
import { signAuthToken, setAuthCookie } from "@/lib/auth";
7+
import { env } from "@/lib/env";
8+
9+
type GithubTokenResponse = {
10+
access_token: string;
11+
token_type: string;
12+
scope: string;
13+
};
14+
15+
type GithubUser = {
16+
id: number;
17+
login: string;
18+
name: string | null;
19+
avatar_url: string;
20+
};
21+
22+
type GithubEmail = {
23+
email: string;
24+
primary: boolean;
25+
verified: boolean;
26+
};
27+
28+
export async function GET(req: NextRequest) {
29+
const code = req.nextUrl.searchParams.get("code");
30+
31+
if (!code) {
32+
return NextResponse.redirect(new URL("/login", req.url));
33+
}
34+
35+
const tokenRes = await fetch(
36+
"https://github.com/login/oauth/access_token",
37+
{
38+
method: "POST",
39+
headers: {
40+
Accept: "application/json",
41+
},
42+
body: new URLSearchParams({
43+
client_id: env.github.clientId!,
44+
client_secret: env.github.clientSecret!,
45+
code,
46+
redirect_uri: env.github.redirectUri!,
47+
}),
48+
}
49+
);
50+
51+
if (!tokenRes.ok) {
52+
console.error(
53+
"GitHub token exchange failed",
54+
await tokenRes.text()
55+
);
56+
return NextResponse.redirect(new URL("/login", req.url));
57+
}
58+
59+
const tokenData = (await tokenRes.json()) as GithubTokenResponse;
60+
61+
const userRes = await fetch("https://api.github.com/user", {
62+
headers: {
63+
Authorization: `Bearer ${tokenData.access_token}`,
64+
},
65+
});
66+
67+
if (!userRes.ok) {
68+
return NextResponse.redirect(new URL("/login", req.url));
69+
}
70+
71+
const ghUser = (await userRes.json()) as GithubUser;
72+
73+
const emailsRes = await fetch("https://api.github.com/user/emails", {
74+
headers: {
75+
Authorization: `Bearer ${tokenData.access_token}`,
76+
},
77+
});
78+
79+
if (!emailsRes.ok) {
80+
return NextResponse.redirect(new URL("/login", req.url));
81+
}
82+
83+
const emails = (await emailsRes.json()) as GithubEmail[];
84+
85+
const primaryEmail = emails.find(
86+
(e) => e.primary && e.verified
87+
)?.email;
88+
89+
if (!primaryEmail) {
90+
return NextResponse.redirect(new URL("/login", req.url));
91+
}
92+
93+
const githubId = String(ghUser.id);
94+
let user = null;
95+
96+
const [githubUser] = await db
97+
.select()
98+
.from(users)
99+
.where(eq(users.providerId, githubId))
100+
.limit(1);
101+
102+
if (githubUser) {
103+
user = githubUser;
104+
} else {
105+
const [emailUser] = await db
106+
.select()
107+
.from(users)
108+
.where(eq(users.email, primaryEmail))
109+
.limit(1);
110+
111+
if (emailUser) {
112+
await db
113+
.update(users)
114+
.set({
115+
provider: "github",
116+
providerId: githubId,
117+
emailVerified: emailUser.emailVerified ?? new Date(),
118+
image: emailUser.image ?? ghUser.avatar_url,
119+
name: emailUser.name ?? ghUser.name ?? ghUser.login,
120+
})
121+
.where(eq(users.id, emailUser.id));
122+
123+
user = emailUser;
124+
} else {
125+
const [created] = await db
126+
.insert(users)
127+
.values({
128+
email: primaryEmail,
129+
name: ghUser.name ?? ghUser.login,
130+
image: ghUser.avatar_url,
131+
provider: "github",
132+
providerId: githubId,
133+
emailVerified: new Date(),
134+
})
135+
.returning();
136+
137+
user = created;
138+
}
139+
}
140+
141+
const token = signAuthToken({
142+
userId: user.id,
143+
email: user.email,
144+
role: user.role === "admin" ? "admin" : "user",
145+
});
146+
147+
await setAuthCookie(token);
148+
149+
return NextResponse.redirect(new URL("/", req.url));
150+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { env } from "@/lib/env";
2+
import { NextResponse } from "next/server";
3+
4+
export async function GET() {
5+
const params = new URLSearchParams({
6+
client_id: env.github.clientId!,
7+
redirect_uri: env.github.redirectUri!,
8+
scope: "user:email",
9+
});
10+
11+
return NextResponse.redirect(
12+
`https://github.com/login/oauth/authorize?${params.toString()}`
13+
);
14+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { eq } from "drizzle-orm";
3+
4+
import { db } from "@/db";
5+
import { users } from "@/db/schema/users";
6+
import { signAuthToken, setAuthCookie } from "@/lib/auth";
7+
import { env } from "@/lib/env";
8+
9+
type GoogleTokenResponse = {
10+
access_token: string;
11+
expires_in: number;
12+
token_type: string;
13+
scope: string;
14+
};
15+
16+
type GoogleProfile = {
17+
id: string;
18+
email: string;
19+
verified_email: boolean;
20+
name: string;
21+
picture: string;
22+
};
23+
24+
export async function GET(req: NextRequest) {
25+
const code = req.nextUrl.searchParams.get("code");
26+
27+
if (!code) {
28+
return NextResponse.redirect(new URL("/login", req.url));
29+
}
30+
31+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
32+
method: "POST",
33+
headers: {
34+
"Content-Type": "application/x-www-form-urlencoded",
35+
},
36+
body: new URLSearchParams({
37+
code,
38+
client_id: env.google.clientId!,
39+
client_secret: env.google.clientSecret!,
40+
redirect_uri: env.google.redirectUri!,
41+
grant_type: "authorization_code",
42+
}),
43+
});
44+
45+
if (!tokenRes.ok) {
46+
return NextResponse.redirect(new URL("/login", req.url));
47+
}
48+
49+
const tokenData = (await tokenRes.json()) as GoogleTokenResponse;
50+
51+
const profileRes = await fetch(
52+
"https://www.googleapis.com/oauth2/v2/userinfo",
53+
{
54+
headers: {
55+
Authorization: `Bearer ${tokenData.access_token}`,
56+
},
57+
}
58+
);
59+
60+
if (!profileRes.ok) {
61+
return NextResponse.redirect(new URL("/login", req.url));
62+
}
63+
64+
const profile = (await profileRes.json()) as GoogleProfile;
65+
66+
const email = profile.email;
67+
const googleId = profile.id;
68+
69+
let user = null;
70+
71+
const [googleUser] = await db
72+
.select()
73+
.from(users)
74+
.where(eq(users.providerId, googleId))
75+
.limit(1);
76+
77+
if (googleUser) {
78+
user = googleUser;
79+
} else {
80+
81+
const [emailUser] = await db
82+
.select()
83+
.from(users)
84+
.where(eq(users.email, email))
85+
.limit(1);
86+
87+
if (emailUser) {
88+
await db
89+
.update(users)
90+
.set({
91+
provider: "google",
92+
providerId: googleId,
93+
emailVerified: emailUser.emailVerified ?? new Date(),
94+
image: emailUser.image ?? profile.picture,
95+
name: emailUser.name ?? profile.name,
96+
})
97+
.where(eq(users.id, emailUser.id));
98+
99+
user = emailUser;
100+
} else {
101+
102+
const [created] = await db
103+
.insert(users)
104+
.values({
105+
email,
106+
name: profile.name,
107+
image: profile.picture,
108+
provider: "google",
109+
providerId: googleId,
110+
emailVerified: new Date(),
111+
})
112+
.returning();
113+
114+
user = created;
115+
}
116+
}
117+
118+
119+
const token = signAuthToken({
120+
userId: user.id,
121+
email: user.email,
122+
role: user.role === "admin" ? "admin" : "user",
123+
});
124+
125+
await setAuthCookie(token);
126+
127+
return NextResponse.redirect(new URL("/", req.url));
128+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { env } from "@/lib/env";
2+
import { NextResponse } from "next/server";
3+
4+
export async function GET() {
5+
const params = new URLSearchParams({
6+
client_id: env.google.clientId!,
7+
redirect_uri: env.google.redirectUri!,
8+
response_type: "code",
9+
scope: "openid email profile",
10+
prompt: "select_account",
11+
});
12+
13+
return NextResponse.redirect(
14+
`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`
15+
);
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ProviderButton } from "./ProviderButton";
2+
import { GoogleIcon } from "./icons/GoogleIcon";
3+
import { GitHubIcon } from "./icons/GitHubIcon";
4+
5+
export function OAuthButtons() {
6+
return (
7+
<div className="space-y-2">
8+
<ProviderButton
9+
provider="google"
10+
label="Continue with Google"
11+
icon={<GoogleIcon className="h-4 w-4" />}
12+
/>
13+
14+
<ProviderButton
15+
provider="github"
16+
label="Continue with GitHub"
17+
icon={<GitHubIcon className="h-4 w-4" />}
18+
/>
19+
</div>
20+
);
21+
}

0 commit comments

Comments
 (0)