Skip to content

Commit 4dbb3d1

Browse files
authored
feat(auth): implement Google/GitHub SSO authentication (#32) (#40)
1 parent 7f34de7 commit 4dbb3d1

12 files changed

Lines changed: 347 additions & 20 deletions

File tree

.env.example

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,24 @@ LOG_LEVEL=info
22
DATABASE_URL=postgres://echo:echo@localhost:5433/echo
33
BETTER_AUTH_SECRET=bLQ7KwdL43LHBvssREslZOmrMrtPzS0z
44

5+
# Better Auth OAuth Configuration (Social Sign-In)
6+
# Callback URL format for providers:
7+
# http://localhost:3000/api/auth/callback/{provider}
8+
# Example callbacks:
9+
# http://localhost:3000/api/auth/callback/google
10+
# http://localhost:3000/api/auth/callback/github
11+
12+
# Google OAuth Configuration
13+
# 1. Create OAuth credentials in Google Cloud Console
14+
# 2. Add callback URL: http://localhost:3000/api/auth/callback/google
15+
# 3. Fill in client ID/secret below
16+
GOOGLE_CLIENT_ID=your_google_client_id_here
17+
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
18+
519
# GitHub OAuth Configuration
6-
# 1. 前往 https://github.com/settings/developers 创建 OAuth App
7-
# 2. Callback URL 设置为: http://localhost:3000/api/integrations/github/callback
8-
# 3. 将生成的 Client ID 和 Client Secret 填入下方
20+
# 1. Go to https://github.com/settings/developers and create an OAuth App
21+
# 2. Set callback URL: http://localhost:3000/api/auth/callback/github
22+
# 3. Fill in client ID/secret below
923
GITHUB_CLIENT_ID=your_client_id_here
1024
GITHUB_CLIENT_SECRET=your_client_secret_here
11-
NEXT_PUBLIC_APP_URL=http://localhost:3000
25+
NEXT_PUBLIC_APP_URL=http://localhost:3000

components/auth/login-form.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,16 @@ import { Button } from "@/components/ui/button";
3838
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3939
import { Input } from "@/components/ui/input";
4040
import { Label } from "@/components/ui/label";
41+
import { authClient } from "@/lib/auth/client";
4142
import { loginSchema, type LoginInput } from "@/lib/validations/auth";
4243

44+
function getSocialAuthErrorMessage(error: unknown, fallbackMessage: string) {
45+
if (error instanceof Error && error.message) {
46+
return error.message;
47+
}
48+
return fallbackMessage;
49+
}
50+
4351
export function LoginForm() {
4452
const router = useRouter();
4553
const [isLoading, setIsLoading] = useState(false);
@@ -51,6 +59,7 @@ export function LoginForm() {
5159
rememberMe: false,
5260
});
5361
const t = useTranslations("auth.login");
62+
const tSocial = useTranslations("auth.social");
5463

5564
// Clear potentially corrupted cookies on mount
5665
useEffect(() => {
@@ -121,6 +130,30 @@ export function LoginForm() {
121130
}
122131
};
123132

133+
const handleSocialSignIn = async (provider: "google" | "github") => {
134+
setFormError(null);
135+
setIsLoading(true);
136+
137+
try {
138+
const response = await authClient.signIn.social({
139+
provider,
140+
callbackURL: "/dashboard",
141+
});
142+
const socialError =
143+
response && typeof response === "object" && "error" in response
144+
? response.error
145+
: null;
146+
147+
if (socialError) {
148+
throw socialError;
149+
}
150+
} catch (error) {
151+
setFormError(getSocialAuthErrorMessage(error, tSocial("error")));
152+
} finally {
153+
setIsLoading(false);
154+
}
155+
};
156+
124157
return (
125158
<Card>
126159
<CardHeader>
@@ -194,6 +227,38 @@ export function LoginForm() {
194227
{isLoading ? t("submitting") : t("submitButton")}
195228
</Button>
196229

230+
<div className="relative">
231+
<div className="absolute inset-0 flex items-center">
232+
<span className="w-full border-t" />
233+
</div>
234+
<div className="relative flex justify-center text-xs uppercase">
235+
<span className="bg-card px-2 text-muted-foreground">{tSocial("or")}</span>
236+
</div>
237+
</div>
238+
239+
<div className="grid gap-2 sm:grid-cols-2">
240+
<Button
241+
type="button"
242+
variant="outline"
243+
disabled={isLoading}
244+
onClick={() => {
245+
void handleSocialSignIn("google");
246+
}}
247+
>
248+
{tSocial("google")}
249+
</Button>
250+
<Button
251+
type="button"
252+
variant="outline"
253+
disabled={isLoading}
254+
onClick={() => {
255+
void handleSocialSignIn("github");
256+
}}
257+
>
258+
{tSocial("github")}
259+
</Button>
260+
</div>
261+
197262
<p className="text-center text-sm text-muted-foreground">
198263
{t("noAccount")}
199264
<Link className="ml-1 text-primary hover:underline" href="/register">

components/auth/register-form.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,17 @@ import { Button } from "@/components/ui/button";
2626
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2727
import { Input } from "@/components/ui/input";
2828
import { Label } from "@/components/ui/label";
29+
import { authClient } from "@/lib/auth/client";
2930

3031
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3132

33+
function getSocialAuthErrorMessage(error: unknown, fallbackMessage: string) {
34+
if (error instanceof Error && error.message) {
35+
return error.message;
36+
}
37+
return fallbackMessage;
38+
}
39+
3240
export function RegisterForm() {
3341
const router = useRouter();
3442
const [isLoading, setIsLoading] = useState(false);
@@ -41,6 +49,7 @@ export function RegisterForm() {
4149
confirmPassword: "",
4250
});
4351
const t = useTranslations("auth.register");
52+
const tSocial = useTranslations("auth.social");
4453
const tValidation = useTranslations("auth.validation");
4554

4655
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -130,6 +139,30 @@ export function RegisterForm() {
130139
}
131140
};
132141

142+
const handleSocialSignIn = async (provider: "google" | "github") => {
143+
setFormError(null);
144+
setIsLoading(true);
145+
146+
try {
147+
const response = await authClient.signIn.social({
148+
provider,
149+
callbackURL: "/dashboard",
150+
});
151+
const socialError =
152+
response && typeof response === "object" && "error" in response
153+
? response.error
154+
: null;
155+
156+
if (socialError) {
157+
throw socialError;
158+
}
159+
} catch (error) {
160+
setFormError(getSocialAuthErrorMessage(error, tSocial("error")));
161+
} finally {
162+
setIsLoading(false);
163+
}
164+
};
165+
133166
return (
134167
<Card>
135168
<CardHeader>
@@ -217,6 +250,38 @@ export function RegisterForm() {
217250
{isLoading ? t("submitting") : t("submitButton")}
218251
</Button>
219252

253+
<div className="relative">
254+
<div className="absolute inset-0 flex items-center">
255+
<span className="w-full border-t" />
256+
</div>
257+
<div className="relative flex justify-center text-xs uppercase">
258+
<span className="bg-card px-2 text-muted-foreground">{tSocial("or")}</span>
259+
</div>
260+
</div>
261+
262+
<div className="grid gap-2 sm:grid-cols-2">
263+
<Button
264+
type="button"
265+
variant="outline"
266+
disabled={isLoading}
267+
onClick={() => {
268+
void handleSocialSignIn("google");
269+
}}
270+
>
271+
{tSocial("google")}
272+
</Button>
273+
<Button
274+
type="button"
275+
variant="outline"
276+
disabled={isLoading}
277+
onClick={() => {
278+
void handleSocialSignIn("github");
279+
}}
280+
>
281+
{tSocial("github")}
282+
</Button>
283+
</div>
284+
220285
<p className="text-center text-sm text-muted-foreground">
221286
{t("hasAccount")}
222287
<Link className="ml-1 text-primary hover:underline" href="/login">

lib/auth/config.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,55 @@ if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV !== "production") {
3232
);
3333
}
3434

35+
type SocialProviderName = "google" | "github";
36+
37+
const warnedMissingSocialProviders = new Set<SocialProviderName>();
38+
39+
function resolveSocialProviderFromEnv(
40+
provider: SocialProviderName,
41+
clientIdEnv: string,
42+
clientSecretEnv: string,
43+
env: NodeJS.ProcessEnv
44+
) {
45+
const clientId = env[clientIdEnv];
46+
const clientSecret = env[clientSecretEnv];
47+
48+
if (clientId && clientSecret) {
49+
return { clientId, clientSecret };
50+
}
51+
52+
if (env.NODE_ENV !== "test" && !warnedMissingSocialProviders.has(provider)) {
53+
warnedMissingSocialProviders.add(provider);
54+
console.warn(
55+
`[auth] ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET are required to enable ${provider} social login. Provider is disabled.`
56+
);
57+
}
58+
59+
return null;
60+
}
61+
62+
export function getSocialProvidersFromEnv(env: NodeJS.ProcessEnv = process.env) {
63+
const google = resolveSocialProviderFromEnv(
64+
"google",
65+
"GOOGLE_CLIENT_ID",
66+
"GOOGLE_CLIENT_SECRET",
67+
env
68+
);
69+
const github = resolveSocialProviderFromEnv(
70+
"github",
71+
"GITHUB_CLIENT_ID",
72+
"GITHUB_CLIENT_SECRET",
73+
env
74+
);
75+
76+
return {
77+
...(google ? { google } : {}),
78+
...(github ? { github } : {}),
79+
};
80+
}
81+
82+
const socialProviders = getSocialProvidersFromEnv();
83+
3584
export const auth = betterAuth({
3685
secret: process.env.BETTER_AUTH_SECRET,
3786
database: drizzleAdapter(db, {
@@ -41,6 +90,7 @@ export const auth = betterAuth({
4190
emailAndPassword: {
4291
enabled: true,
4392
},
93+
socialProviders,
4494
session: {
4595
expiresIn: 60 * 60 * 24 * 30,
4696
},

messages/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,12 @@
611611
"emailExists": "Email already exists",
612612
"registerFailed": "Registration failed, please try again later"
613613
},
614+
"social": {
615+
"google": "Continue with Google",
616+
"github": "Continue with GitHub",
617+
"or": "Or continue with",
618+
"error": "Social sign-in failed. Please try again."
619+
},
614620
"forgotPassword": {
615621
"pageTitle": "Forgot Password",
616622
"pageSubtitle": "Enter your email to receive a password reset link",
@@ -1076,4 +1082,4 @@
10761082
"defaultUser": "User"
10771083
}
10781084
}
1079-
}
1085+
}

messages/jp.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,12 @@
611611
"emailExists": "このメールアドレスは既に使用されています",
612612
"registerFailed": "登録に失敗しました。後でもう一度お試しください"
613613
},
614+
"social": {
615+
"google": "Google で続行",
616+
"github": "GitHub で続行",
617+
"or": "または",
618+
"error": "ソーシャルログインに失敗しました。もう一度お試しください。"
619+
},
614620
"forgotPassword": {
615621
"pageTitle": "パスワードをお忘れですか?",
616622
"pageSubtitle": "パスワード再設定リンクを受け取るためにメールアドレスを入力してください",
@@ -973,4 +979,4 @@
973979
"defaultUser": "ユーザー"
974980
}
975981
}
976-
}
982+
}

messages/zh-CN.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,12 @@
611611
"emailExists": "邮箱已存在",
612612
"registerFailed": "注册失败,请稍后重试"
613613
},
614+
"social": {
615+
"google": "使用 Google 继续",
616+
"github": "使用 GitHub 继续",
617+
"or": "或使用以下方式继续",
618+
"error": "社交登录失败,请重试。"
619+
},
614620
"forgotPassword": {
615621
"pageTitle": "忘记密码",
616622
"pageSubtitle": "输入您的邮箱以获取密码重置链接",
@@ -966,4 +972,4 @@
966972
"defaultUser": "用户"
967973
}
968974
}
969-
}
975+
}

tests/components/landing/hero.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ mock.module("next/link", () => ({
3131
}));
3232

3333
mock.module("@/components/layout/language-switcher", () => ({
34+
LanguageMenuItems: () => <div>Language</div>,
3435
LanguageSwitcher: () => <button data-testid="language-switcher" />,
3536
}));
3637

tests/components/layout/sidebar.test.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,9 @@ mock.module("next/navigation", () => ({
3737
useSelectedLayoutSegments: () => [],
3838
}));
3939

40-
// Mock LanguageMenuItems to avoid testing next-intl internals and ensure specific text is rendered for testing
41-
mock.module("@/components/layout/language-switcher", () => ({
42-
LanguageMenuItems: () => <div>语言</div>,
43-
}));
44-
45-
// Mock LanguageMenuItems to avoid testing next-intl internals and ensure specific text is rendered for testing
40+
// Mock language switcher exports to avoid next-intl internals in this unit test.
4641
mock.module("@/components/layout/language-switcher", () => ({
42+
LanguageSwitcher: () => <button type="button">Language</button>,
4743
LanguageMenuItems: () => <div>语言</div>,
4844
}));
4945

0 commit comments

Comments
 (0)