fix(panel): passkey 无凭据与用户不存在改用错误码的本地化文案,补齐通用错误码映射,认证来源与日期跟随界面语言
This commit is contained in:
13 files changed
+249
-26
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 关联",
|
||||
|
||||
@@ -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": "请求格式不被服务器接受,请刷新页面后重试。"
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} · {formatAbsolute(acc.verified_at, "en-US")}
|
||||
{sourceLabel(acc.auth_source)} · {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
|
||||
|
||||
Reference in new issue
Block a user