feat(auth): support public auth bind endpoint
This commit is contained in:
7 files changed
+168
-3
No files matched your search
@@ -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 });
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "修改控制台登录密码。",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user