diff --git a/README.md b/README.md index 5303d071..4c4ed36c 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,21 @@ The console is available only on the host at . Wait until bash scripts/selfhost.sh create-user --email you@example.com ``` +For a public deployment, create the review account with the `admin` role (use your +existing owner subject if it must keep the current account data): + +```bash +bash scripts/selfhost.sh create-user --email admin@inalpha.dev --roles admin --subject console:dev +``` + +Visitors can then choose **Request a trial** on the sign-in page. Registration creates a +`pending` account without accepting a password. Approval in **Trial Waitlist** generates a +one-time activation link: send it to the applicant's stated email address to verify mailbox +ownership. The applicant sets a password from the link and only then becomes active. Links +expire after 48 hours and can be regenerated; rejected applications remain blocked. +Because the application endpoint is public, keep a persistent per-IP rate limit or challenge +enabled at the reverse proxy / CDN layer; the in-process limits are only a second line of defence. + Sign in at , open **LLM Settings**, and add your provider, model, and personal API key. The Dashboard encrypts it with `LLM_CONFIG_ENCRYPTION_KEY`; the orchestrator and Evolver resolve that owner-scoped credential. The standalone Research service still uses the deployment-level `LLM_PROVIDER` / `LLM_MODEL` and matching provider key in `infra/.env.selfhost`, so configure that block if you need deep dives and treat it as a shared-credential boundary until per-owner propagation lands. Useful operations: diff --git a/README.zh-CN.md b/README.zh-CN.md index 614b4898..e4dc2c73 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -297,6 +297,20 @@ bash scripts/selfhost.sh up bash scripts/selfhost.sh create-user --email you@example.com ``` +公网部署时,用 `admin` 角色创建审核账号;如果该账号需要继承现有 owner 数据,继续使用 +原来的 `console:dev` subject: + +```bash +bash scripts/selfhost.sh create-user --email admin@inalpha.dev --roles admin --subject console:dev +``` + +访客可在登录页选择“申请试用”。注册只创建 `pending` 账号且不收密码。管理员在侧边栏 +“试用审核”批准后会得到一次性激活链接:必须通过邮件把链接发到申请人填写的邮箱,以此 +验证邮箱归属;申请人从链接设置密码后才变为 active。链接 48 小时过期,可重新生成; +被拒绝的申请继续禁止访问,已有账号迁移后保持 active。 +注册端点对公网开放时,反向代理 / CDN 必须启用持久化的 per-IP 限流或人机验证;进程内 +限流只是第二道防线,不能替代边缘防护。 + 在 登录,打开 **LLM Settings**,填写你的 provider、model 与个人 API key。控制台用 `LLM_CONFIG_ENCRYPTION_KEY` 加密后写入数据库,orchestrator 与 Evolver 按 owner 临时解析。独立 Research service 当前仍读取 `infra/.env.selfhost` 中部署级的 `LLM_PROVIDER` / `LLM_MODEL` 与对应 provider key;需要 deep dive 时须配置该段,并在 per-owner 透传落地前把它视为共享凭据边界。 常用操作: diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index df752273..01a30424 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -19,7 +19,33 @@ "collapse": "Collapse sidebar", "expand": "Expand sidebar", "logout": "Sign out", - "config": "Config" + "config": "Config", + "waitlist": "Trial Waitlist" + }, + "adminWaitlist": { + "title": "Trial Waitlist", + "subtitle": "Review access requests before applicants can enter the operator console.", + "loading": "Loading applications…", + "loadFailed": "Could not load the waitlist. You may not have administrator access.", + "retry": "Retry", + "empty": "No applications are waiting for review.", + "unnamed": "Unnamed applicant", + "appliedAt": "Applied {time}", + "note": "Exploration plan", + "noNote": "No note provided.", + "pending": "Pending review", + "invited": "Activation link issued", + "approve": "Approve trial", + "regenerate": "New activation link", + "regenerateConfirm": "Generate a new activation link? Any link already sent to this applicant will stop working.", + "reject": "Reject", + "rejectConfirm": "Reject this trial request? This action cannot be undone from the dashboard.", + "activationReady": "Activation link ready", + "activationInstructions": "Send this one-time link to {email} by email. It expires in 48 hours; sending it to that address verifies mailbox ownership.", + "activationLink": "Activation link", + "copy": "Copy link", + "copied": "Copied", + "actionFailed": "The review could not be saved. The list has been refreshed." }, "theme": { "label": "Theme", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 5014bdfc..fb19157a 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -19,7 +19,33 @@ "collapse": "收起侧边栏", "expand": "展开侧边栏", "logout": "登出", - "config": "配置" + "config": "配置", + "waitlist": "试用审核" + }, + "adminWaitlist": { + "title": "试用候审名单", + "subtitle": "审核注册申请;批准后申请人才可以进入操作者控制台。", + "loading": "正在加载申请…", + "loadFailed": "无法加载候审名单,你可能没有管理员权限。", + "retry": "重试", + "empty": "目前没有等待审核的申请。", + "unnamed": "未填写姓名", + "appliedAt": "申请于 {time}", + "note": "探索计划", + "noNote": "申请人没有填写说明。", + "pending": "等待审核", + "invited": "已生成激活链接", + "approve": "批准试用", + "regenerate": "重新生成激活链接", + "regenerateConfirm": "确定重新生成激活链接吗?之前已经发送给申请人的链接会立即失效。", + "reject": "拒绝", + "rejectConfirm": "确定拒绝这条试用申请吗?控制台暂不支持撤销该操作。", + "activationReady": "激活链接已生成", + "activationInstructions": "请通过邮件把这个一次性链接发送到 {email}。链接 48 小时内有效;发到该邮箱可验证申请人确实拥有邮箱。", + "activationLink": "激活链接", + "copy": "复制链接", + "copied": "已复制", + "actionFailed": "审核结果保存失败,名单已刷新。" }, "theme": { "label": "主题", diff --git a/apps/dashboard/src/app/[locale]/admin/waitlist/page.tsx b/apps/dashboard/src/app/[locale]/admin/waitlist/page.tsx new file mode 100644 index 00000000..78ea89c1 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/admin/waitlist/page.tsx @@ -0,0 +1,18 @@ +import { getTranslations } from "next-intl/server"; + +import { WaitlistClient } from "@/components/admin/WaitlistClient"; + +/** 管理员试用审核页;API 仍会在服务端实时校验 admin 角色。 */ +export default async function AdminWaitlistPage() { + const t = await getTranslations("adminWaitlist"); + return ( +
+
+
Admin
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ ); +} diff --git a/apps/dashboard/src/app/activate/page.tsx b/apps/dashboard/src/app/activate/page.tsx new file mode 100644 index 00000000..edefb1d1 --- /dev/null +++ b/apps/dashboard/src/app/activate/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from "react"; + +import { ActivateForm } from "@/components/auth/ActivateForm"; + +/** 公开账号激活页;令牌从 URL fragment 读取,不进入服务器日志。 */ +export default function ActivatePage() { + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/review-error.ts b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/review-error.ts new file mode 100644 index 00000000..d405f21f --- /dev/null +++ b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/review-error.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +/** 将 paper 审核失败收敛为不泄露后端细节的稳定 BFF 错误契约。 */ +export function reviewErrorResponse(error: unknown): Response { + const status = + typeof error === "object" && error !== null && "status" in error + ? (error as { status?: unknown }).status + : undefined; + if (status === 401 || status === 403) { + return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 }); + } + if (status === 409) { + return NextResponse.json({ error: "ALREADY_REVIEWED" }, { status: 409 }); + } + return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 }); +} diff --git a/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.test.ts b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.test.ts new file mode 100644 index 00000000..005a8d95 --- /dev/null +++ b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { backendFetch } from "@/lib/backend"; + +import { reviewErrorResponse } from "./review-error"; +import { POST } from "./route"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/backend", () => ({ backendFetch: vi.fn() })); + +const mockedBackendFetch = vi.mocked(backendFetch); + +function request(body: unknown): Request { + return new Request("http://dashboard.test/api/admin/waitlist/user/review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function context(subject = "user:applicant/one") { + return { params: Promise.resolve({ subject }) }; +} + +describe("admin waitlist review BFF", () => { + beforeEach(() => mockedBackendFetch.mockReset()); + + it("encodes the subject and forwards a valid decision", async () => { + mockedBackendFetch.mockResolvedValue({ + subject: "user:applicant/one", + access_status: "invited", + activation_token: "one-time-token", + }); + + const response = await POST( + request({ decision: "approve", expected_reviewed_at: null }), + context(), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + subject: "user:applicant/one", + access_status: "invited", + activation_token: "one-time-token", + }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(mockedBackendFetch).toHaveBeenCalledWith( + "paper", + "/auth/waitlist/user%3Aapplicant%2Fone/review", + { + method: "POST", + body: { decision: "approve", expected_reviewed_at: null }, + }, + ); + }); + + it("rejects malformed decisions before calling the backend", async () => { + const response = await POST(request({ decision: "activate" }), context()); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "INVALID_DECISION" }); + expect(mockedBackendFetch).not.toHaveBeenCalled(); + }); + + async function expectMappedFailure( + backendStatus: number, + status: number, + code: string, + ) { + const response = reviewErrorResponse({ status: backendStatus }); + + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ error: code }); + } + + it("maps forbidden review failures", async () => { + await expectMappedFailure(403, 403, "FORBIDDEN"); + }); + + it("maps repeat review conflicts", async () => { + await expectMappedFailure(409, 409, "ALREADY_REVIEWED"); + }); + + it("maps unexpected review failures", async () => { + await expectMappedFailure(500, 502, "SERVICE_UNAVAILABLE"); + }); +}); diff --git a/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.ts b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.ts new file mode 100644 index 00000000..45fb51e5 --- /dev/null +++ b/apps/dashboard/src/app/api/admin/waitlist/[subject]/review/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; + +import { backendFetch } from "@/lib/backend"; + +import { reviewErrorResponse } from "./review-error"; + +/** + * 管理员审核 BFF。 + * + * 功能:批准或拒绝一条 pending 申请。 + * 何时用:waitlist 页面提交单次审核动作时。 + * 何时不用:修改已审核用户;后端状态机只接受 pending 转换。 + * 坑:subject 必须 URL 编码,且并发重复审核会返回 409。 + */ +export async function POST( + req: Request, + context: { params: Promise<{ subject: string }> }, +): Promise { + const { subject } = await context.params; + let decision: unknown; + let expectedReviewedAt: unknown; + try { + ({ decision, expected_reviewed_at: expectedReviewedAt } = await req.json()); + } catch { + return NextResponse.json({ error: "INVALID_REQUEST" }, { status: 400 }); + } + if (decision !== "approve" && decision !== "reject") { + return NextResponse.json({ error: "INVALID_DECISION" }, { status: 400 }); + } + if (expectedReviewedAt !== null && typeof expectedReviewedAt !== "string") { + return NextResponse.json({ error: "INVALID_REVIEW_VERSION" }, { status: 400 }); + } + + try { + const result = await backendFetch<{ subject: string; access_status: string }>( + "paper", + `/auth/waitlist/${encodeURIComponent(subject)}/review`, + { + method: "POST", + body: { decision, expected_reviewed_at: expectedReviewedAt }, + }, + ); + return NextResponse.json(result, { + headers: { "Cache-Control": "no-store" }, + }); + } catch (err) { + return reviewErrorResponse(err); + } +} diff --git a/apps/dashboard/src/app/api/admin/waitlist/route.test.ts b/apps/dashboard/src/app/api/admin/waitlist/route.test.ts new file mode 100644 index 00000000..119a2572 --- /dev/null +++ b/apps/dashboard/src/app/api/admin/waitlist/route.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { backendFetch } from "@/lib/backend"; + +import { GET } from "./route"; + +vi.mock("@/lib/backend", () => ({ backendFetch: vi.fn() })); + +const mockedBackendFetch = vi.mocked(backendFetch); + +describe("admin waitlist BFF", () => { + beforeEach(() => mockedBackendFetch.mockReset()); + + it("returns only the backend-approved waitlist payload", async () => { + const payload = { users: [{ subject: "user:one", email: "one@example.com" }] }; + mockedBackendFetch.mockResolvedValue(payload); + + const response = await GET(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(payload); + expect(mockedBackendFetch).toHaveBeenCalledWith("paper", "/auth/waitlist"); + }); +}); diff --git a/apps/dashboard/src/app/api/admin/waitlist/route.ts b/apps/dashboard/src/app/api/admin/waitlist/route.ts new file mode 100644 index 00000000..74a2b3af --- /dev/null +++ b/apps/dashboard/src/app/api/admin/waitlist/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +import { backendFetch } from "@/lib/backend"; + +/** + * 管理员待审列表 BFF。 + * + * 功能:读取 paper 中的 pending 用户。 + * 何时用:管理员打开 waitlist 页面时。 + * 何时不用:普通用户查询自己的申请状态;登录接口会给出该状态。 + * 坑:权限必须由 paper 查询数据库角色判定,不能只信 session 或前端菜单。 + */ +export async function GET(): Promise { + try { + const result = await backendFetch<{ users: unknown[] }>("paper", "/auth/waitlist"); + return NextResponse.json(result); + } catch (err) { + const status = + typeof err === "object" && err !== null && "status" in err + ? (err as { status?: unknown }).status + : undefined; + if (status === 401 || status === 403) { + return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 }); + } + return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/app/api/auth/activate/activate-error.ts b/apps/dashboard/src/app/api/auth/activate/activate-error.ts new file mode 100644 index 00000000..44b3dafa --- /dev/null +++ b/apps/dashboard/src/app/api/auth/activate/activate-error.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +/** 将激活后端错误映射为稳定且不泄露内部细节的公开响应。 */ +export function activateErrorResponse(error: unknown): Response { + const status = + typeof error === "object" && error !== null && "status" in error + ? (error as { status?: unknown }).status + : undefined; + if (status === 400 || status === 422) { + return NextResponse.json({ error: "INVALID_ACTIVATION" }, { status: 400 }); + } + if (status === 409) { + return NextResponse.json({ error: "ACTIVATION_USED" }, { status: 409 }); + } + if (status === 429) { + return NextResponse.json( + { error: "ACTIVATION_BUSY" }, + { status: 429, headers: { "Retry-After": "5" } }, + ); + } + return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 }); +} diff --git a/apps/dashboard/src/app/api/auth/activate/route.test.ts b/apps/dashboard/src/app/api/auth/activate/route.test.ts new file mode 100644 index 00000000..95498b3e --- /dev/null +++ b/apps/dashboard/src/app/api/auth/activate/route.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { backendFetch } from "@/lib/backend"; + +import { activateErrorResponse } from "./activate-error"; +import { POST } from "./route"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/backend", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, backendFetch: vi.fn() }; +}); + +const mockedBackendFetch = vi.mocked(backendFetch); + +function request(body: unknown): Request { + return new Request("http://dashboard.test/api/auth/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("activation BFF", () => { + beforeEach(() => mockedBackendFetch.mockReset()); + + it("forwards the one-time token without service authentication", async () => { + mockedBackendFetch.mockResolvedValue({ activated: true }); + const body = { token: "t".repeat(43), password: "long-enough-password" }; + + const response = await POST(request(body)); + + expect(response.status).toBe(200); + expect(mockedBackendFetch).toHaveBeenCalledWith("paper", "/auth/activate", { + auth: false, + method: "POST", + body, + timeoutMs: 15_000, + }); + }); + + it.each([ + [400, 400, "INVALID_ACTIVATION"], + [409, 409, "ACTIVATION_USED"], + [429, 429, "ACTIVATION_BUSY"], + [500, 502, "SERVICE_UNAVAILABLE"], + ] as const)("maps backend status %s", async (backendStatus, status, code) => { + const response = activateErrorResponse({ status: backendStatus }); + + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ error: code }); + }); + + it("adds Retry-After when activation capacity is saturated", async () => { + const response = activateErrorResponse({ status: 429 }); + + expect(response.headers.get("Retry-After")).toBe("5"); + }); +}); diff --git a/apps/dashboard/src/app/api/auth/activate/route.ts b/apps/dashboard/src/app/api/auth/activate/route.ts new file mode 100644 index 00000000..329e9a96 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/activate/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; + +import { backendFetch } from "@/lib/backend"; + +import { PublicJsonError, readLimitedJson } from "../request-json"; +import { activateErrorResponse } from "./activate-error"; + +/** 公开激活 BFF;一次性令牌换取密码设置,成功后账号才变为 active。 */ +export async function POST(req: Request): Promise { + let body: unknown; + try { + body = await readLimitedJson(req); + } catch (error) { + const status = error instanceof PublicJsonError ? error.status : 400; + return NextResponse.json({ error: "INVALID_REQUEST" }, { status }); + } + try { + await backendFetch("paper", "/auth/activate", { + auth: false, + method: "POST", + body, + timeoutMs: 15_000, + }); + return NextResponse.json({ activated: true }); + } catch (error) { + return activateErrorResponse(error); + } +} diff --git a/apps/dashboard/src/app/api/auth/login/route.test.ts b/apps/dashboard/src/app/api/auth/login/route.test.ts new file mode 100644 index 00000000..7dad3670 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/login/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { BackendError, backendFetch } from "@/lib/backend"; +import { createSessionToken } from "@/lib/session"; + +import { POST } from "./route"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/backend", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, backendFetch: vi.fn() }; +}); +vi.mock("@/lib/session", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createSessionToken: vi.fn() }; +}); + +const mockedBackendFetch = vi.mocked(backendFetch); +const mockedCreateSessionToken = vi.mocked(createSessionToken); + +function loginRequest(): Request { + return new Request("http://dashboard.test/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "applicant@example.com", password: "password" }), + }); +} + +describe("login BFF access status", () => { + beforeEach(() => { + mockedBackendFetch.mockReset(); + mockedCreateSessionToken.mockReset(); + }); + + it("preserves the generic inactive status after valid credentials", async () => { + const code = "ACCOUNT_INACTIVE"; + mockedBackendFetch.mockRejectedValue(new BackendError(403, code, { code })); + + const response = await POST(loginRequest()); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: code }); + expect(mockedCreateSessionToken).not.toHaveBeenCalled(); + }); + + it("does not expose an unknown forbidden response as an account status", async () => { + mockedBackendFetch.mockRejectedValue(new BackendError(403, "forbidden")); + + const response = await POST(loginRequest()); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: "登录服务暂不可用,请稍后重试" }); + expect(mockedCreateSessionToken).not.toHaveBeenCalled(); + }); + + it("rejects an oversized public request before calling the backend", async () => { + const request = new Request("http://dashboard.test/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "a@example.com", password: "x".repeat(17_000) }), + }); + + const response = await POST(request); + + expect(response.status).toBe(413); + expect(mockedBackendFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/src/app/api/auth/login/route.ts b/apps/dashboard/src/app/api/auth/login/route.ts index 80fcc1af..bb262326 100644 --- a/apps/dashboard/src/app/api/auth/login/route.ts +++ b/apps/dashboard/src/app/api/auth/login/route.ts @@ -8,6 +8,8 @@ import { createSessionToken, } from "@/lib/session"; +import { PublicJsonError, readLimitedJson } from "../request-json"; + /** * 登录:校验凭据 → 落 session cookie。 * @@ -18,9 +20,13 @@ export async function POST(req: Request): Promise { let email: unknown; let password: unknown; try { - ({ email, password } = await req.json()); - } catch { - return NextResponse.json({ error: "请求体格式错误" }, { status: 400 }); + ({ email, password } = (await readLimitedJson(req)) as { + email?: unknown; + password?: unknown; + }); + } catch (error) { + const status = error instanceof PublicJsonError ? error.status : 400; + return NextResponse.json({ error: "请求体格式错误" }, { status }); } if (typeof email !== "string" || typeof password !== "string" || !email || !password) { return NextResponse.json({ error: "缺少邮箱或密码" }, { status: 400 }); @@ -55,6 +61,13 @@ export async function POST(req: Request): Promise { { status: 429 }, ); } + if (err instanceof BackendError && err.status === 403) { + const detail = err.detail as { code?: unknown } | undefined; + const code = typeof detail?.code === "string" ? detail.code : undefined; + if (code === "ACCOUNT_INACTIVE") { + return NextResponse.json({ error: code }, { status: 403 }); + } + } return NextResponse.json({ error: "登录服务暂不可用,请稍后重试" }, { status: 502 }); } } diff --git a/apps/dashboard/src/app/api/auth/register/route.test.ts b/apps/dashboard/src/app/api/auth/register/route.test.ts new file mode 100644 index 00000000..42a0b800 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/register/route.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { BackendError, backendFetch } from "@/lib/backend"; + +import { POST } from "./route"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/backend", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, backendFetch: vi.fn() }; +}); + +const mockedBackendFetch = vi.mocked(backendFetch); + +function request(body: unknown): Request { + return new Request("http://dashboard.test/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("registration BFF", () => { + beforeEach(() => mockedBackendFetch.mockReset()); + + it("forwards the application without a service token", async () => { + mockedBackendFetch.mockResolvedValue({ accepted: true }); + const body = { + email: "applicant@example.com", + display_name: "Applicant", + application_note: "Factor validation", + }; + + const response = await POST(request(body)); + + expect(response.status).toBe(202); + expect(mockedBackendFetch).toHaveBeenCalledWith("paper", "/auth/register", { + auth: false, + method: "POST", + body, + timeoutMs: 15_000, + }); + }); + + it("maps validation and throttle failures without exposing backend details", async () => { + mockedBackendFetch.mockRejectedValueOnce(new BackendError(422, "invalid", { secret: true })); + mockedBackendFetch.mockRejectedValueOnce(new BackendError(429, "limited")); + + const invalid = await POST(request({})); + const limited = await POST(request({})); + + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ error: "INVALID_APPLICATION" }); + expect(limited.status).toBe(429); + expect(await limited.json()).toEqual({ error: "RATE_LIMITED" }); + }); + + it("rejects non-JSON and oversized public requests before forwarding", async () => { + const wrongType = new Request("http://dashboard.test/api/auth/register", { + method: "POST", + body: "plain text", + }); + const oversized = new Request("http://dashboard.test/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ application_note: "x".repeat(17 * 1024) }), + }); + + const wrongTypeResponse = await POST(wrongType); + const oversizedResponse = await POST(oversized); + + expect(wrongTypeResponse.status).toBe(415); + expect(oversizedResponse.status).toBe(413); + expect(mockedBackendFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/src/app/api/auth/register/route.ts b/apps/dashboard/src/app/api/auth/register/route.ts new file mode 100644 index 00000000..9ff5f963 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/register/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; + +import { BackendError, backendFetch } from "@/lib/backend"; + +import { PublicJsonError, readLimitedJson } from "../request-json"; + +/** + * 公开注册申请 BFF。 + * + * 功能:把表单透传给内网 paper 的 waitlist 注册端点。 + * 何时用:未登录访客申请试用时。 + * 何时不用:创建已批准账号或管理员改密仍走受控 CLI。 + * 坑:后端对重复邮箱返回同一 202,前端不得据此判断邮箱是否存在。 + */ +export async function POST(req: Request): Promise { + let body: unknown; + try { + body = await readLimitedJson(req); + } catch (error) { + const status = error instanceof PublicJsonError ? error.status : 400; + return NextResponse.json({ error: "INVALID_REQUEST" }, { status }); + } + + try { + await backendFetch("paper", "/auth/register", { + auth: false, + method: "POST", + body, + timeoutMs: 15_000, + }); + return NextResponse.json({ accepted: true }, { status: 202 }); + } catch (err) { + if (err instanceof BackendError && err.status === 400) { + return NextResponse.json({ error: "INVALID_APPLICATION" }, { status: 400 }); + } + if (err instanceof BackendError && err.status === 422) { + return NextResponse.json({ error: "INVALID_APPLICATION" }, { status: 400 }); + } + if (err instanceof BackendError && err.status === 429) { + return NextResponse.json({ error: "RATE_LIMITED" }, { status: 429 }); + } + return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/app/api/auth/request-json.ts b/apps/dashboard/src/app/api/auth/request-json.ts new file mode 100644 index 00000000..5fe25bbe --- /dev/null +++ b/apps/dashboard/src/app/api/auth/request-json.ts @@ -0,0 +1,45 @@ +const DEFAULT_MAX_BYTES = 16 * 1024; + +/** 公开 JSON 请求读取错误;保留安全的 HTTP 状态,不携带原始请求内容。 */ +export class PublicJsonError extends Error { + constructor(public status: 400 | 413 | 415) { + super("invalid public JSON request"); + this.name = "PublicJsonError"; + } +} + +/** 流式读取公开端点 JSON,并在分配完整正文前执行类型与字节上限检查。 */ +export async function readLimitedJson( + request: Request, + maxBytes = DEFAULT_MAX_BYTES, +): Promise { + const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim(); + if (contentType !== "application/json") throw new PublicJsonError(415); + + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new PublicJsonError(413); + } + if (!request.body) throw new PublicJsonError(400); + + const reader = request.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw new PublicJsonError(413); + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + try { + return JSON.parse(text) as unknown; + } catch { + throw new PublicJsonError(400); + } +} diff --git a/apps/dashboard/src/app/api/auth/session/route.ts b/apps/dashboard/src/app/api/auth/session/route.ts index 9b9ce461..2f03f5ca 100644 --- a/apps/dashboard/src/app/api/auth/session/route.ts +++ b/apps/dashboard/src/app/api/auth/session/route.ts @@ -9,6 +9,8 @@ import { readSession } from "@/lib/session"; export async function GET(): Promise { const session = await readSession(); return NextResponse.json({ - user: session ? { email: session.email, subject: session.subject } : null, + user: session + ? { email: session.email, subject: session.subject, roles: session.roles } + : null, }); } diff --git a/apps/dashboard/src/app/register/page.tsx b/apps/dashboard/src/app/register/page.tsx new file mode 100644 index 00000000..bba29df7 --- /dev/null +++ b/apps/dashboard/src/app/register/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; + +import { RegisterForm } from "@/components/auth/RegisterForm"; + +export const metadata = { + title: "Request access · Inalpha", + robots: { index: false, follow: false }, +}; + +/** 公开注册申请页,放在 locale 控制台外壳之外,避免未登录组件发起受保护请求。 */ +export default function RegisterPage() { + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/components/admin/WaitlistClient.tsx b/apps/dashboard/src/components/admin/WaitlistClient.tsx new file mode 100644 index 00000000..77f96a6f --- /dev/null +++ b/apps/dashboard/src/components/admin/WaitlistClient.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { Check, LoaderCircle, RefreshCw, UserRoundCheck, X } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import useSWR from "swr"; + +interface WaitlistUser { + subject: string; + email: string; + display_name: string | null; + application_note: string | null; + access_status: "pending" | "invited"; + created_at: string; + reviewed_at: string | null; +} + +interface WaitlistResponse { + users: WaitlistUser[]; +} + +const fetcher = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) throw new Error(String(response.status)); + return response.json() as Promise; +}; + +/** 管理员 waitlist:展示申请背景并提供批准/拒绝两种原子审核动作。 */ +export function WaitlistClient() { + const t = useTranslations("adminWaitlist"); + const locale = useLocale(); + const { data, error, isLoading, mutate } = useSWR( + "/api/admin/waitlist", + fetcher, + ); + const [reviewing, setReviewing] = useState(null); + const [actionError, setActionError] = useState(null); + const [activation, setActivation] = useState<{ email: string; link: string } | null>(null); + const [copied, setCopied] = useState(false); + + async function review(user: WaitlistUser, decision: "approve" | "reject") { + if (decision === "reject" && !window.confirm(t("rejectConfirm"))) return; + if ( + decision === "approve" && + user.access_status === "invited" && + !window.confirm(t("regenerateConfirm")) + ) { + return; + } + setReviewing(user.subject); + setActionError(null); + try { + const response = await fetch( + `/api/admin/waitlist/${encodeURIComponent(user.subject)}/review`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + decision, + expected_reviewed_at: user.reviewed_at, + }), + }, + ); + if (!response.ok) throw new Error(String(response.status)); + const result = (await response.json()) as { activation_token?: string | null }; + if (decision === "approve" && result.activation_token) { + const link = `${window.location.origin}/activate?from=${encodeURIComponent(`/${locale}`)}#token=${encodeURIComponent(result.activation_token)}`; + setActivation({ email: user.email, link }); + setCopied(false); + } + await mutate(); + } catch { + setActionError(t("actionFailed")); + await mutate(); + } finally { + setReviewing(null); + } + } + + if (isLoading) { + return ( +
+ {t("loading")} +
+ ); + } + + if (error) { + return ( +
+

{t("loadFailed")}

+ +
+ ); + } + + const users = data?.users ?? []; + if (users.length === 0) { + return ( +
+ +

{t("empty")}

+
+ ); + } + + return ( +
+ {actionError &&

{actionError}

} + {activation && ( +
+

{t("activationReady")}

+

+ {t("activationInstructions", { email: activation.email })} +

+
+ + +
+
+ )} + {users.map((user) => { + const busy = reviewing !== null; + const current = reviewing === user.subject; + return ( +
+
+
+

{user.display_name || t("unnamed")}

+

{user.email}

+

+ {t(user.access_status === "invited" ? "invited" : "pending")} +

+

+ {t("appliedAt", { + time: new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(user.created_at)), + })} +

+
+
+ + +
+
+
+
{t("note")}
+

{user.application_note || t("noNote")}

+
+
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/auth/ActivateForm.tsx b/apps/dashboard/src/components/auth/ActivateForm.tsx new file mode 100644 index 00000000..523d50f9 --- /dev/null +++ b/apps/dashboard/src/components/auth/ActivateForm.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +import { pickLoginLocale } from "./login-locale"; + +const STRINGS = { + en: { + title: "Activate trial access", + password: "New password", + confirm: "Confirm password", + hint: "At least 12 characters", + submit: "Activate account", + submitting: "Activating…", + mismatch: "Passwords do not match", + invalid: "This activation link is invalid, expired, or already used", + busy: "Activation is busy. Please wait a few seconds and try again.", + unavailable: "Activation service unavailable, try again later", + success: "Account activated. You can now sign in.", + login: "Continue to sign in", + }, + zh: { + title: "激活试用账号", + password: "设置密码", + confirm: "确认密码", + hint: "至少 12 个字符", + submit: "激活账号", + submitting: "正在激活…", + mismatch: "两次输入的密码不一致", + invalid: "激活链接无效、已过期或已经使用", + busy: "激活请求较多,请等待几秒后重试", + unavailable: "激活服务暂不可用,请稍后重试", + success: "账号已激活,现在可以登录。", + login: "前往登录", + }, +}; + +const INPUT_CLASS = + "rounded-md border border-border-subtle bg-bg-deep/60 px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-cyan/50"; + +/** 一次性激活表单;读取 fragment 后立即清理地址栏,避免令牌留在历史记录。 */ +export function ActivateForm() { + const params = useSearchParams(); + const from = params.get("from"); + const t = STRINGS[pickLoginLocale(from, undefined)]; + const [token, setToken] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + const loginHref = `/login${from ? `?from=${encodeURIComponent(from)}` : ""}`; + + useEffect(() => { + const fragment = new URLSearchParams(window.location.hash.slice(1)); + const activationToken = fragment.get("token") ?? ""; + setToken(activationToken); + if (!activationToken) setError(t.invalid); + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); + }, [t.invalid]); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token || password !== confirm) { + setError(token ? t.mismatch : t.invalid); + return; + } + setLoading(true); + setError(null); + try { + const response = await fetch("/api/auth/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + if (response.ok) { + setToken(""); + setSuccess(true); + return; + } + setError( + response.status === 400 || response.status === 409 + ? t.invalid + : response.status === 429 + ? t.busy + : t.unavailable, + ); + } catch { + setError(t.unavailable); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Inalpha +

{t.title}

+
+ {success ? ( +
+

{t.success}

+ {t.login} +
+ ) : ( +
+ + + {error &&

{error}

} + +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/auth/LoginForm.tsx b/apps/dashboard/src/components/auth/LoginForm.tsx index 7a2de9c4..fdd0c312 100644 --- a/apps/dashboard/src/components/auth/LoginForm.tsx +++ b/apps/dashboard/src/components/auth/LoginForm.tsx @@ -21,6 +21,9 @@ const STRINGS = { invalid: "Incorrect email or password", rateLimited: "Too many attempts, try again later", unavailable: "Login service unavailable, try again later", + inactive: "This account is not active", + applyPrompt: "Need access?", + apply: "Request a trial", }, zh: { title: "操作控制台", @@ -32,6 +35,9 @@ const STRINGS = { invalid: "邮箱或密码不正确", rateLimited: "尝试过于频繁,请稍后再试", unavailable: "登录服务暂不可用,请稍后重试", + inactive: "该账号当前不可登录", + applyPrompt: "还没有访问权限?", + apply: "申请试用", }, }; @@ -63,12 +69,15 @@ export function LoginForm() { router.refresh(); return; } + const data = (await res.json().catch(() => null)) as { error?: string } | null; setError( res.status === 401 ? t.invalid - : res.status === 429 - ? t.rateLimited - : t.unavailable, + : res.status === 403 && data?.error === "ACCOUNT_INACTIVE" + ? t.inactive + : res.status === 429 + ? t.rateLimited + : t.unavailable, ); } catch { setError(t.unavailable); @@ -144,6 +153,16 @@ export function LoginForm() { > {loading ? t.submitting : t.submit} + +

+ {t.applyPrompt}{" "} + + {t.apply} + +

); } diff --git a/apps/dashboard/src/components/auth/RegisterForm.tsx b/apps/dashboard/src/components/auth/RegisterForm.tsx new file mode 100644 index 00000000..0405d1a3 --- /dev/null +++ b/apps/dashboard/src/components/auth/RegisterForm.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; + +import { pickLoginLocale } from "./login-locale"; + +const STRINGS = { + en: { + title: "Trial access", + subtitle: "Tell us how you would like to explore Inalpha", + name: "Name", + email: "Email", + note: "What would you like to explore? (optional)", + submit: "Join the waitlist", + submitting: "Submitting…", + successTitle: "Request received", + successBody: "We will review your request. If approved, you will receive a one-time activation link by email.", + invalid: "Please check the form and try again", + rateLimited: "Too many requests. Please wait before trying again", + unavailable: "Registration service unavailable, try again later", + back: "Back to sign in", + }, + zh: { + title: "申请试用", + subtitle: "告诉我们你希望如何探索 Inalpha", + name: "姓名", + email: "邮箱", + note: "你希望探索哪些方向?(选填)", + submit: "加入候审名单", + submitting: "提交中…", + successTitle: "申请已收到", + successBody: "我们会尽快审核。通过后,管理员会通过邮件发送一次性激活链接。", + invalid: "请检查填写内容后重试", + rateLimited: "申请过于频繁,请稍后再试", + unavailable: "注册服务暂不可用,请稍后重试", + back: "返回登录", + }, +}; + +const INPUT_CLASS = + "rounded-md border border-border-subtle bg-bg-deep/60 px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-cyan/50"; + +/** 公开试用申请表单;申请成功只进入 waitlist,不建立登录会话。 */ +export function RegisterForm() { + const params = useSearchParams(); + const from = params.get("from"); + const t = STRINGS[pickLoginLocale(from, undefined)]; + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [applicationNote, setApplicationNote] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + const loginHref = `/login${from ? `?from=${encodeURIComponent(from)}` : ""}`; + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + display_name: displayName, + email, + application_note: applicationNote, + }), + }); + if (response.ok) { + setSuccess(true); + return; + } + setError( + response.status === 400 + ? t.invalid + : response.status === 429 + ? t.rateLimited + : t.unavailable, + ); + } catch { + setError(t.unavailable); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Inalpha +
+
Inalpha
+
{t.title}
+
+
+ + {success ? ( +
+

{t.successTitle}

+

{t.successBody}

+ {t.back} +
+ ) : ( +
+

{t.subtitle}

+
+ + setDisplayName(event.target.value)} className={INPUT_CLASS} /> + + + setEmail(event.target.value)} className={INPUT_CLASS} /> + + +