From 8023ed3ab4eabcbdfdc2d6a5c2ef8c59ecd01683 Mon Sep 17 00:00:00 2001 From: IssueBot Date: Thu, 12 Feb 2026 17:10:45 -0800 Subject: [PATCH 1/4] feat(auth): enable conditional Google/GitHub providers (#32 step1) --- lib/auth/config.ts | 50 +++++++++++++++++++++++++++++++ tests/lib/auth-config.test.ts | 55 ++++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/lib/auth/config.ts b/lib/auth/config.ts index f01cb26..61a3ece 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -32,6 +32,55 @@ if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV !== "production") { ); } +type SocialProviderName = "google" | "github"; + +const warnedMissingSocialProviders = new Set(); + +function resolveSocialProviderFromEnv( + provider: SocialProviderName, + clientIdEnv: string, + clientSecretEnv: string, + env: NodeJS.ProcessEnv +) { + const clientId = env[clientIdEnv]; + const clientSecret = env[clientSecretEnv]; + + if (clientId && clientSecret) { + return { clientId, clientSecret }; + } + + if (env.NODE_ENV !== "test" && !warnedMissingSocialProviders.has(provider)) { + warnedMissingSocialProviders.add(provider); + console.warn( + `[auth] ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET are required to enable ${provider} social login. Provider is disabled.` + ); + } + + return null; +} + +export function getSocialProvidersFromEnv(env: NodeJS.ProcessEnv = process.env) { + const google = resolveSocialProviderFromEnv( + "google", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + env + ); + const github = resolveSocialProviderFromEnv( + "github", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + env + ); + + return { + ...(google ? { google } : {}), + ...(github ? { github } : {}), + }; +} + +const socialProviders = getSocialProvidersFromEnv(); + export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { @@ -41,6 +90,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + socialProviders, session: { expiresIn: 60 * 60 * 24 * 30, }, diff --git a/tests/lib/auth-config.test.ts b/tests/lib/auth-config.test.ts index f89ea64..9da2a5b 100644 --- a/tests/lib/auth-config.test.ts +++ b/tests/lib/auth-config.test.ts @@ -16,11 +16,58 @@ */ import { describe, expect, it } from "bun:test"; + +const TEST_DATABASE_URL = "postgres://user:pass@localhost:5432/echo"; + +async function loadAuthConfig() { + process.env.DATABASE_URL ??= TEST_DATABASE_URL; + return import("@/lib/auth/config"); +} + describe("auth session config", () => { - it("uses 30-day session expiry", () => { - process.env.DATABASE_URL ??= "postgres://user:pass@localhost:5432/echo"; - return import("@/lib/auth/config").then(({ auth }) => { - expect(auth.options.session?.expiresIn).toBe(60 * 60 * 24 * 30); + it("uses 30-day session expiry", async () => { + const { auth } = await loadAuthConfig(); + expect(auth.options.session?.expiresIn).toBe(60 * 60 * 24 * 30); + }); +}); + +describe("auth social provider config", () => { + it("enables google and github providers when env vars are set", async () => { + const { getSocialProvidersFromEnv } = await loadAuthConfig(); + const providers = getSocialProvidersFromEnv({ + ...process.env, + NODE_ENV: "test", + GOOGLE_CLIENT_ID: "google-client-id", + GOOGLE_CLIENT_SECRET: "google-client-secret", + GITHUB_CLIENT_ID: "github-client-id", + GITHUB_CLIENT_SECRET: "github-client-secret", + }); + + expect(providers.google).toEqual({ + clientId: "google-client-id", + clientSecret: "google-client-secret", + }); + expect(providers.github).toEqual({ + clientId: "github-client-id", + clientSecret: "github-client-secret", + }); + }); + + it("does not enable a provider when its env pair is missing", async () => { + const { getSocialProvidersFromEnv } = await loadAuthConfig(); + const providers = getSocialProvidersFromEnv({ + ...process.env, + NODE_ENV: "test", + GOOGLE_CLIENT_ID: "google-client-id", + GOOGLE_CLIENT_SECRET: "google-client-secret", + GITHUB_CLIENT_ID: "github-client-id", + GITHUB_CLIENT_SECRET: undefined, + }); + + expect(providers.google).toEqual({ + clientId: "google-client-id", + clientSecret: "google-client-secret", }); + expect(providers.github).toBeUndefined(); }); }); From 43749ba61128d51ec1355bba72e30f6ac2bbbf36 Mon Sep 17 00:00:00 2001 From: IssueBot Date: Thu, 12 Feb 2026 18:05:16 -0800 Subject: [PATCH 2/4] feat(auth): add social sign-in actions to auth forms (#32 step2) --- components/auth/login-form.tsx | 65 ++++++++++++++++++++++++++ components/auth/register-form.tsx | 65 ++++++++++++++++++++++++++ tests/components/login-form.test.ts | 29 ++++++++++-- tests/components/register-form.test.ts | 48 +++++++++++++++++++ 4 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 tests/components/register-form.test.ts diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 6092ca4..a715594 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -38,8 +38,16 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; import { loginSchema, type LoginInput } from "@/lib/validations/auth"; +function getSocialAuthErrorMessage(error: unknown, fallbackMessage: string) { + if (error instanceof Error && error.message) { + return error.message; + } + return fallbackMessage; +} + export function LoginForm() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); @@ -51,6 +59,7 @@ export function LoginForm() { rememberMe: false, }); const t = useTranslations("auth.login"); + const tSocial = useTranslations("auth.social"); // Clear potentially corrupted cookies on mount useEffect(() => { @@ -121,6 +130,30 @@ export function LoginForm() { } }; + const handleSocialSignIn = async (provider: "google" | "github") => { + setFormError(null); + setIsLoading(true); + + try { + const response = await authClient.signIn.social({ + provider, + callbackURL: "/dashboard", + }); + const socialError = + response && typeof response === "object" && "error" in response + ? response.error + : null; + + if (socialError) { + throw socialError; + } + } catch (error) { + setFormError(getSocialAuthErrorMessage(error, tSocial("error"))); + } finally { + setIsLoading(false); + } + }; + return ( @@ -194,6 +227,38 @@ export function LoginForm() { {isLoading ? t("submitting") : t("submitButton")} +
+
+ +
+
+ {tSocial("or")} +
+
+ +
+ + +
+

{t("noAccount")} diff --git a/components/auth/register-form.tsx b/components/auth/register-form.tsx index 481408d..4e30934 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/register-form.tsx @@ -26,9 +26,17 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function getSocialAuthErrorMessage(error: unknown, fallbackMessage: string) { + if (error instanceof Error && error.message) { + return error.message; + } + return fallbackMessage; +} + export function RegisterForm() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); @@ -41,6 +49,7 @@ export function RegisterForm() { confirmPassword: "", }); const t = useTranslations("auth.register"); + const tSocial = useTranslations("auth.social"); const tValidation = useTranslations("auth.validation"); const handleChange = (e: React.ChangeEvent) => { @@ -130,6 +139,30 @@ export function RegisterForm() { } }; + const handleSocialSignIn = async (provider: "google" | "github") => { + setFormError(null); + setIsLoading(true); + + try { + const response = await authClient.signIn.social({ + provider, + callbackURL: "/dashboard", + }); + const socialError = + response && typeof response === "object" && "error" in response + ? response.error + : null; + + if (socialError) { + throw socialError; + } + } catch (error) { + setFormError(getSocialAuthErrorMessage(error, tSocial("error"))); + } finally { + setIsLoading(false); + } + }; + return ( @@ -217,6 +250,38 @@ export function RegisterForm() { {isLoading ? t("submitting") : t("submitButton")} +

+
+ +
+
+ {tSocial("or")} +
+
+ +
+ + +
+

{t("hasAccount")} diff --git a/tests/components/login-form.test.ts b/tests/components/login-form.test.ts index 822b15d..7254783 100644 --- a/tests/components/login-form.test.ts +++ b/tests/components/login-form.test.ts @@ -15,11 +15,34 @@ * along with this program. If not, see . */ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import "../setup"; + +mock.module("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +mock.module("next/navigation", () => ({ + useRouter: () => ({ + push: () => {}, + }), +})); + +mock.module("next/link", () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => + createElement("a", { href }, children), +})); + import { LoginForm } from "@/components/auth/login-form"; describe("LoginForm", () => { - it("is a function", () => { - expect(typeof LoginForm).toBe("function"); + it("renders social sign-in buttons", () => { + const { getByRole } = render(createElement(LoginForm)); + + expect(getByRole("button", { name: "google" })).toBeDefined(); + expect(getByRole("button", { name: "github" })).toBeDefined(); }); }); diff --git a/tests/components/register-form.test.ts b/tests/components/register-form.test.ts new file mode 100644 index 0000000..a0f8289 --- /dev/null +++ b/tests/components/register-form.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Nexttylabs Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, mock } from "bun:test"; +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import "../setup"; + +mock.module("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +mock.module("next/navigation", () => ({ + useRouter: () => ({ + push: () => {}, + }), +})); + +mock.module("next/link", () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => + createElement("a", { href }, children), +})); + +import { RegisterForm } from "@/components/auth/register-form"; + +describe("RegisterForm", () => { + it("renders social sign-in buttons", () => { + const { getByRole } = render(createElement(RegisterForm)); + + expect(getByRole("button", { name: "google" })).toBeDefined(); + expect(getByRole("button", { name: "github" })).toBeDefined(); + }); +}); From 1d2dd0bef3369608320d36bc2a81b3272cb215f4 Mon Sep 17 00:00:00 2001 From: IssueBot Date: Thu, 12 Feb 2026 18:38:18 -0800 Subject: [PATCH 3/4] chore(auth): add SSO locale strings and env docs (#32 step3) --- .env.example | 22 ++++++++++++++++++---- messages/en.json | 8 +++++++- messages/jp.json | 8 +++++++- messages/zh-CN.json | 8 +++++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index e18f68d..1b4d2df 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,24 @@ LOG_LEVEL=info DATABASE_URL=postgres://echo:echo@localhost:5433/echo BETTER_AUTH_SECRET=bLQ7KwdL43LHBvssREslZOmrMrtPzS0z +# Better Auth OAuth Configuration (Social Sign-In) +# Callback URL format for providers: +# http://localhost:3000/api/auth/callback/{provider} +# Example callbacks: +# http://localhost:3000/api/auth/callback/google +# http://localhost:3000/api/auth/callback/github + +# Google OAuth Configuration +# 1. Create OAuth credentials in Google Cloud Console +# 2. Add callback URL: http://localhost:3000/api/auth/callback/google +# 3. Fill in client ID/secret below +GOOGLE_CLIENT_ID=your_google_client_id_here +GOOGLE_CLIENT_SECRET=your_google_client_secret_here + # GitHub OAuth Configuration -# 1. 前往 https://github.com/settings/developers 创建 OAuth App -# 2. Callback URL 设置为: http://localhost:3000/api/integrations/github/callback -# 3. 将生成的 Client ID 和 Client Secret 填入下方 +# 1. Go to https://github.com/settings/developers and create an OAuth App +# 2. Set callback URL: http://localhost:3000/api/auth/callback/github +# 3. Fill in client ID/secret below GITHUB_CLIENT_ID=your_client_id_here GITHUB_CLIENT_SECRET=your_client_secret_here -NEXT_PUBLIC_APP_URL=http://localhost:3000 \ No newline at end of file +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/messages/en.json b/messages/en.json index a02e8ca..ffa63a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -611,6 +611,12 @@ "emailExists": "Email already exists", "registerFailed": "Registration failed, please try again later" }, + "social": { + "google": "Continue with Google", + "github": "Continue with GitHub", + "or": "Or continue with", + "error": "Social sign-in failed. Please try again." + }, "forgotPassword": { "pageTitle": "Forgot Password", "pageSubtitle": "Enter your email to receive a password reset link", @@ -1076,4 +1082,4 @@ "defaultUser": "User" } } -} \ No newline at end of file +} diff --git a/messages/jp.json b/messages/jp.json index df9926c..ed7cc2d 100644 --- a/messages/jp.json +++ b/messages/jp.json @@ -611,6 +611,12 @@ "emailExists": "このメールアドレスは既に使用されています", "registerFailed": "登録に失敗しました。後でもう一度お試しください" }, + "social": { + "google": "Google で続行", + "github": "GitHub で続行", + "or": "または", + "error": "ソーシャルログインに失敗しました。もう一度お試しください。" + }, "forgotPassword": { "pageTitle": "パスワードをお忘れですか?", "pageSubtitle": "パスワード再設定リンクを受け取るためにメールアドレスを入力してください", @@ -973,4 +979,4 @@ "defaultUser": "ユーザー" } } -} \ No newline at end of file +} diff --git a/messages/zh-CN.json b/messages/zh-CN.json index dfa63f2..b9b8a60 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -611,6 +611,12 @@ "emailExists": "邮箱已存在", "registerFailed": "注册失败,请稍后重试" }, + "social": { + "google": "使用 Google 继续", + "github": "使用 GitHub 继续", + "or": "或使用以下方式继续", + "error": "社交登录失败,请重试。" + }, "forgotPassword": { "pageTitle": "忘记密码", "pageSubtitle": "输入您的邮箱以获取密码重置链接", @@ -966,4 +972,4 @@ "defaultUser": "用户" } } -} \ No newline at end of file +} From dd0e0881b63e8c4392da9ed4368f3c46aff49f9d Mon Sep 17 00:00:00 2001 From: IssueBot Date: Thu, 12 Feb 2026 19:16:20 -0800 Subject: [PATCH 4/4] fix(tests): resolve CI failures for SSO PR (#32) --- tests/components/landing/hero.test.tsx | 1 + tests/components/layout/sidebar.test.tsx | 8 ++------ tests/components/register-form.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/components/landing/hero.test.tsx b/tests/components/landing/hero.test.tsx index 9c19f55..e656c08 100644 --- a/tests/components/landing/hero.test.tsx +++ b/tests/components/landing/hero.test.tsx @@ -31,6 +31,7 @@ mock.module("next/link", () => ({ })); mock.module("@/components/layout/language-switcher", () => ({ + LanguageMenuItems: () =>

Language
, LanguageSwitcher: () => , LanguageMenuItems: () =>
语言
, })); diff --git a/tests/components/register-form.test.ts b/tests/components/register-form.test.ts index a0f8289..c36c7b0 100644 --- a/tests/components/register-form.test.ts +++ b/tests/components/register-form.test.ts @@ -40,9 +40,9 @@ import { RegisterForm } from "@/components/auth/register-form"; describe("RegisterForm", () => { it("renders social sign-in buttons", () => { - const { getByRole } = render(createElement(RegisterForm)); + const { getAllByRole } = render(createElement(RegisterForm)); - expect(getByRole("button", { name: "google" })).toBeDefined(); - expect(getByRole("button", { name: "github" })).toBeDefined(); + expect(getAllByRole("button", { name: /google/i }).length).toBeGreaterThan(0); + expect(getAllByRole("button", { name: /github/i }).length).toBeGreaterThan(0); }); });