feat : add icon and introduce login with google
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" } },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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)}`}`} />}>
|
||||
มีบัญชีแล้ว? เข้าสู่ระบบ
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 ไม่สำเร็จ");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user