feat(auth): support public auth bind endpoint
This commit is contained in:
7 files changed
+201
-36
No files matched your search
@@ -599,6 +599,26 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
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 });
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "修改控制台登录密码。",
|
||||
|
||||
@@ -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<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 () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
@@ -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<BindResult>("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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+130
-36
@@ -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<string | null>(null);
|
||||
|
||||
@@ -43,7 +46,7 @@ export function Login() {
|
||||
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
|
||||
if (identity) return <Navigate to="/" replace />;
|
||||
|
||||
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 (
|
||||
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={submitting || !username.trim() || !password}
|
||||
{/* 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",
|
||||
)}
|
||||
>
|
||||
{submitting ? t("signing_in") : t("sign_in")}
|
||||
</Button>
|
||||
</form>
|
||||
{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">
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={submitting || !username.trim() || !password}
|
||||
>
|
||||
{submitting ? t("signing_in") : t("sign_in")}
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
|
||||
Reference in new issue
Block a user