Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
NEXT_PUBLIC_APP_URL=http://localhost:3000
65 changes: 65 additions & 0 deletions components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 (
<Card>
<CardHeader>
Expand Down Expand Up @@ -194,6 +227,38 @@ export function LoginForm() {
{isLoading ? t("submitting") : t("submitButton")}
</Button>

<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tSocial("or")}</span>
</div>
</div>

<div className="grid gap-2 sm:grid-cols-2">
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
void handleSocialSignIn("google");
}}
>
{tSocial("google")}
</Button>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
void handleSocialSignIn("github");
}}
>
{tSocial("github")}
</Button>
</div>

<p className="text-center text-sm text-muted-foreground">
{t("noAccount")}
<Link className="ml-1 text-primary hover:underline" href="/register">
Expand Down
65 changes: 65 additions & 0 deletions components/auth/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<HTMLInputElement>) => {
Expand Down Expand Up @@ -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 (
<Card>
<CardHeader>
Expand Down Expand Up @@ -217,6 +250,38 @@ export function RegisterForm() {
{isLoading ? t("submitting") : t("submitButton")}
</Button>

<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tSocial("or")}</span>
</div>
</div>

<div className="grid gap-2 sm:grid-cols-2">
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
void handleSocialSignIn("google");
}}
>
{tSocial("google")}
</Button>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
void handleSocialSignIn("github");
}}
>
{tSocial("github")}
</Button>
</div>

<p className="text-center text-sm text-muted-foreground">
{t("hasAccount")}
<Link className="ml-1 text-primary hover:underline" href="/login">
Expand Down
50 changes: 50 additions & 0 deletions lib/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,55 @@ if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV !== "production") {
);
}

type SocialProviderName = "google" | "github";

const warnedMissingSocialProviders = new Set<SocialProviderName>();

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, {
Expand All @@ -41,6 +90,7 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
socialProviders,
session: {
expiresIn: 60 * 60 * 24 * 30,
},
Expand Down
8 changes: 7 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1076,4 +1082,4 @@
"defaultUser": "User"
}
}
}
}
8 changes: 7 additions & 1 deletion messages/jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@
"emailExists": "このメールアドレスは既に使用されています",
"registerFailed": "登録に失敗しました。後でもう一度お試しください"
},
"social": {
"google": "Google で続行",
"github": "GitHub で続行",
"or": "または",
"error": "ソーシャルログインに失敗しました。もう一度お試しください。"
},
"forgotPassword": {
"pageTitle": "パスワードをお忘れですか?",
"pageSubtitle": "パスワード再設定リンクを受け取るためにメールアドレスを入力してください",
Expand Down Expand Up @@ -973,4 +979,4 @@
"defaultUser": "ユーザー"
}
}
}
}
8 changes: 7 additions & 1 deletion messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@
"emailExists": "邮箱已存在",
"registerFailed": "注册失败,请稍后重试"
},
"social": {
"google": "使用 Google 继续",
"github": "使用 GitHub 继续",
"or": "或使用以下方式继续",
"error": "社交登录失败,请重试。"
},
"forgotPassword": {
"pageTitle": "忘记密码",
"pageSubtitle": "输入您的邮箱以获取密码重置链接",
Expand Down Expand Up @@ -966,4 +972,4 @@
"defaultUser": "用户"
}
}
}
}
1 change: 1 addition & 0 deletions tests/components/landing/hero.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mock.module("next/link", () => ({
}));

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

Expand Down
8 changes: 2 additions & 6 deletions tests/components/layout/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,9 @@ mock.module("next/navigation", () => ({
useSelectedLayoutSegments: () => [],
}));

// Mock LanguageMenuItems to avoid testing next-intl internals and ensure specific text is rendered for testing
mock.module("@/components/layout/language-switcher", () => ({
LanguageMenuItems: () => <div>语言</div>,
}));

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

Expand Down
Loading
Loading