From 9a5225f77e1bea95cfb90353b0d7b2c6e81fc064 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 11:44:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20passkey=20=E6=97=A0=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E7=94=A8=E6=88=B7=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=94=99=E8=AF=AF=E7=A0=81=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8C=96=E6=96=87=E6=A1=88=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E9=94=99=E8=AF=AF=E7=A0=81=E6=98=A0=E5=B0=84?= =?UTF-8?q?=EF=BC=8C=E8=AE=A4=E8=AF=81=E6=9D=A5=E6=BA=90=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E8=B7=9F=E9=9A=8F=E7=95=8C=E9=9D=A2=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/components/States.tsx | 17 +++- panel/src/i18n/resources/en-US/admin.json | 4 + panel/src/i18n/resources/en-US/errors.json | 9 +- panel/src/i18n/resources/zh-CN/admin.json | 4 + panel/src/i18n/resources/zh-CN/errors.json | 9 +- panel/src/lib/api.test.ts | 34 ++++++- panel/src/lib/api.ts | 24 +++++ panel/src/pages/Account.tsx | 6 +- panel/src/pages/Login.test.tsx | 35 ++++++- panel/src/pages/Login.tsx | 6 +- panel/src/pages/Setup.tsx | 4 +- panel/src/pages/admin/UserDetailPage.test.tsx | 91 +++++++++++++++++++ panel/src/pages/admin/UserDetailPage.tsx | 32 +++++-- 13 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 panel/src/pages/admin/UserDetailPage.test.tsx diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx index b850d71..41fd7f9 100644 --- a/panel/src/components/States.tsx +++ b/panel/src/components/States.tsx @@ -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 (
-

{t("not_found_title")}

-

{t("not_found_body")}

+

{title ?? t("not_found_title")}

+

{body ?? t("not_found_body")}

- - {t("back_to_dashboard")} + + {linkLabel ?? t("back_to_dashboard")}
); diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 128a1d6..8ea42a1 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -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", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index ac12111..d70f11a 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -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." } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index c0bf707..dd880b3 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -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 关联", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index bad7e32..376327b 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -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": "请求格式不被服务器接受,请刷新页面后重试。" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 4b2ee64..cdf2137 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -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."); + }); +}); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index b82a072..b7d8d69 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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"); diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index 299a33f..86159b8 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -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, diff --git a/panel/src/pages/Login.test.tsx b/panel/src/pages/Login.test.tsx index 9019c89..4d0fe25 100644 --- a/panel/src/pages/Login.test.tsx +++ b/panel/src/pages/Login.test.tsx @@ -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(); 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")), "a@b.c"); + await userEvent.click(screen.getByRole("button", { name: t("auth:passkey_btn") })); + await vi.waitFor(() => expect(calls.authPasskeyLoginBegin).toHaveBeenCalledWith("a@b.c")); + expect((await screen.findByRole("alert")).textContent).toBe("The browser returned no passkey. Try again."); + expect(calls.authPasskeyLoginFinish).not.toHaveBeenCalled(); + }); }); diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index edb6ec5..718c1e8 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -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; diff --git a/panel/src/pages/Setup.tsx b/panel/src/pages/Setup.tsx index 145db12..901920c 100644 --- a/panel/src/pages/Setup.tsx +++ b/panel/src/pages/Setup.tsx @@ -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; diff --git a/panel/src/pages/admin/UserDetailPage.test.tsx b/panel/src/pages/admin/UserDetailPage.test.tsx new file mode 100644 index 0000000..64000c7 --- /dev/null +++ b/panel/src/pages/admin/UserDetailPage.test.tsx @@ -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(); + 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: "steve@example.test", + 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( + + + } /> + + , + ); +} + +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(); + }); +}); diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index 9d31c30..7e7ca1a 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -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 ; + if (error && (error as Partial).status === 404) { + return ( + + ); + } if (error) return ; - if (!user) return ; + if (!user) return ; return (
@@ -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(null); const [unlinkDlg, setUnlinkDlg] = useState(null); @@ -308,8 +322,8 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged: - Mojang - Third-party Yggdrasil + {t("users_link_source_mojang")} + {t("users_link_source_thirdparty")}
@@ -331,7 +345,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
{acc.mc_uuid}
- {acc.auth_source} · {formatAbsolute(acc.verified_at, "en-US")} + {sourceLabel(acc.auth_source)} · {formatAbsolute(acc.verified_at, i18n.language)}