feat : use guide login only [no ci]

This commit is contained in:
2026-10-06 18:41:28 +07:00 Unverified
parent 1339dd43e6
commit 3c8f60433a
28 changed files with 50 additions and 783 deletions
+11 -11
View File
@@ -1,13 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const session = vi.fn();
const handler = vi.fn(async () => Response.json({ passed: true }));
vi.mock("server-only", () => ({}));
vi.mock("better-auth/next-js", () => ({ toNextJsHandler: () => ({ GET: handler }) }));
vi.mock("@/lib/auth/server", () => ({
getAuth: () => ({ api: { getSession: session }, handler }),
isSudlohOidcEnabled: () => true,
getAuth: () => ({ handler }),
}));
const { GET, POST } = await import("./route");
@@ -15,11 +13,9 @@ const { GET, POST } = await import("./route");
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
process.env.SUDLOH_OIDC_ONLY = "true";
session.mockResolvedValue({ user: { id: "user-1" }, session: { id: "session-1" } });
});
describe("Better Auth Sudloh boundary", () => {
describe("Better Auth route", () => {
it("lets Better Auth serve the local browser session", async () => {
const response = await GET(new Request("https://guide.sudloh.com/api/auth/get-session"));
expect(response.status).toBe(200);
@@ -27,11 +23,6 @@ describe("Better Auth Sudloh boundary", () => {
expect(handler).toHaveBeenCalledOnce();
});
it("permits the OIDC callback", async () => {
const callback = await GET(new Request("https://guide.sudloh.com/api/auth/callback/sudloh?code=code"));
expect(callback.status).toBe(200);
});
it("passes mutations to Better Auth for local session checks", async () => {
const response = await POST(new Request("https://guide.sudloh.com/api/auth/admin/create-user", {
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
@@ -40,4 +31,13 @@ describe("Better Auth Sudloh boundary", () => {
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledOnce();
});
it("passes local profile changes to Better Auth", async () => {
const response = await POST(new Request("https://guide.sudloh.com/api/auth/change-email", {
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
body: JSON.stringify({ newEmail: "[email protected]" }),
}));
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledOnce();
});
});
+1 -16
View File
@@ -1,9 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";
import { and, eq } from "drizzle-orm";
import { getAuth, isSudlohOidcEnabled } from "@/lib/auth/server";
import { getDb } from "@/db";
import { accounts } from "@/db/schema";
import { getAuth } from "@/lib/auth/server";
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
const handlers = toNextJsHandler((request) => getAuth().handler(request));
@@ -17,17 +13,6 @@ async function mutate(request: Request) {
requireSameOrigin(request);
const input = await readJson(request.clone());
const path = new URL(request.url).pathname;
if (["/update-user", "/change-email"].some((endpoint) => path.endsWith(endpoint))) {
if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true")
throw new HttpError(403, "manage-profile-at-sudloh");
const session = await getAuth().api.getSession({ headers: request.headers });
if (session) {
const [linked] = await getDb().select({ id: accounts.id }).from(accounts).where(and(
eq(accounts.userId, session.user.id), eq(accounts.providerId, "sudloh"),
)).limit(1);
if (linked) throw new HttpError(403, "manage-profile-at-sudloh");
}
}
if (["/admin/create-user", "/admin/set-user-password"].some((endpoint) => path.endsWith(endpoint))) {
const password = input && typeof input === "object" && "password" in input ? input.password : undefined;
const newPassword = input && typeof input === "object" && "newPassword" in input ? input.newPassword : undefined;
-8
View File
@@ -7,12 +7,10 @@ const returning = vi.fn();
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const write = vi.fn();
const linkedAccounts = vi.fn();
vi.mock("@/lib/commission/server", () => ({ requireCommissionUser }));
vi.mock("@/db", () => ({ getDb: () => ({
update: () => ({ set }),
select: () => ({ from: () => ({ where: () => ({ limit: linkedAccounts }) }) }),
}) }));
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write }), publicMediaUrl: (key: string) => `https://cdn.test/${key}` }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined }));
@@ -31,7 +29,6 @@ describe("profile update", () => {
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
vi.clearAllMocks();
requireCommissionUser.mockResolvedValue({ id: "user-1", image: null });
linkedAccounts.mockResolvedValue([]);
returning.mockResolvedValue([{ name: "New Name", image: null }]);
});
@@ -66,9 +63,4 @@ describe("profile update", () => {
expect(set).not.toHaveBeenCalled();
});
it("sends Sudloh-linked users to Sudloh for profile changes", async () => {
linkedAccounts.mockResolvedValueOnce([{ id: "sudloh-account" }]);
expect((await POST(profileRequest("New Name"))).status).toBe(403);
expect(set).not.toHaveBeenCalled();
});
});
+2 -5
View File
@@ -1,7 +1,7 @@
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import sharp from "sharp";
import { getDb } from "@/db";
import { accounts, users } from "@/db/schema";
import { users } from "@/db/schema";
import { requireCommissionUser } from "@/lib/commission/server";
import { inspectImage } from "@/lib/media/inspect";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
@@ -15,9 +15,6 @@ export async function POST(request: Request) {
try {
requireSameOrigin(request);
const user = await requireCommissionUser();
const [sudloh] = await getDb().select({ id: accounts.id }).from(accounts)
.where(and(eq(accounts.userId, user.id), eq(accounts.providerId, "sudloh"))).limit(1);
if (sudloh) throw new HttpError(403, "manage-profile-at-sudloh");
await limitRequest("profile-update", user.id, 20);
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
throw new HttpError(415, "expected-multipart");
-13
View File
@@ -1,13 +0,0 @@
import { getCustomerSession } from "@/lib/auth/server";
import { refreshLinkedSudlohProfile } from "@/lib/auth/sudloh";
import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http";
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const session = await getCustomerSession();
if (!session) throw new HttpError(401, "unauthorized");
await refreshLinkedSudlohProfile(session.user.id);
return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } });
} catch (cause) { return errorResponse(cause); }
}
+1 -4
View File
@@ -2,16 +2,13 @@ import { SiteHeader } from "@/components/public/site-header";
import { EmailActionForm } from "@/components/auth/email-action-form";
import { connection } from "next/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { isSudlohOidcEnabled } from "@/lib/auth/server";
export const instant = false;
export default async function ForgotPasswordPage({ searchParams }: PageProps<"/auth/forgot-password">) {
await connection();
const { next } = await searchParams;
const oidcEnabled = isSudlohOidcEnabled();
const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true";
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<EmailActionForm mode="forgot" nextPath={safeAuthReturnPath(next)} oidcEnabled={oidcEnabled} oidcOnly={oidcOnly} />
<EmailActionForm mode="forgot" nextPath={safeAuthReturnPath(next)} />
</main></div>;
}
+2 -3
View File
@@ -5,8 +5,7 @@ export const instant = false;
export default async function LoginPage({ searchParams }: PageProps<"/auth/login">) {
await connection();
const { next, verified, error, sudloh } = await searchParams;
const oidcError = sudloh === "1" && typeof error === "string" ? error : undefined;
const { next, verified, error } = await searchParams;
return <AccountPage mode="login" next={next} verified={verified === "1" && !error}
verificationError={typeof error === "string" && !oidcError} oidcError={oidcError} />;
verificationError={typeof error === "string"} />;
}
+1 -4
View File
@@ -2,17 +2,14 @@ import { connection } from "next/server";
import { SiteHeader } from "@/components/public/site-header";
import { EmailActionForm } from "@/components/auth/email-action-form";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { isSudlohOidcEnabled } from "@/lib/auth/server";
export const instant = false;
export default async function ResetPasswordPage({ searchParams }: PageProps<"/auth/password-reset">) {
await connection();
const { token, error, next } = await searchParams;
const oidcEnabled = isSudlohOidcEnabled();
const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true";
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<EmailActionForm mode="reset" token={typeof token === "string" ? token : ""} invalidToken={typeof error === "string"}
nextPath={safeAuthReturnPath(next)} oidcEnabled={oidcEnabled} oidcOnly={oidcOnly} />
nextPath={safeAuthReturnPath(next)} />
</main></div>;
}
+8 -16
View File
@@ -8,33 +8,28 @@ import { AdminHeader } from "@/components/admin/admin-header";
import { ProfileForm } from "@/components/auth/profile-form";
import { PasswordForm } from "@/components/auth/password-form";
import { EmailSettings } from "@/components/auth/email-settings";
import { SudlohConnection } from "@/components/auth/sudloh-connection";
import { SudlohProfile } from "@/components/auth/sudloh-profile";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server";
import { getCustomerSession } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { ACCOUNT_SETTINGS_URL } from "@/lib/auth/sudloh";
export const instant = false;
export default async function ProfilePage({ searchParams }: PageProps<"/profile">) {
await connection();
const { setup, next, upload, emailAction, error, sudloh } = await searchParams;
const { setup, next, upload, emailAction, error } = await searchParams;
const nextPath = safeAuthReturnPath(next);
const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath;
const session = await getCustomerSession();
if (!session) redirect("/auth/login?next=%2Fprofile");
const oidcEnabled = isSudlohOidcEnabled();
const [userRows, accountRows] = await Promise.all([
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),
oidcEnabled ? getDb().select({ providerId: accounts.providerId })
.from(accounts).where(eq(accounts.userId, session.user.id)) : Promise.resolve([]),
getDb().select({ providerId: accounts.providerId })
.from(accounts).where(eq(accounts.userId, session.user.id)),
]);
const [user] = userRows;
if (!user) redirect("/auth/login?next=%2Fprofile");
const hasSudloh = accountRows.some((account) => account.providerId === "sudloh");
const hasCredential = accountRows.some((account) => account.providerId === "credential");
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">
@@ -44,13 +39,10 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
"เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}
</p>}
<div className="grid items-start gap-6 md:grid-cols-2">
{hasSudloh ? <SudlohProfile {...user} accountUrl={ACCOUNT_SETTINGS_URL} callbackError={sudloh === "error"} />
: <ProfileForm {...user} nextPath={setup === "1" ? setupNextPath : undefined} />}
{oidcEnabled && !hasSudloh && <SudlohConnection linked={hasSudloh}
callbackError={sudloh === "error" && typeof error === "string" ? error : undefined} />}
{!hasSudloh && <EmailSettings email={user.email} verified={user.emailVerified}
callbackCompleted={emailAction === "1" && !error} callbackError={typeof error === "string" && sudloh !== "error"} />}
{!hasSudloh && hasCredential && <PasswordForm />}
<ProfileForm {...user} nextPath={setup === "1" ? setupNextPath : undefined} />
<EmailSettings email={user.email} verified={user.emailVerified}
callbackCompleted={emailAction === "1" && !error} callbackError={typeof error === "string"} />
{hasCredential && <PasswordForm />}
</div>
</main>
</div>;