fix(panel): passkey 无凭据与用户不存在改用错误码的本地化文案,补齐通用错误码映射,认证来源与日期跟随界面语言

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:44:47 +08:00
1 parent 6c3421fe8c
commit 9a5225f77e
13 files changed
+249 -26

No files matched your search

+12 -5
View File
@@ -71,17 +71,24 @@ export function NotAuthorized() {
);
}
export function NotFound() {
export interface NotFoundProps {
title?: string;
body?: string;
linkTo?: string;
linkLabel?: string;
}
export function NotFound({ title, body, linkTo = "/", linkLabel }: NotFoundProps) {
const { t } = useTranslation("common");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<SearchX className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{t("not_found_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("not_found_body")}</p>
<p className="font-medium">{title ?? t("not_found_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{body ?? t("not_found_body")}</p>
</div>
<Link to="/" className="text-sm font-medium text-primary hover:underline">
{t("back_to_dashboard")}
<Link to={linkTo} className="text-sm font-medium text-primary hover:underline">
{linkLabel ?? t("back_to_dashboard")}
</Link>
</div>
);
@@ -155,6 +155,8 @@
"users_col_status": "Status",
"users_col_created": "Created",
"users_view_detail": "Details",
"user_not_found_title": "No such user",
"user_not_found_body": "This account may have been deleted. Find it again from the user list.",
"users_empty_title": "No Users Found",
"users_empty_hint": "No users match the current filters.",
"users_back_to_list": "Back to user list",
@@ -168,6 +170,8 @@
"users_no_linked": "No Minecraft accounts linked yet.",
"users_link_add": "Link Account",
"users_link_source": "Auth Source",
"users_link_source_mojang": "Mojang",
"users_link_source_thirdparty": "Third-party Yggdrasil",
"users_link_confirm": "Link",
"users_unlink_tooltip": "Unlink this Minecraft account",
"users_unlink_dlg_title": "Unlink Minecraft Account",
+8 -1
View File
@@ -84,5 +84,12 @@
"network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
"upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
"bad_path_param": "The name in this link is not valid. Open it again from the list.",
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent."
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent.",
"passkey_no_credential": "The browser returned no passkey. Try again.",
"not_found": "That no longer exists. It may have been deleted; refresh and try again.",
"conflict": "Something changed in the meantime. Refresh and try again.",
"bad_request": "The request was not accepted: {{detail}}",
"restore_in_progress": "Another backup is still being restored to this server's world. Try again once it finishes.",
"uploads_full": "The upload store is full. An admin has to delete reviewed submissions before new uploads fit.",
"unsupported_media_type": "The request was sent in a format the server does not accept. Reload the page and try again."
}
@@ -155,6 +155,8 @@
"users_col_status": "状态",
"users_col_created": "创建时间",
"users_view_detail": "详情",
"user_not_found_title": "用户不存在",
"user_not_found_body": "这个账户可能已被删除,请回到用户列表重新查找。",
"users_empty_title": "未找到用户",
"users_empty_hint": "没有匹配当前筛选条件的用户。",
"users_back_to_list": "返回用户列表",
@@ -168,6 +170,8 @@
"users_no_linked": "尚未关联任何 Minecraft 账号。",
"users_link_add": "关联账号",
"users_link_source": "验证源",
"users_link_source_mojang": "Mojang",
"users_link_source_thirdparty": "第三方 Yggdrasil",
"users_link_confirm": "关联",
"users_unlink_tooltip": "解除此 Minecraft 账号关联",
"users_unlink_dlg_title": "解除 Minecraft 关联",
+8 -1
View File
@@ -84,5 +84,12 @@
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
"bad_path_param": "链接里的名称无效,请从列表重新打开。",
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。"
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。",
"passkey_no_credential": "浏览器没有返回 Passkey,请重试。",
"not_found": "要找的内容已不存在,可能已被删除,请刷新后重试。",
"conflict": "期间状态已发生变化,请刷新后重试。",
"bad_request": "请求未被接受:{{detail}}",
"restore_in_progress": "这台服务器的世界正在恢复另一份备份,请等它完成后再试。",
"uploads_full": "上传存储已满,需要管理员删除已审核的提交后才能继续上传。",
"unsupported_media_type": "请求格式不被服务器接受,请刷新页面后重试。"
}
+33 -1
View File
@@ -12,7 +12,7 @@ vi.mock("./config", () => ({
}));
// Imported after the mock so api.ts picks up the mocked loadConfig.
const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost } =
const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } =
await import("./api");
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
@@ -923,3 +923,35 @@ describe("session and connection signals", () => {
expect(isConnectionLost()).toBe(false);
});
});
// The API's generic codes carry an English developer message ("user not found",
// "invalid request"); the panel words them itself so a Chinese UI never shows it.
describe("copy for the generic server codes", () => {
it("names a missing record, a lost race and a refused format in the UI's words", () => {
expect(humanizeError({ status: 404, code: "not_found", message: "user not found" })).toMatch(/no longer exists/);
expect(humanizeError({ status: 409, code: "conflict", message: "conflict" })).toMatch(/changed in the meantime/);
expect(humanizeError({ status: 409, code: "restore_in_progress", message: "restore running" })).toMatch(
/still being restored/,
);
expect(humanizeError({ status: 415, code: "unsupported_media_type", message: "json only" })).toMatch(
/format the server does not accept/,
);
});
it("keeps the server's reason for a bad request, and never shows an empty one", () => {
expect(humanizeError({ status: 400, code: "bad_request", message: "mc_uuid is required" })).toBe(
"The request was not accepted: mc_uuid is required",
);
expect(humanizeError({ status: 400, code: "bad_request", message: "" })).toBe("Something went wrong.");
});
it("reads a full upload store as full, not as an outage", () => {
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
});
it("words a failure the panel caught itself from its code", () => {
const err = clientError("passkey_no_credential");
expect(err.status).toBe(0);
expect(humanizeError(err)).toBe("The browser returned no passkey. Try again.");
});
});
+24
View File
@@ -753,6 +753,12 @@ export function buildLogsStreamURL(apiBase: string, id: string): string {
return `${apiBase}/images/build/${encodeURIComponent(id)}/logs`;
}
/** clientError is a failure the panel itself detects (no request was made),
* shaped like a server error so humanizeError words it from the code. */
export function clientError(code: string): ApiError {
return { status: 0, code, message: "" };
}
/** humanizeError turns the stable error code into a user-facing line. */
export function humanizeError(e: unknown): string {
const t = i18next.getFixedT(null, "errors");
@@ -966,6 +972,24 @@ export function humanizeError(e: unknown): string {
return t("upstream_unavailable");
case "bad_path_param":
return t("bad_path_param");
case "passkey_no_credential":
return t("passkey_no_credential");
case "not_found":
return t("not_found");
case "conflict":
return t("conflict");
case "restore_in_progress":
return t("restore_in_progress");
// 507: named on its own so the 5xx fallback below does not call it an
// outage.
case "uploads_full":
return t("uploads_full");
case "unsupported_media_type":
return t("unsupported_media_type");
// The detail says which field was wrong; the server writes it in English,
// so it rides inside a localized sentence.
case "bad_request":
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+3 -3
View File
@@ -9,7 +9,7 @@ import { Loading, ErrorState } from "@/components/States";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine, InlineError } from "@/components/MessageLine";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { api, clientError, humanizeError } from "@/lib/api";
import { formatAbsolute } from "@/lib/format";
import type { PasskeyCredential } from "@/lib/types";
import { useAsync } from "@/lib/hooks";
@@ -150,7 +150,7 @@ export function Account() {
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to create credential");
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAttestationResponse;
@@ -673,7 +673,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
if (!credential) throw new Error("Failed to get credential");
if (!credential) throw clientError("passkey_no_credential");
const response = credential.response as AuthenticatorAssertionResponse;
await api.migrateConfirmPasskeyFinish({
id: credential.id,
+34 -1
View File
@@ -9,6 +9,11 @@ import { Login } from "./Login";
const calls = vi.hoisted(() => ({
authEmailStart: vi.fn(),
authEmailVerify: vi.fn(),
authPasskeyDiscoverableBegin: vi.fn(),
authPasskeyDiscoverableFinish: vi.fn(),
authPasskeyLoginBegin: vi.fn(),
authPasskeyLoginFinish: vi.fn(),
credentialsGet: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
@@ -22,7 +27,15 @@ vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: { ...actual.api, authEmailStart: calls.authEmailStart, authEmailVerify: calls.authEmailVerify },
api: {
...actual.api,
authEmailStart: calls.authEmailStart,
authEmailVerify: calls.authEmailVerify,
authPasskeyDiscoverableBegin: calls.authPasskeyDiscoverableBegin,
authPasskeyDiscoverableFinish: calls.authPasskeyDiscoverableFinish,
authPasskeyLoginBegin: calls.authPasskeyLoginBegin,
authPasskeyLoginFinish: calls.authPasskeyLoginFinish,
},
};
});
@@ -38,6 +51,9 @@ function renderLogin() {
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
// jsdom has no WebAuthn; the browser handing back nothing is what a
// dismissed or empty authenticator looks like to the page.
Object.defineProperty(navigator, "credentials", { value: { get: calls.credentialsGet }, configurable: true });
});
describe("Login", () => {
@@ -66,4 +82,21 @@ describe("Login", () => {
await userEvent.click(screen.getByRole("button", { name: t("auth:otp_btn") }));
expect(screen.queryByRole("alert")).toBeNull();
});
it("words an empty passkey answer in the UI language, with and without an email", async () => {
calls.credentialsGet.mockResolvedValue(null);
calls.authPasskeyDiscoverableBegin.mockResolvedValue({ login_id: "l1", publicKey: { challenge: "AAAA" } });
calls.authPasskeyLoginBegin.mockResolvedValue({ challenge: "AAAA" });
renderLogin();
await userEvent.click(screen.getByRole("button", { name: t("auth:passkey_btn") }));
expect((await screen.findByRole("alert")).textContent).toBe("The browser returned no passkey. Try again.");
expect(calls.authPasskeyDiscoverableFinish).not.toHaveBeenCalled();
await userEvent.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
await userEvent.click(screen.getByRole("button", { name: t("auth:passkey_btn") }));
await vi.waitFor(() => expect(calls.authPasskeyLoginBegin).toHaveBeenCalledWith("[email protected]"));
expect((await screen.findByRole("alert")).textContent).toBe("The browser returned no passkey. Try again.");
expect(calls.authPasskeyLoginFinish).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { loginReturnPath } from "@/lib/auth";
import { api, humanizeError } from "@/lib/api";
import { api, clientError, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine";
@@ -170,7 +170,7 @@ export function Login() {
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to get credential");
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
@@ -208,7 +208,7 @@ export function Login() {
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to get credential");
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
+2 -2
View File
@@ -7,7 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api, humanizeError, type SetupState } from "@/lib/api";
import { api, clientError, humanizeError, type SetupState } from "@/lib/api";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { InlineError } from "@/components/MessageLine";
@@ -261,7 +261,7 @@ function PasskeyStep({
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to create credential");
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAttestationResponse;
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { UserDetailPage } from "./UserDetailPage";
import type { UserDetail } from "@/lib/types";
const calls = vi.hoisted(() => ({
getUser: vi.fn(),
getUserQuotas: vi.fn(),
listUserSessions: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }),
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const VERIFIED = "2026-03-04T05:06:07Z";
const EXPIRES = "2026-11-12T13:14:15Z";
const USER: UserDetail = {
id: "u-1",
username: "steve",
email: "[email protected]",
role: "user",
disabled: false,
email_verified: true,
server_count: 0,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
linked_accounts: [{ mc_uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5", auth_source: "thirdparty", verified_at: VERIFIED }],
};
function renderPage() {
return render(
<MemoryRouter initialEntries={["/admin/users/u-1"]}>
<Routes>
<Route path="/admin/users/:id" element={<UserDetailPage />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
calls.getUserQuotas.mockResolvedValue({ user_id: "u-1" });
calls.listUserSessions.mockResolvedValue([]);
});
afterEach(() => i18next.changeLanguage("en-US"));
describe("UserDetailPage", () => {
it("says the user is gone and leads back to the list, without the server's English", async () => {
calls.getUser.mockRejectedValue({ status: 404, code: "not_found", message: "user not found" });
await i18next.changeLanguage("zh-CN");
renderPage();
expect(await screen.findByText("用户不存在")).toBeTruthy();
expect(screen.getByRole("link", { name: "返回用户列表" }).getAttribute("href")).toBe("/admin/users");
expect(screen.queryByRole("button", { name: i18next.t("common:try_again") })).toBeNull();
expect(document.body.textContent).not.toMatch(/user not found/i);
});
it("keeps the retry for a failure that is not a missing user", async () => {
calls.getUser.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
renderPage();
expect((await screen.findByRole("alert")).textContent).toBe("backend refused");
expect(screen.getByRole("button", { name: i18next.t("common:try_again") })).toBeTruthy();
expect(screen.queryByText(i18next.t("admin:user_not_found_title"))).toBeNull();
});
it("names the auth source and writes dates in the UI language", async () => {
calls.getUser.mockResolvedValue(USER);
calls.listUserSessions.mockResolvedValue([
{ token_hash: "abcdef0123456789abcdef0123456789", created_at: VERIFIED, expires_at: EXPIRES },
]);
await i18next.changeLanguage("zh-CN");
renderPage();
const zhVerified = new Date(VERIFIED).toLocaleString("zh-CN");
const zhExpires = new Date(EXPIRES).toLocaleString("zh-CN");
expect(zhVerified).not.toBe(new Date(VERIFIED).toLocaleString("en-US"));
expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy();
expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy();
});
});
+23 -9
View File
@@ -42,14 +42,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loading, ErrorState } from "@/components/States";
import { Loading, ErrorState, NotFound } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { formatAbsolute } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { UserDetail, SessionView } from "@/lib/types";
import type { ApiError, UserDetail, SessionView } from "@/lib/types";
export function UserDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -64,8 +64,18 @@ export function UserDetailPage() {
);
if (loading && !user) return <Loading />;
if (error && (error as Partial<ApiError>).status === 404) {
return (
<NotFound
title={t("user_not_found_title")}
body={t("user_not_found_body")}
linkTo="/admin/users"
linkLabel={t("users_back_to_list")}
/>
);
}
if (error) return <ErrorState error={error} onRetry={reload} />;
if (!user) return <ErrorState error={new Error("user not found")} />;
if (!user) return <Loading />;
return (
<div className="space-y-6">
@@ -226,7 +236,11 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
}
function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged: () => void }) {
const { t } = useTranslation("admin");
const { t, i18n } = useTranslation("admin");
// The two sources the link form offers are named; anything else the server
// reports is shown as is.
const sourceLabel = (source: string) =>
source === "mojang" || source === "thirdparty" ? t(`users_link_source_${source}`) : source;
const accounts = user.linked_accounts ?? [];
const [unlinking, setUnlinking] = useState<string | null>(null);
const [unlinkDlg, setUnlinkDlg] = useState<string | null>(null);
@@ -308,8 +322,8 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mojang">Mojang</SelectItem>
<SelectItem value="thirdparty">Third-party Yggdrasil</SelectItem>
<SelectItem value="mojang">{t("users_link_source_mojang")}</SelectItem>
<SelectItem value="thirdparty">{t("users_link_source_thirdparty")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -331,7 +345,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
<div className="min-w-0 flex-1">
<span className="font-mono text-xs truncate block">{acc.mc_uuid}</span>
<div className="mt-0.5 text-xs text-muted-foreground">
{acc.auth_source} &middot; {formatAbsolute(acc.verified_at, "en-US")}
{sourceLabel(acc.auth_source)} &middot; {formatAbsolute(acc.verified_at, i18n.language)}
</div>
</div>
<Button
@@ -493,7 +507,7 @@ function QuotasCard({ userId }: { userId: string }) {
}
function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => void }) {
const { t } = useTranslation("admin");
const { t, i18n } = useTranslation("admin");
const { data: sessions, error, loading, reload } = useAsync(
() => api.listUserSessions(userId),
[userId],
@@ -587,7 +601,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
</span>
<div className="mt-0.5 text-muted-foreground/70">
<Clock className="inline h-3 w-3 mr-0.5" />
{t("users_session_expires")}: {formatAbsolute(s.expires_at, "en-US")}
{t("users_session_expires")}: {formatAbsolute(s.expires_at, i18n.language)}
</div>
</div>
<Button