feat : add icon and introduce login with google
CI / Verify (push) Failing after 1m22s
CI / Build immutable images and deploy (push) Skipped

This commit is contained in:
2026-10-10 23:33:34 +07:00 Unverified
parent a3803118ef
commit f826051438
19 changed files with 469 additions and 23 deletions
+3
View File
@@ -13,6 +13,9 @@ BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
# Separate trusted browser origins with commas for local development or proxies.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
# Optional for local development; required by the production deployment.
GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id
GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret
# Resend sending key; verify sudloh.com before sending from [email protected].
RESEND_API_KEY=replace-with-resend-sending-key
+10
View File
@@ -62,6 +62,7 @@ Git; `.env` is ignored by the repository.
| Setting | Purpose |
| --- | --- |
| `BETTER_AUTH_URL` | Must exactly match the application's origin. |
| `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` | Enable Google sign-in; configure the OAuth redirect URI as `{BETTER_AUTH_URL}/api/auth/callback/google`. |
| `BASE_URL` | Public origin used for SEO metadata, the sitemap, and robots.txt. |
| Database, Redis, and S3 settings | Connection details for the required services. |
@@ -317,6 +318,8 @@ Before the first rollout, a cluster administrator must provision a
```text
DATABASE_URL
BETTER_AUTH_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
RESEND_API_KEY
REDIS_URL
S3_ENDPOINT
@@ -343,6 +346,13 @@ DISCORD_CHANNEL_ID
across replicas and rolling deployments. Do not place secret values in the
ConfigMap or commit them to this repository.
#### Google sign-in
Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the application environment.
In Google Cloud, register `{BETTER_AUTH_URL}/api/auth/callback/google` as an
authorized redirect URI for each environment (for production,
`https://guide.sudloh.com/api/auth/callback/google`).
#### Email verification
Before deploying required email verification, add `sudloh.com` to Resend and
+1 -1
View File
@@ -24,7 +24,7 @@ export default function HomePage() {
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 pt-4 pb-10 sm:px-6 sm:pt-6 sm:pb-14">
<header className="max-w-3xl">
<h1 className="font-heading text-4xl font-semibold">
เลือกตัวละครแล้วส่องดู
เลือกตัวละครแล้วดูได้เลย
</h1>
<p className="mt-3 text-lg leading-8 text-muted-foreground">
ไกด์ตัวละคร อาวุธ อาร์ติแฟกต์ กลุ่มดาว ดาเมทแต่ละทีม และ ส่วนเสริมเล็กๆน้อยๆ
+15 -5
View File
@@ -1,22 +1,24 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { UserRoundIcon } from "lucide-react";
import { getDb } from "@/db";
import { accounts, users } from "@/db/schema";
import { SiteHeader } from "@/components/public/site-header";
import { AdminHeader } from "@/components/admin/admin-header";
import { ProfileForm } from "@/components/auth/profile-form";
import { GoogleAccountSettings } from "@/components/auth/google-account-settings";
import { PasswordForm } from "@/components/auth/password-form";
import { EmailSettings } from "@/components/auth/email-settings";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server";
import { getCustomerSession, isGoogleAuthEnabled } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
export const instant = false;
export default async function ProfilePage({ searchParams }: PageProps<"/profile">) {
await connection();
const { setup, next, upload, emailAction, error } = await searchParams;
const { setup, next, upload, emailAction, error, googleLinkError } = await searchParams;
const nextPath = safeAuthReturnPath(next);
const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath;
const session = await getCustomerSession();
@@ -25,15 +27,21 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
getDb().select({ name: users.name, email: users.email,
emailVerified: users.emailVerified, image: users.image })
.from(users).where(eq(users.id, session.user.id)).limit(1),
getDb().select({ providerId: accounts.providerId })
getDb().select({ id: accounts.id, providerId: accounts.providerId })
.from(accounts).where(eq(accounts.userId, session.user.id)),
]);
const [user] = userRows;
if (!user) redirect("/auth/login?next=%2Fprofile");
const hasCredential = accountRows.some((account) => account.providerId === "credential");
const googleAccountIds = accountRows.filter((account) => account.providerId === "google")
.map((account) => account.id);
const hasOtherAuthMethod = accountRows.some((account) => account.providerId !== "google");
return <div className="min-h-svh">{isAuthorizedAdmin(session.user) ? <AdminHeader /> : <SiteHeader />}
<main className="mx-auto max-w-5xl p-4 py-10 sm:p-8">
<h1 className="mb-6 font-heading text-3xl font-semibold">โปรไฟล์ของฉัน</h1>
<h1 className="mb-6 flex items-center gap-2 font-heading text-3xl font-semibold">
<UserRoundIcon aria-hidden="true" className="size-7 shrink-0" />
โปรไฟล์ของฉัน
</h1>
{setup === "1" && <p className="mb-6 text-muted-foreground">
{upload === "failed" ? "สร้างบัญชีแล้ว แต่บันทึกรูปโปรไฟล์ไม่สำเร็จ กรุณาลองอีกครั้ง" :
"สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน"}
@@ -41,7 +49,9 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
<div className="grid items-start gap-6 md:grid-cols-2">
<ProfileForm {...user} nextPath={setup === "1" ? setupNextPath : undefined} />
<EmailSettings email={user.email} verified={user.emailVerified}
callbackCompleted={emailAction === "1" && !error} callbackError={typeof error === "string"} />
callbackCompleted={emailAction === "1" && !error} callbackError={emailAction === "1" && typeof error === "string"} />
{isGoogleAuthEnabled() && <GoogleAccountSettings googleAccountIds={googleAccountIds}
hasOtherAuthMethod={hasOtherAuthMethod} linkError={googleLinkError === "1"} />}
{hasCredential && <PasswordForm />}
</div>
</main>
+97
View File
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ social: vi.fn(), push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({
push: mocks.push, replace: mocks.replace, refresh: mocks.refresh,
}) }));
vi.mock("@/lib/auth/client", () => ({ authClient: {
signIn: { social: mocks.social },
signUp: { email: vi.fn() },
emailOtp: { sendVerificationOtp: vi.fn(), verifyEmail: vi.fn() },
} }));
import { AccountForm } from "./account-form";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("NODE_ENV", "development");
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.social.mockResolvedValue({ error: null });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
Reflect.deleteProperty(window, "turnstile");
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
async function mount(mode: "login" | "register", nextPath = "/commission/tickets?from=login", googleEnabled = true) {
await act(async () => root.render(createElement(AccountForm, {
mode, nextPath, googleEnabled, siteKey: "test-site-key",
})));
}
function googleButton() {
return [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Google"));
}
describe("Google sign-in form action", () => {
it("offers Google sign-in on login with safe existing-user and new-user callbacks", async () => {
await mount("login");
const button = googleButton();
expect(button).toBeDefined();
expect(button?.querySelector("img")?.getAttribute("src")).toBe("/icon/google.svg");
expect(button?.nextElementSibling?.textContent).toMatch(/สมัครสมาชิก|มีบัญชีแล้ว/);
await act(async () => button!.click());
expect(mocks.social).toHaveBeenCalledWith(expect.objectContaining({
provider: "google",
callbackURL: "/commission/tickets?from=login",
newUserCallbackURL: "/auth/register?step=profile&next=%2Fcommission%2Ftickets%3Ffrom%3Dlogin",
}));
});
it("hides Google sign-in on registration", async () => {
await mount("register");
expect(googleButton()).toBeUndefined();
expect(mocks.social).not.toHaveBeenCalled();
});
it("hides the provider action when Google credentials are not configured", async () => {
await mount("login", "/", false);
expect(googleButton()).toBeUndefined();
expect(mocks.social).not.toHaveBeenCalled();
});
it("passes the Turnstile token to the protected social sign-in endpoint", async () => {
vi.stubEnv("NODE_ENV", "production");
const turnstile = {
render: vi.fn((_container: HTMLElement, options: { callback: (token: string) => void }) => {
options.callback("turnstile-token");
return "widget-id";
}),
remove: vi.fn(),
reset: vi.fn(),
};
Object.defineProperty(window, "turnstile", { configurable: true, value: turnstile });
await mount("login", "/profile");
const button = googleButton();
expect(button?.disabled).toBe(false);
await act(async () => button!.click());
expect(mocks.social).toHaveBeenCalledWith(expect.objectContaining({
provider: "google",
fetchOptions: { headers: { "x-captcha-response": "turnstile-token" } },
}));
});
});
+31
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { EyeClosedIcon, EyeIcon } from "lucide-react";
@@ -33,12 +34,14 @@ export function AccountForm({
mode,
siteKey,
nextPath,
googleEnabled,
verified,
verificationError,
}: {
mode: "login" | "register";
siteKey: string;
nextPath: string;
googleEnabled: boolean;
verified?: boolean;
verificationError?: boolean;
}) {
@@ -109,6 +112,27 @@ export function AccountForm({
};
}, [captcha, siteKey, pendingEmail, captchaAttempt]);
async function signInWithGoogle() {
if (!googleEnabled || busy || (captcha && !token)) return;
setBusy(true);
setError("");
try {
const result = await authClient.signIn.social({
provider: "google",
callbackURL: nextPath,
newUserCallbackURL: `/auth/register?step=profile&next=${encodeURIComponent(nextPath)}`,
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
});
if (result.error) throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
setToken("");
if (widget.current) window.turnstile?.reset(widget.current);
} finally {
setBusy(false);
}
}
async function sendRegistrationOtp() {
if (!register || busy || (pendingEmail && resendSeconds > 0)) return;
if (pendingEmail) {
@@ -276,6 +300,13 @@ export function AccountForm({
}}>ข้ามการยืนยันอีเมลตอนนี้</Button>}
</FieldGroup>
</form>
{googleEnabled && !register && <>
<p className="text-center text-xs text-muted-foreground">หรือ</p>
<Button type="button" variant="outline" className="w-full" disabled={busy || (captcha && !token)} onClick={() => void signInWithGoogle()}>
<Image src="/icon/google.svg" alt="" width={18} height={18} data-icon="inline-start" />
เข้าสู่ระบบด้วย Google
</Button>
</>}
{register ? <Button variant="link" nativeButton={false}
render={<Link href={`/auth/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
มีบัญชีแล้ว? เข้าสู่ระบบ
+22 -2
View File
@@ -4,10 +4,15 @@ import { renderToStaticMarkup } from "react-dom/server";
const mocks = vi.hoisted(() => ({ session: vi.fn(), userRows: vi.fn(), select: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: (path: string) => { throw new Error(`redirect:${path}`); } }));
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: mocks.session }));
vi.mock("@/lib/auth/server", () => ({
getCustomerSession: mocks.session,
isGoogleAuthEnabled: () => Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
}));
vi.mock("@/db", () => ({ getDb: () => ({ select: mocks.select }) }));
vi.mock("@/components/public/site-header", () => ({ SiteHeader: () => null }));
vi.mock("./account-form", () => ({ AccountForm: () => <div>account details</div> }));
vi.mock("./account-form", () => ({ AccountForm: ({ mode, nextPath, googleEnabled }: {
mode: "login" | "register"; nextPath: string; googleEnabled: boolean;
}) => <div data-mode={mode} data-next-path={nextPath} data-google-enabled={googleEnabled}>account details</div> }));
vi.mock("./profile-form", () => ({ ProfileForm: ({ name, nextPath, setup }: {
name: string; nextPath: string; setup: boolean;
}) => <div data-next={nextPath} data-setup={setup}>{name}</div> }));
@@ -17,6 +22,8 @@ const { AccountPage } = await import("./account-page");
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("GOOGLE_CLIENT_ID", "");
vi.stubEnv("GOOGLE_CLIENT_SECRET", "");
mocks.session.mockResolvedValue({ user: { id: "user-1", email: "[email protected]",
emailVerified: false, role: "user" }, session: { id: "session-1" } });
mocks.select.mockReturnValue({ from: () => ({ where: () => ({ limit: mocks.userRows }) }) });
@@ -38,9 +45,22 @@ describe("registration profile step", () => {
mocks.session.mockResolvedValue(null);
const html = renderToStaticMarkup(await AccountPage({ mode: "register", setup: true, next: "/commission" }));
expect(html).toContain("account details");
expect(html).toContain('data-google-enabled="false"');
expect(mocks.select).not.toHaveBeenCalled();
});
it("offers the configured Google provider on both shared account screens", async () => {
mocks.session.mockResolvedValue(null);
vi.stubEnv("GOOGLE_CLIENT_ID", "google-client-id");
vi.stubEnv("GOOGLE_CLIENT_SECRET", "google-client-secret");
for (const mode of ["login", "register"] as const) {
const html = renderToStaticMarkup(await AccountPage({ mode, next: "/commission" }));
expect(html).toContain(`data-mode="${mode}"`);
expect(html).toContain('data-next-path="/commission"');
expect(html).toContain('data-google-enabled="true"');
}
});
it("sanitizes external and unauthorized admin destinations before completing setup", async () => {
for (const next of ["https://elsewhere.test", "/admin/guides"]) {
const html = renderToStaticMarkup(await AccountPage({ mode: "register", setup: true, next }));
+2 -2
View File
@@ -4,7 +4,7 @@ import { getDb } from "@/db";
import { users } from "@/db/schema";
import { SiteHeader } from "@/components/public/site-header";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server";
import { getCustomerSession, isGoogleAuthEnabled } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { AccountForm } from "./account-form";
import { ProfileForm } from "./profile-form";
@@ -30,7 +30,7 @@ export async function AccountPage({ mode, next, verified, verificationError, set
const siteKey = process.env.TURNSTILE_SITE_KEY ?? "";
if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required");
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath}
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath} googleEnabled={isGoogleAuthEnabled()}
verified={verified} verificationError={verificationError} />
</main></div>;
}
+5 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
import { MailIcon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -53,7 +54,10 @@ export function EmailSettings({ email, verified, callbackCompleted, callbackErro
}
return <Card>
<CardHeader><CardTitle>อีเมล</CardTitle></CardHeader>
<CardHeader><CardTitle className="flex items-center gap-2">
<MailIcon aria-hidden="true" className="size-4 shrink-0" />
อีเมล
</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm">{email} - {verified ? "ยืนยันแล้ว" : "ยังไม่ยืนยัน"}</p>
{callbackCompleted && <Alert><AlertDescription>ยืนยันขั้นตอนแล้ว หากกำลังเปลี่ยนอีเมล โปรดตรวจสอบกล่องจดหมายสำหรับขั้นตอนถัดไป</AlertDescription></Alert>}
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ linkSocial: vi.fn(), unlinkAccount: vi.fn() }));
vi.mock("@/lib/auth/client", () => ({ authClient: mocks }));
import { GoogleAccountSettings } from "./google-account-settings";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
mocks.linkSocial.mockResolvedValue({ error: null });
mocks.unlinkAccount.mockResolvedValue({ error: null });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function mount(googleAccountIds: string[], hasOtherAuthMethod: boolean, linkError = false) {
await act(async () => root.render(createElement(GoogleAccountSettings, {
googleAccountIds, hasOtherAuthMethod, linkError,
})));
}
function actionButton() {
return container.querySelector<HTMLButtonElement>("[data-slot='card-content'] button");
}
describe("Google account settings", () => {
it("starts an authenticated Google link flow and shows unlinked status", async () => {
await mount([], false);
expect(container.querySelector("[data-slot='card-title']")?.textContent).toContain("บัญชีที่เชื่อมต่อ");
expect(container.querySelector("[data-slot='card-content'] img")?.getAttribute("src")).toBe("/icon/google.svg");
expect(container.textContent).toContain("ยังไม่ได้เชื่อมต่อ");
await act(async () => actionButton()?.click());
expect(mocks.linkSocial).toHaveBeenCalledWith({
provider: "google",
requestSignUp: false,
callbackURL: "/profile",
errorCallbackURL: "/profile?googleLinkError=1",
});
});
it("unlinks the selected Google account and updates its status", async () => {
await mount(["google-account-id"], true);
expect(container.textContent).toContain("เชื่อมต่อแล้ว");
await act(async () => actionButton()?.click());
expect(mocks.unlinkAccount).toHaveBeenCalledWith({ accountId: "google-account-id" });
expect(container.textContent).toContain("ยังไม่ได้เชื่อมต่อ");
});
it("prevents unlinking the only sign-in method", async () => {
await mount(["google-account-id"], false);
expect(actionButton()?.disabled).toBe(true);
expect(container.textContent).toContain("วิธีเข้าสู่ระบบเดียว");
});
it("shows a callback error after failed linking", async () => {
await mount([], false, true);
expect(container.querySelector('[role="alert"]')?.textContent)
.toContain("เชื่อมบัญชี Google ไม่สำเร็จ");
});
});
+104
View File
@@ -0,0 +1,104 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { LinkIcon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { authClient } from "@/lib/auth/client";
export function GoogleAccountSettings({
googleAccountIds: initialGoogleAccountIds,
hasOtherAuthMethod,
linkError,
}: {
googleAccountIds: string[];
hasOtherAuthMethod: boolean;
linkError: boolean;
}) {
const [googleAccountIds, setGoogleAccountIds] = useState(initialGoogleAccountIds);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const linked = googleAccountIds.length > 0;
const canUnlink = hasOtherAuthMethod || googleAccountIds.length > 1;
async function linkGoogle() {
if (busy) return;
setBusy(true);
setError("");
try {
const result = await authClient.linkSocial({
provider: "google",
requestSignUp: false,
callbackURL: "/profile",
errorCallbackURL: "/profile?googleLinkError=1",
});
if (result.error) throw new Error(result.error.message ?? "เชื่อมบัญชี Google ไม่สำเร็จ");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "เชื่อมบัญชี Google ไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setBusy(false);
}
}
async function unlinkGoogle() {
const accountId = googleAccountIds[0];
if (busy || !accountId || !canUnlink) return;
setBusy(true);
setError("");
try {
const result = await authClient.unlinkAccount({ accountId });
if (result.error) throw new Error(result.error.message ?? "ยกเลิกการเชื่อมต่อ Google ไม่สำเร็จ");
setGoogleAccountIds((current) => current.filter((id) => id !== accountId));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ยกเลิกการเชื่อมต่อ Google ไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setBusy(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LinkIcon aria-hidden="true" className="size-4 shrink-0" />
บัญชีที่เชื่อมต่อ
</CardTitle>
<CardDescription>เชื่อมต่อบัญชี Google เพื่อเข้าสู่ระบบได้ง่ายขึ้น</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-4 rounded-lg border p-4">
<div className="grid size-12 shrink-0 place-items-center rounded-lg bg-muted">
<Image src="/icon/google.svg" alt="" width={24} height={24} />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">Google</p>
<p role="status" className="text-sm text-muted-foreground">
{linked ? "เชื่อมต่อแล้ว" : "ยังไม่ได้เชื่อมต่อ"}
</p>
</div>
{linked ? <Button type="button" variant="outline" size="sm"
disabled={busy || !canUnlink} onClick={() => void unlinkGoogle()}>
{busy ? "กำลังดำเนินการ…" : "ยกเลิกการเชื่อมต่อ"}
</Button> : <Button type="button" size="sm" disabled={busy}
onClick={() => void linkGoogle()}>
{busy ? "กำลังดำเนินการ…" : "เชื่อมต่อ"}
</Button>}
</div>
{linked && !canUnlink && <p className="text-sm text-muted-foreground">
Google เป็นวิธีเข้าสู่ระบบเดียวของบัญชีนี้ เพิ่มวิธีเข้าสู่ระบบอื่นก่อนยกเลิกการเชื่อมต่อ
</p>}
{(linkError || error) && <Alert variant="destructive" role="alert">
<AlertDescription>{error || "เชื่อมบัญชี Google ไม่สำเร็จ กรุณาลองอีกครั้ง"}</AlertDescription>
</Alert>}
</CardContent>
</Card>
);
}
+5 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { KeyRoundIcon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -47,7 +48,10 @@ export function PasswordForm() {
return <Card>
<CardHeader>
<CardTitle>เปลี่ยนรหัสผ่าน</CardTitle>
<CardTitle className="flex items-center gap-2">
<KeyRoundIcon aria-hidden="true" className="size-4 shrink-0" />
เปลี่ยนรหัสผ่าน
</CardTitle>
<CardDescription>กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่</CardDescription>
</CardHeader>
<CardContent>
+5 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { UserRoundIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -71,7 +72,10 @@ export function ProfileForm({
return (
<Card>
<CardHeader>
<CardTitle>{setup ? "ปรับแต่งโปรไฟล์" : "ข้อมูลบัญชี"}</CardTitle>
<CardTitle className="flex items-center gap-2">
<UserRoundIcon aria-hidden="true" className="size-4 shrink-0" />
{setup ? "ปรับแต่งโปรไฟล์" : "ข้อมูลบัญชี"}
</CardTitle>
{setup && <CardDescription>ขั้นตอน 3 จาก 3 - ปรับแต่งโปรไฟล์</CardDescription>}
<CardDescription>{email}</CardDescription>
</CardHeader>
+20 -7
View File
@@ -5,7 +5,16 @@ import { searchItems } from "@/lib/search";
import Image from "@/components/ui/resilient-image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, SlidersHorizontalIcon, StarIcon, UsersIcon, XIcon } from "lucide-react";
import {
ArrowDownAZIcon,
ArrowUpZAIcon,
Clock3Icon,
SearchIcon,
SlidersHorizontalIcon,
StarIcon,
UsersIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, type ReactNode } from "react";
import { ElementIcon } from "@/components/public/element-icon";
@@ -78,10 +87,10 @@ const weaponTypeLabels: Record<string, string> = {
};
const sortOptions = [
{ value: "manual", label: "แนะนำ" },
{ value: "recent", label: "อัปเดตล่าสุด" },
{ value: "name-asc", label: "A - Z" },
{ value: "name-desc", label: "Z - A" },
{ value: "manual", label: "แนะนำ", icon: StarIcon },
{ value: "recent", label: "อัปเดตล่าสุด", icon: Clock3Icon },
{ value: "name-asc", label: "A - Z", icon: ArrowDownAZIcon },
{ value: "name-desc", label: "Z - A", icon: ArrowUpZAIcon },
] as const;
const SORT_STORAGE_KEY = "buzz-sheet:directory-sort";
@@ -111,6 +120,8 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const selectedRarities = [...new Set((searchParams.get("rarity") ?? "").split(",").filter((value) => rarities.includes(Number(value))))];
const sortParam = searchParams.get("sort");
const sort = sortParam === "recent" || sortParam === "name-asc" || sortParam === "name-desc" ? sortParam : "manual";
const selectedSortOption = sortOptions.find((option) => option.value === sort) ?? sortOptions[0];
const SelectedSortIcon = selectedSortOption.icon;
const matchingGuides = guides.filter((guide) =>
(!selectedElements.length || (guide.element !== null && selectedElements.includes(guide.element))) &&
(!selectedWeapons.length || (guide.weaponType !== null && selectedWeapons.includes(guide.weaponType))) &&
@@ -182,10 +193,12 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
/>
</InputGroup>
<Select value={sort} onValueChange={(next) => next && setParam("sort", next)}>
<SelectTrigger aria-label="เรียงตาม" className="min-h-11 w-32 sm:min-h-0 sm:w-auto"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
<SelectTrigger aria-label="เรียงตาม" className="min-h-11 w-32 sm:min-h-0 sm:w-auto"><SelectValue><SelectedSortIcon aria-hidden="true" />{selectedSortOption.label}</SelectValue></SelectTrigger>
<SelectContent>
<SelectGroup>
{sortOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
{sortOptions.map((option) => <SelectItem key={option.value} value={option.value}>
<option.icon aria-hidden="true" />{option.label}
</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
+10
View File
@@ -87,6 +87,16 @@ spec:
secretKeyRef:
name: buzz-sheet-env
key: BETTER_AUTH_SECRET
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: GOOGLE_CLIENT_ID
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: GOOGLE_CLIENT_SECRET
- name: RESEND_API_KEY
valueFrom:
secretKeyRef:
+36 -2
View File
@@ -7,10 +7,11 @@ vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("better-auth", () => ({ betterAuth: mocks.auth }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: () => ({}) }));
vi.mock("better-auth/next-js", () => ({ nextCookies: () => ({}) }));
vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options, captcha: () => ({}),
vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options,
captcha: (options: unknown) => ({ id: "captcha", options }),
emailOTP: (options: unknown) => ({ id: "email-otp", options }) }));
const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "TRUSTED_CLIENT_IP_HEADER"] as const;
const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "TURNSTILE_SECRET_KEY", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "TRUSTED_CLIENT_IP_HEADER"] as const;
const testEnv = process.env as Record<string, string | undefined>;
const originalEnv = envNames.map((name) => testEnv[name]);
@@ -20,6 +21,9 @@ beforeEach(() => {
testEnv.DATABASE_URL = "postgresql://test.invalid/test";
testEnv.BETTER_AUTH_URL = "https://guide.example.test";
testEnv.BETTER_AUTH_SECRET = "a-test-secret-with-more-than-32-characters";
delete testEnv.GOOGLE_CLIENT_ID;
delete testEnv.GOOGLE_CLIENT_SECRET;
delete testEnv.TURNSTILE_SECRET_KEY;
mocks.auth.mockReturnValue({ api: { getSession: mocks.session } });
mocks.session.mockResolvedValue(null);
delete testEnv.TRUSTED_CLIENT_IP_HEADER;
@@ -57,12 +61,42 @@ describe("actual administrator session boundary", () => {
expect(options.rateLimit.customStorage.consume).toBeTypeOf("function");
expect(options.databaseHooks).toBeUndefined();
});
it("enables Google only with both credentials and requires verified account linking", async () => {
testEnv.GOOGLE_CLIENT_ID = "google-client-id";
testEnv.GOOGLE_CLIENT_SECRET = "google-client-secret";
const { getAuth, isGoogleAuthEnabled } = await import("./server");
expect(isGoogleAuthEnabled()).toBe(true);
getAuth();
const options = mocks.auth.mock.calls.at(-1)![0];
expect(options.socialProviders.google).toEqual({
clientId: "google-client-id", clientSecret: "google-client-secret",
});
expect(options.account.accountLinking).toMatchObject({
enabled: true, disableImplicitLinking: false, requireLocalEmailVerified: true,
});
expect(options.account.accountLinking.trustedProviders).toBeUndefined();
});
it("rejects a partial Google credential configuration", async () => {
testEnv.GOOGLE_CLIENT_ID = "google-client-id";
const { getAuth, isGoogleAuthEnabled } = await import("./server");
expect(isGoogleAuthEnabled).toThrow("GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set together");
expect(getAuth).toThrow("GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set together");
});
it("protects Google sign-in with Turnstile in production", async () => {
testEnv.NODE_ENV = "production";
testEnv.TURNSTILE_SECRET_KEY = "turnstile-secret";
(await import("./server")).getAuth();
const options = mocks.auth.mock.calls.at(-1)![0];
const captchaPlugin = options.plugins.find((plugin: { id?: string }) => plugin.id === "captcha");
expect(captchaPlugin.options.endpoints).toContain("/sign-in/social");
});
it("always enables Guide credentials and registration", async () => {
testEnv.TRUSTED_CLIENT_IP_HEADER = "x-forwarded-for";
(await import("./server")).getAuth();
const options = mocks.auth.mock.calls.at(-1)![0];
expect(options.advanced.ipAddress.ipAddressHeaders).toEqual(["x-forwarded-for"]);
expect(options.emailAndPassword).toMatchObject({ enabled: true });
expect(options.socialProviders).toBeUndefined();
expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(true);
expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "generic-oauth")).toBe(false);
expect(options.databaseHooks).toBeUndefined();
+23 -1
View File
@@ -26,6 +26,19 @@ function required(name: string): string {
return value;
}
function googleCredentials() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (Boolean(clientId) !== Boolean(clientSecret)) {
throw new Error("GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set together.");
}
return clientId && clientSecret ? { clientId, clientSecret } : undefined;
}
export function isGoogleAuthEnabled(): boolean {
return Boolean(googleCredentials());
}
function hasAuthConfiguration(): boolean {
return ["DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET"].every(
(name) => Boolean(process.env[name]),
@@ -33,6 +46,7 @@ function hasAuthConfiguration(): boolean {
}
function createAuth() {
const google = googleCredentials();
return betterAuth({
appName: "Buzz Guide",
database: drizzleAdapter(getDb(), {
@@ -51,6 +65,14 @@ function createAuth() {
.map((origin) => origin.trim())
.filter(Boolean),
secret: required("BETTER_AUTH_SECRET"),
socialProviders: google ? { google } : undefined,
account: {
accountLinking: {
enabled: true,
disableImplicitLinking: false,
requireLocalEmailVerified: true,
},
},
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
@@ -140,7 +162,7 @@ function createAuth() {
captcha({
provider: "cloudflare-turnstile",
secretKey: required("TURNSTILE_SECRET_KEY"),
endpoints: ["/sign-in/email", "/sign-up/email"],
endpoints: ["/sign-in/email", "/sign-up/email", "/sign-in/social"],
}),
]),
admin({ defaultRole: "user" }),
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

+4
View File
@@ -83,6 +83,8 @@ describe("production deployment contract", () => {
expect(manifests).not.toContain("kind: ClusterRoleBinding");
expect(manifests).toContain("kind: NetworkPolicy");
expect(manifests).toContain("key: DATABASE_URL");
expect(manifests).toContain("key: GOOGLE_CLIENT_ID");
expect(manifests).toContain("key: GOOGLE_CLIENT_SECRET");
expect(manifests).not.toContain("secretRef:");
});
@@ -204,6 +206,8 @@ describe("environment template contract", () => {
"DATABASE_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_SECRET",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"REDIS_URL",
"S3_ENDPOINT",
"S3_BUCKET",