feat(auth): support public auth bind endpoint

This commit is contained in:
Lemon-miaow committed 2026-07-03 02:11:57 +08:00
1 parent 5427bc7623
commit 55592ed123
7 files changed
+168 -3

No files matched your search

+20
View File
@@ -599,6 +599,26 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendJSON(ctx.res, 200, out); sendJSON(ctx.res, 200, out);
return true; 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": case "POST auth/logout":
clearSessionCookie(ctx.res); clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true }); sendJSON(ctx.res, 200, { ok: true });
+7
View File
@@ -5,6 +5,13 @@
"password": "Password", "password": "Password",
"sign_in": "Sign in", "sign_in": "Sign in",
"signing_in": "Signing 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_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_forced": "Your account was issued a one-time password. Choose a new one to continue.",
"change_password_subtitle_voluntary": "Update your console password.", "change_password_subtitle_voluntary": "Update your console password.",
+7
View File
@@ -5,6 +5,13 @@
"password": "密码", "password": "密码",
"sign_in": "登录", "sign_in": "登录",
"signing_in": "登录中…", "signing_in": "登录中…",
"tab_password": "账号密码",
"tab_bind": "游戏绑定码",
"bind_code": "绑定码",
"bind_code_placeholder": "例如:ABCD2345",
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
"bind_btn": "验证并登录",
"binding": "验证中…",
"change_password_title": "设置新密码", "change_password_title": "设置新密码",
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。", "change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
"change_password_subtitle_voluntary": "修改控制台登录密码。", "change_password_subtitle_voluntary": "修改控制台登录密码。",
+26
View File
@@ -124,6 +124,32 @@ describe("local-password auth wire shapes", () => {
expect((opts as RequestInit).method).toBe("POST"); 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<typeof vi.fn>).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 () => { it("changePassword POSTs {current_password, new_password}", async () => {
const fetchSpy = fakeFetch({ ok: true }); const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy); vi.stubGlobal("fetch", fetchSpy);
+4
View File
@@ -12,6 +12,7 @@ import type {
LinkResult, LinkResult,
LinkStatus, LinkStatus,
LoginResult, LoginResult,
BindResult,
PlayersResult, PlayersResult,
ServerInfo, ServerInfo,
WhitelistImage, WhitelistImage,
@@ -99,6 +100,9 @@ export const api = {
// the tier model reads as `unauthenticated` and routes back to /login. // the tier model reads as `unauthenticated` and routes back to /login.
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
bind: (code: string) =>
request<BindResult>("POST", "/auth/bind", { code }),
// changePassword is callable during the first-login lockdown (the route is // changePassword is callable during the first-login lockdown (the route is
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an // AllowDuringPasswordChange): the server re-verifies current_password, rejects an
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes // unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
+7
View File
@@ -228,6 +228,13 @@ export interface LoginResult {
must_change_password: boolean; 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"; export type BuildStatus = "pending" | "building" | "succeeded" | "failed" | "cancelled";
/** Build mirrors an image_builds row (spec §6, §16). */ /** Build mirrors an image_builds row (spec §6, §16). */
+97 -3
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api"; 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 // 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 // surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
@@ -23,8 +24,10 @@ export function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const [activeTab, setActiveTab] = useState<"password" | "bind">("password");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [bindCode, setBindCode] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -43,7 +46,7 @@ export function Login() {
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />; if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
if (identity) return <Navigate to="/" replace />; if (identity) return <Navigate to="/" replace />;
async function submit(e: FormEvent) { async function handlePasswordSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!username.trim() || !password || submitting) return; if (!username.trim() || !password || submitting) return;
setSubmitting(true); setSubmitting(true);
@@ -60,11 +63,62 @@ 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 ( return (
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}> <AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> {/* Tab Selector */}
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1 text-muted-foreground select-none mb-4">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
activeTab === "password"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("tab_password")}
</button>
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("bind");
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
activeTab === "bind"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("tab_bind")}
</button>
</div>
{activeTab === "password" ? (
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label> <Label htmlFor="username">{t("username")}</Label>
<Input <Input
@@ -99,6 +153,46 @@ export function Login() {
{submitting ? t("signing_in") : t("sign_in")} {submitting ? t("signing_in") : t("sign_in")}
</Button> </Button>
</form> </form>
) : (
<form onSubmit={handleBindSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bindCode">{t("bind_code")}</Label>
<Input
id="bindCode"
placeholder={t("bind_code_placeholder")}
value={bindCode}
onChange={(e) => setBindCode(e.target.value)}
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("bind_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !bindCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("binding")}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
{t("bind_btn")}
</>
)}
</Button>
</form>
)}
</CardContent> </CardContent>
</Card> </Card>
</AuthLayout> </AuthLayout>