diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 2c87e9c..ecc067f 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -599,6 +599,26 @@ async function handlePublic(ctx: RequestContext): Promise { sendJSON(ctx.res, 200, out); return true; } + case "POST auth/bind": { + const body = await readJSON<{ code?: string }>(ctx.req); + const code = body.code?.trim().toUpperCase(); + if (!code) { + sendError(ctx.res, 400, "bad_request", "code is required"); + return true; + } + if (code !== MOCK_LINK_CODE) { + sendError(ctx.res, 400, "invalid_code", "bind code is invalid or expired"); + return true; + } + setSessionCookie(ctx.res, "linked"); + sendJSON(ctx.res, 200, { + user_id: "mock-linked", + linked: true, + mc_uuid: MC_UUID, + auth_source: "mojang", + }); + return true; + } case "POST auth/logout": clearSessionCookie(ctx.res); sendJSON(ctx.res, 200, { ok: true }); diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index 38a688b..ec22f8b 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -5,6 +5,13 @@ "password": "Password", "sign_in": "Sign in", "signing_in": "Signing in…", + "tab_password": "Password", + "tab_bind": "Bind Code", + "bind_code": "Bind Code", + "bind_code_placeholder": "e.g., ABCD2345", + "bind_hint": "Type /login in-game to generate a one-time bind code.", + "bind_btn": "Verify & Sign In", + "binding": "Verifying…", "change_password_title": "Set a new password", "change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.", "change_password_subtitle_voluntary": "Update your console password.", diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index b8912bd..0e58ef7 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -5,6 +5,13 @@ "password": "密码", "sign_in": "登录", "signing_in": "登录中…", + "tab_password": "账号密码", + "tab_bind": "游戏绑定码", + "bind_code": "绑定码", + "bind_code_placeholder": "例如:ABCD2345", + "bind_hint": "在游戏内输入 /login 即可获取一次性绑定码", + "bind_btn": "验证并登录", + "binding": "验证中…", "change_password_title": "设置新密码", "change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。", "change_password_subtitle_voluntary": "修改控制台登录密码。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 703d06e..1014a36 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -124,6 +124,32 @@ describe("local-password auth wire shapes", () => { expect((opts as RequestInit).method).toBe("POST"); }); + it("bind POSTs {code} to /auth/bind", async () => { + const fetchSpy = fakeFetch({ + user_id: "mock-linked", + linked: true, + mc_uuid: "uuid-123", + auth_source: "mojang", + }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.bind("ABCD2345"); + expect(res.user_id).toBe("mock-linked"); + expect(res.linked).toBe(true); + expect(res.mc_uuid).toBe("uuid-123"); + expect(res.auth_source).toBe("mojang"); + + const [url, opts] = (fetchSpy as unknown as ReturnType).mock + .calls[0]; + expect(String(url)).toBe("/auth/bind"); + expect((opts as RequestInit).method).toBe("POST"); + expect((opts as RequestInit).headers).toEqual({ + "Content-Type": "application/json", + }); + expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ + code: "ABCD2345", + }); + }); + it("changePassword POSTs {current_password, new_password}", async () => { const fetchSpy = fakeFetch({ ok: true }); vi.stubGlobal("fetch", fetchSpy); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 17ddcd4..88a9684 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -12,6 +12,7 @@ import type { LinkResult, LinkStatus, LoginResult, + BindResult, PlayersResult, ServerInfo, WhitelistImage, @@ -99,6 +100,9 @@ export const api = { // the tier model reads as `unauthenticated` and routes back to /login. logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), + bind: (code: string) => + request("POST", "/auth/bind", { code }), + // changePassword is callable during the first-login lockdown (the route is // AllowDuringPasswordChange): the server re-verifies current_password, rejects an // unchanged or weak (8–72 byte) new password, writes the new hash, and revokes diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 64306be..0439e12 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -228,6 +228,13 @@ export interface LoginResult { must_change_password: boolean; } +export interface BindResult { + user_id: string; + linked: boolean; + mc_uuid: string; + auth_source: string; +} + export type BuildStatus = "pending" | "building" | "succeeded" | "failed" | "cancelled"; /** Build mirrors an image_builds row (spec §6, §16). */ diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index 7adfa0c..c4285ae 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from "react"; import { Navigate, useNavigate } from "react-router-dom"; -import { Loader2 } from "lucide-react"; +import { Loader2, KeyRound } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AuthLayout } from "@/components/AuthLayout"; import { Card, CardContent } from "@/components/ui/card"; @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTier } from "@/lib/tier"; import { api, humanizeError } from "@/lib/api"; +import { cn } from "@/lib/utils"; // Login is the local-password sign-in (spec §B1). It is the ONLY local credential // surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success @@ -23,8 +24,10 @@ export function Login() { const navigate = useNavigate(); const { t } = useTranslation("auth"); + const [activeTab, setActiveTab] = useState<"password" | "bind">("password"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [bindCode, setBindCode] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -43,7 +46,7 @@ export function Login() { if (identity && mustChangePassword) return ; if (identity) return ; - async function submit(e: FormEvent) { + async function handlePasswordSubmit(e: FormEvent) { e.preventDefault(); if (!username.trim() || !password || submitting) return; setSubmitting(true); @@ -60,45 +63,136 @@ export function Login() { } } + async function handleBindSubmit(e: FormEvent) { + e.preventDefault(); + const code = bindCode.trim(); + if (!code || submitting) return; + setSubmitting(true); + setError(null); + try { + await api.bind(code); + await refresh(); + navigate("/", { replace: true }); + } catch (err) { + setError(humanizeError(err)); + setSubmitting(false); + } + } + return ( -
-
- - setUsername(e.target.value)} - autoComplete="username" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - autoFocus - aria-invalid={error ? true : undefined} - /> -
-
- - setPassword(e.target.value)} - autoComplete="current-password" - aria-invalid={error ? true : undefined} - /> -
- {error &&

{error}

} - -
+ {t("tab_password")} + + + + + {activeTab === "password" ? ( +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + aria-invalid={error ? true : undefined} + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + aria-invalid={error ? true : undefined} + /> +
+ {error &&

{error}

} + +
+ ) : ( +
+
+ + setBindCode(e.target.value)} + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + autoFocus + disabled={submitting} + aria-invalid={error ? true : undefined} + /> +

+ {t("bind_hint")} +

+
+ {error &&

{error}

} + +
+ )}