fix(panel): 错误提示改为读屏即时播报,图标按钮、筛选下拉与表单字段补齐可读名称,上传区可用键盘选择文件,并加全页面无障碍巡检
This commit is contained in:
37 files changed
+375
-79
No files matched your search
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect, t } from "./fixtures";
|
||||||
|
|
||||||
|
// Every control a screen reader can land on must say what it does. The sweep
|
||||||
|
// reads each page's accessibility tree (what assistive tech gets) and fails on
|
||||||
|
// a button, link, field or toggle whose accessible name is empty, which is
|
||||||
|
// what an icon-only button without aria-label turns into.
|
||||||
|
const CONTROL_ROLES = ["button", "link", "combobox", "checkbox", "switch", "textbox", "menuitem", "tab", "slider"];
|
||||||
|
// A named control reads `- button "Save"`; an unnamed one has nothing quoted
|
||||||
|
// after the role, only [state] flags and maybe its value (`- combobox: Presets`).
|
||||||
|
const UNNAMED = new RegExp(`^\\s*- (${CONTROL_ROLES.join("|")})(?: \\[[^\\]]*\\])*(?::.*)?$`);
|
||||||
|
|
||||||
|
async function unnamedControls(page: Page): Promise<string[]> {
|
||||||
|
const tree = await page.locator("body").ariaSnapshot();
|
||||||
|
const lines = tree.split("\n");
|
||||||
|
// Each offender is reported with the lines under it, so the failure shows
|
||||||
|
// which icon or field it was.
|
||||||
|
return lines.flatMap((line, i) => (UNNAMED.test(line) ? [lines.slice(i, i + 3).join("\n")] : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [account, path] of [
|
||||||
|
["linked", "/"],
|
||||||
|
["linked", "/servers"],
|
||||||
|
["linked", "/servers/lobby"],
|
||||||
|
["linked", "/servers/lobby/players"],
|
||||||
|
["linked", "/servers/lobby/backups"],
|
||||||
|
["linked", "/servers/lobby/files"],
|
||||||
|
["linked", "/servers/lobby/luckperms"],
|
||||||
|
["linked", "/submissions"],
|
||||||
|
["linked", "/account"],
|
||||||
|
["owner", "/"],
|
||||||
|
["owner", "/servers"],
|
||||||
|
["owner", "/admin/images"],
|
||||||
|
["owner", "/admin/builds"],
|
||||||
|
["owner", "/admin/submissions"],
|
||||||
|
["owner", "/admin/updates"],
|
||||||
|
["owner", "/admin/users"],
|
||||||
|
["owner", "/account"],
|
||||||
|
] as const) {
|
||||||
|
test(`every control on ${path} has a name for ${account}`, async ({ page, signIn }) => {
|
||||||
|
await signIn(account);
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
expect(await unnamedControls(page)).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialogs only exist once opened, so each is opened and swept on its own.
|
||||||
|
for (const [account, path, opener] of [
|
||||||
|
["owner", "/servers", "servers:create_server_btn"],
|
||||||
|
["owner", "/servers/lobby", "servers:edit_server_title"],
|
||||||
|
["owner", "/admin/users", "admin:users_create_btn"],
|
||||||
|
["owner", "/admin/images", "admin:add_image_btn"],
|
||||||
|
["owner", "/admin/builds", "admin:trigger_build_title"],
|
||||||
|
["linked", "/submissions", "submissions:submit_card_title"],
|
||||||
|
] as const) {
|
||||||
|
test(`every control in the ${opener} dialog has a name`, async ({ page, signIn }) => {
|
||||||
|
await signIn(account);
|
||||||
|
await page.goto(path);
|
||||||
|
await page.getByRole("button", { name: t(opener) }).first().click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
expect(await unnamedControls(page)).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every control on a user's detail page has a name", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/users");
|
||||||
|
await page.getByRole("link", { name: "linked", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "linked", exact: true })).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
expect(await unnamedControls(page)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every control on a player's LuckPerms groups and nodes has a name", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/servers/survival/luckperms");
|
||||||
|
await page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") }).fill("Steve");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("cell", { name: "essentials.fly", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
expect(await unnamedControls(page)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every control on the sign-in page has a name", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
expect(await unnamedControls(page)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the submission's file picker is reachable from the keyboard", async ({ page, signIn }) => {
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/submissions");
|
||||||
|
await page.getByRole("button", { name: t("submissions:submit_card_title") }).first().click();
|
||||||
|
|
||||||
|
const picker = page.getByLabel(t("submissions:file_label"));
|
||||||
|
await page.getByLabel(t("submissions:display_name_label")).focus();
|
||||||
|
for (let i = 0; i < 10 && !(await picker.evaluate((el) => el === document.activeElement)); i++) {
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
}
|
||||||
|
await expect(picker).toBeFocused();
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
|||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
|
{ name: "desktop", testMatch: /(smoke|a11y)\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
|
||||||
{
|
{
|
||||||
name: "mobile",
|
name: "mobile",
|
||||||
testMatch: /mobile\.spec\.ts/,
|
testMatch: /mobile\.spec\.ts/,
|
||||||
|
|||||||
@@ -117,11 +117,15 @@ function UserStrip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LangToggle() {
|
function LangToggle() {
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation("common");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
|
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
|
||||||
<SelectPrimitive.Trigger className={FOOT_ICON_BTN}>
|
<SelectPrimitive.Trigger
|
||||||
|
className={FOOT_ICON_BTN}
|
||||||
|
aria-label={t("change_language")}
|
||||||
|
title={t("change_language")}
|
||||||
|
>
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
<SelectContent align="start" className="min-w-[6rem]">
|
<SelectContent align="start" className="min-w-[6rem]">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { api, humanizeError } from "@/lib/api";
|
|||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
||||||
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
||||||
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
|
// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
|
||||||
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
|
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
|
||||||
@@ -154,9 +155,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_image")}</Label>
|
<Label htmlFor="create-server-image">{t("create_server_image")}</Label>
|
||||||
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="create-server-image">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
images.loading
|
images.loading
|
||||||
@@ -179,9 +180,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_memory")}</Label>
|
<Label htmlFor="create-server-memory">{t("create_server_memory")}</Label>
|
||||||
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="create-server-memory">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -194,9 +195,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_storage")}</Label>
|
<Label htmlFor="create-server-storage">{t("create_server_storage")}</Label>
|
||||||
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
|
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="create-server-storage">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -211,12 +212,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_policy")}</Label>
|
<Label htmlFor="create-server-policy">{t("create_server_policy")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.autostartPolicy}
|
value={form.autostartPolicy}
|
||||||
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="create-server-policy">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmFooter
|
<ConfirmFooter
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label htmlFor="create-user-username" className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("users_field_username")} *
|
{t("users_field_username")} *
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-user-username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder={t("users_create_username_placeholder")}
|
placeholder={t("users_create_username_placeholder")}
|
||||||
@@ -109,10 +110,11 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label htmlFor="create-user-email" className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("users_field_email")}
|
{t("users_field_email")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-user-email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -123,11 +125,11 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label htmlFor="create-user-role" className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("users_field_role")}
|
{t("users_field_role")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger id="create-user-role" className="h-9 text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { api, humanizeError } from "@/lib/api";
|
|||||||
import { splitImageRef } from "@/lib/format";
|
import { splitImageRef } from "@/lib/format";
|
||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import type { AutostartPolicy } from "@/lib/types";
|
import type { AutostartPolicy } from "@/lib/types";
|
||||||
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
||||||
|
|
||||||
@@ -231,9 +232,9 @@ export function EditServerDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_image")}</Label>
|
<Label htmlFor="edit-server-image">{t("create_server_image")}</Label>
|
||||||
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="edit-server-image">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
images.loading
|
images.loading
|
||||||
@@ -290,9 +291,9 @@ export function EditServerDialog({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_memory")}</Label>
|
<Label htmlFor="edit-server-memory">{t("create_server_memory")}</Label>
|
||||||
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="edit-server-memory">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -310,8 +311,8 @@ export function EditServerDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{/* Storage is immutable but displayed to maintain visual consistency */}
|
{/* Storage is immutable but displayed to maintain visual consistency */}
|
||||||
<Label>{t("create_server_storage")}</Label>
|
<Label htmlFor="edit-server-storage">{t("create_server_storage")}</Label>
|
||||||
<Input value={currentStorage} disabled className="opacity-60 cursor-not-allowed select-none bg-muted" />
|
<Input id="edit-server-storage" value={currentStorage} disabled className="opacity-60 cursor-not-allowed select-none bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -326,12 +327,12 @@ export function EditServerDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("create_server_policy")}</Label>
|
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.autostartPolicy}
|
value={form.autostartPolicy}
|
||||||
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="edit-server-policy">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -346,9 +347,9 @@ export function EditServerDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("edit_server_idle")}</Label>
|
<Label htmlFor="edit-server-idle">{t("edit_server_idle")}</Label>
|
||||||
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
|
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="edit-server-idle">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -372,7 +373,7 @@ export function EditServerDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmFooter
|
<ConfirmFooter
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { InlineError, MessageLine } from "./MessageLine";
|
||||||
|
import { ErrorState } from "./States";
|
||||||
|
import { FeedbackLine } from "./players/shared";
|
||||||
|
|
||||||
|
// A failure has to interrupt a screen reader (role=alert); a success waits for
|
||||||
|
// a pause (role=status). Neither may be an empty region.
|
||||||
|
describe("announced messages", () => {
|
||||||
|
it("MessageLine interrupts for an error and waits for a success", () => {
|
||||||
|
const { rerender } = render(<MessageLine kind="error" message="save failed" />);
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("save failed");
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
|
||||||
|
rerender(<MessageLine kind="success" message="saved" />);
|
||||||
|
expect(screen.getByRole("status").textContent).toBe("saved");
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
|
||||||
|
rerender(<MessageLine kind="error" message="short" compact />);
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("InlineError renders nothing until there is an error", () => {
|
||||||
|
const { rerender, container } = render(<InlineError message={null} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
|
||||||
|
rerender(<InlineError message="" />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
|
||||||
|
rerender(<InlineError message="wrong code" />);
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("wrong code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FeedbackLine interrupts only for a failed player action", () => {
|
||||||
|
const { rerender, container } = render(<FeedbackLine fb={{ kind: "err", msg: "no such player" }} />);
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("no such player");
|
||||||
|
|
||||||
|
rerender(<FeedbackLine fb={{ kind: "ok", msg: "whitelisted" }} />);
|
||||||
|
expect(screen.getByRole("status").textContent).toBe("whitelisted");
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
|
||||||
|
rerender(<FeedbackLine fb={null} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ErrorState reads out the reason, and only the reason", () => {
|
||||||
|
render(<ErrorState error={{ status: 409, code: "test", message: "backend refused" }} onRetry={() => {}} />);
|
||||||
|
const alert = screen.getByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("backend refused");
|
||||||
|
expect(alert.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,10 +18,15 @@ const STYLE = {
|
|||||||
|
|
||||||
const COMPACT_STYLE = "bg-destructive/10 text-destructive";
|
const COMPACT_STYLE = "bg-destructive/10 text-destructive";
|
||||||
|
|
||||||
|
// A failure is read out at once (alert); a success waits for the screen reader
|
||||||
|
// to finish what it is saying (status).
|
||||||
|
const ROLE = { error: "alert", success: "status" } as const;
|
||||||
|
|
||||||
export function MessageLine({ kind, message, compact, className }: Props) {
|
export function MessageLine({ kind, message, compact, className }: Props) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
|
role={ROLE[kind]}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-medium rounded-md p-2.5",
|
"text-xs font-medium rounded-md p-2.5",
|
||||||
COMPACT_STYLE,
|
COMPACT_STYLE,
|
||||||
@@ -35,6 +40,7 @@ export function MessageLine({ kind, message, compact, className }: Props) {
|
|||||||
const Icon = ICON[kind];
|
const Icon = ICON[kind];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role={ROLE[kind]}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md border p-3 text-sm",
|
"flex items-center gap-2 rounded-md border p-3 text-sm",
|
||||||
STYLE[kind],
|
STYLE[kind],
|
||||||
@@ -46,3 +52,14 @@ export function MessageLine({ kind, message, compact, className }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** InlineError is the one-line failure a form shows under its fields, or
|
||||||
|
* nothing when there is none. */
|
||||||
|
export function InlineError({ message, className }: { message?: string | null; className?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<p role="alert" className={cn("text-sm text-destructive", className)}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
<p className="text-sm text-muted-foreground">{humanizeError(error)}</p>
|
<p role="alert" className="text-sm text-muted-foreground">{humanizeError(error)}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function BansSection({ name }: { name: string }) {
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-xs text-destructive">{t("access_ban_load_error")}</p>
|
<p role="alert" className="text-xs text-destructive">{t("access_ban_load_error")}</p>
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-xs text-destructive">{t("access_online_load_error")}</p>
|
<p role="alert" className="text-xs text-destructive">{t("access_online_load_error")}</p>
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
{namesUnavailable ? (
|
{namesUnavailable ? (
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function WhitelistSection({ name, defaultOpen = false }: { name: string;
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
|
<p role="alert" className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function FeedbackLine({ fb }: { fb: Feedback }) {
|
|||||||
if (!fb) return null;
|
if (!fb) return null;
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role={fb.kind === "ok" ? "status" : "alert"}
|
||||||
className={fb.kind === "ok" ? "text-xs text-emerald-500" : "text-xs text-destructive"}
|
className={fb.kind === "ok" ? "text-xs text-emerald-500" : "text-xs text-destructive"}
|
||||||
>
|
>
|
||||||
{fb.msg}
|
{fb.msg}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@
|
|||||||
"users_search_btn": "Search",
|
"users_search_btn": "Search",
|
||||||
"users_filter_role_all": "All Roles",
|
"users_filter_role_all": "All Roles",
|
||||||
"users_filter_status_all": "All Status",
|
"users_filter_status_all": "All Status",
|
||||||
|
"users_filter_role": "Filter by role",
|
||||||
|
"users_filter_status": "Filter by status",
|
||||||
"users_status_active": "Active",
|
"users_status_active": "Active",
|
||||||
"users_status_disabled": "Disabled",
|
"users_status_disabled": "Disabled",
|
||||||
"users_role_admin": "Admin",
|
"users_role_admin": "Admin",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"brand_tagline": "K8s-native Minecraft orchestration",
|
"brand_tagline": "K8s-native Minecraft orchestration",
|
||||||
"page_title": "Felis · Console",
|
"page_title": "Felis · Console",
|
||||||
"toggle_theme": "Toggle theme",
|
"toggle_theme": "Toggle theme",
|
||||||
|
"change_language": "Language",
|
||||||
"pagination_prev": "Previous",
|
"pagination_prev": "Previous",
|
||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
"pagination_of": "Page {{current}} of {{total}}",
|
"pagination_of": "Page {{current}} of {{total}}",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"fleet_distribution": "Phase distribution",
|
"fleet_distribution": "Phase distribution",
|
||||||
"fleet_search_placeholder": "Search name, subdomain or owner…",
|
"fleet_search_placeholder": "Search name, subdomain or owner…",
|
||||||
"fleet_filter_all": "All phases",
|
"fleet_filter_all": "All phases",
|
||||||
|
"fleet_filter_phase": "Filter by phase",
|
||||||
"fleet_count": "{{count}} servers",
|
"fleet_count": "{{count}} servers",
|
||||||
"fleet_count_filtered": "{{shown}} / {{total}} servers",
|
"fleet_count_filtered": "{{shown}} / {{total}} servers",
|
||||||
"fleet_col_status": "Status",
|
"fleet_col_status": "Status",
|
||||||
|
|||||||
@@ -156,9 +156,12 @@
|
|||||||
"luckperms_actions_column": "Actions",
|
"luckperms_actions_column": "Actions",
|
||||||
"luckperms_custom_group_placeholder": "Enter custom group name...",
|
"luckperms_custom_group_placeholder": "Enter custom group name...",
|
||||||
"luckperms_batch_players": "Player Usernames List",
|
"luckperms_batch_players": "Player Usernames List",
|
||||||
|
"luckperms_player_lookup": "Look up a player by name",
|
||||||
"luckperms_recent_actions": "Recent Actions History",
|
"luckperms_recent_actions": "Recent Actions History",
|
||||||
"luckperms_no_recent_actions": "No recent actions.",
|
"luckperms_no_recent_actions": "No recent actions.",
|
||||||
"luckperms_revert": "Revert",
|
"luckperms_revert": "Revert",
|
||||||
|
"luckperms_remove_group": "Remove group {{group}}",
|
||||||
|
"luckperms_remove_perm": "Remove permission {{node}}",
|
||||||
"luckperms_reverting": "Reverting...",
|
"luckperms_reverting": "Reverting...",
|
||||||
"luckperms_revert_success": "Action reverted successfully!",
|
"luckperms_revert_success": "Action reverted successfully!",
|
||||||
"luckperms_presets": "Presets",
|
"luckperms_presets": "Presets",
|
||||||
|
|||||||
@@ -142,6 +142,8 @@
|
|||||||
"users_search_btn": "搜索",
|
"users_search_btn": "搜索",
|
||||||
"users_filter_role_all": "全部角色",
|
"users_filter_role_all": "全部角色",
|
||||||
"users_filter_status_all": "全部状态",
|
"users_filter_status_all": "全部状态",
|
||||||
|
"users_filter_role": "按角色筛选",
|
||||||
|
"users_filter_status": "按状态筛选",
|
||||||
"users_status_active": "正常",
|
"users_status_active": "正常",
|
||||||
"users_status_disabled": "已禁用",
|
"users_status_disabled": "已禁用",
|
||||||
"users_role_admin": "管理员",
|
"users_role_admin": "管理员",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
||||||
"page_title": "Felis · 控制台",
|
"page_title": "Felis · 控制台",
|
||||||
"toggle_theme": "切换主题",
|
"toggle_theme": "切换主题",
|
||||||
|
"change_language": "切换语言",
|
||||||
"pagination_prev": "上一页",
|
"pagination_prev": "上一页",
|
||||||
"pagination_next": "下一页",
|
"pagination_next": "下一页",
|
||||||
"pagination_of": "第 {{current}} / {{total}} 页",
|
"pagination_of": "第 {{current}} / {{total}} 页",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"fleet_distribution": "状态分布",
|
"fleet_distribution": "状态分布",
|
||||||
"fleet_search_placeholder": "搜索名称、域名或所有者…",
|
"fleet_search_placeholder": "搜索名称、域名或所有者…",
|
||||||
"fleet_filter_all": "全部状态",
|
"fleet_filter_all": "全部状态",
|
||||||
|
"fleet_filter_phase": "按状态筛选",
|
||||||
"fleet_count": "共 {{count}} 台",
|
"fleet_count": "共 {{count}} 台",
|
||||||
"fleet_count_filtered": "{{shown}} / {{total}} 台",
|
"fleet_count_filtered": "{{shown}} / {{total}} 台",
|
||||||
"fleet_col_status": "状态",
|
"fleet_col_status": "状态",
|
||||||
|
|||||||
@@ -156,9 +156,12 @@
|
|||||||
"luckperms_actions_column": "操作",
|
"luckperms_actions_column": "操作",
|
||||||
"luckperms_custom_group_placeholder": "输入自定义组名...",
|
"luckperms_custom_group_placeholder": "输入自定义组名...",
|
||||||
"luckperms_batch_players": "玩家用户名列表",
|
"luckperms_batch_players": "玩家用户名列表",
|
||||||
|
"luckperms_player_lookup": "按名称查询玩家",
|
||||||
"luckperms_recent_actions": "最近操作历史",
|
"luckperms_recent_actions": "最近操作历史",
|
||||||
"luckperms_no_recent_actions": "暂无最近操作历史。",
|
"luckperms_no_recent_actions": "暂无最近操作历史。",
|
||||||
"luckperms_revert": "撤销",
|
"luckperms_revert": "撤销",
|
||||||
|
"luckperms_remove_group": "移除权限组 {{group}}",
|
||||||
|
"luckperms_remove_perm": "移除权限 {{node}}",
|
||||||
"luckperms_reverting": "正在撤销...",
|
"luckperms_reverting": "正在撤销...",
|
||||||
"luckperms_revert_success": "已成功撤销该操作!",
|
"luckperms_revert_success": "已成功撤销该操作!",
|
||||||
"luckperms_presets": "预设",
|
"luckperms_presets": "预设",
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ describe("Account passkey delete", () => {
|
|||||||
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
|
||||||
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
|
||||||
|
|
||||||
expect(await within(screen.getByRole("dialog")).findByText(t("errors:last_passkey"))).toBeTruthy();
|
const alert = await within(screen.getByRole("dialog")).findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe(t("errors:last_passkey"));
|
||||||
expect(mocks.passkeyList).toHaveBeenCalledTimes(2);
|
expect(mocks.passkeyList).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Loading, ErrorState } from "@/components/States";
|
import { Loading, ErrorState } from "@/components/States";
|
||||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { formatAbsolute } from "@/lib/format";
|
import { formatAbsolute } from "@/lib/format";
|
||||||
@@ -361,7 +361,7 @@ export function Account() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{emailError && <p className="text-sm text-destructive ml-9">{emailError}</p>}
|
<InlineError message={emailError} className="ml-9" />
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -405,7 +405,7 @@ export function Account() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{passkeyError && <p className="text-sm text-destructive">{passkeyError}</p>}
|
<InlineError message={passkeyError} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -613,7 +613,7 @@ function LinkForm({
|
|||||||
{submitting ? t("verifying") : t("verify_btn")}
|
{submitting ? t("verifying") : t("verify_btn")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -858,7 +858,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
<InlineError message={err} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { Login } from "./Login";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({
|
||||||
|
authEmailStart: vi.fn(),
|
||||||
|
authEmailVerify: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tier", () => ({
|
||||||
|
useTier: () => ({ loading: false, identity: null, refresh: calls.refresh }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/config", () => ({
|
||||||
|
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "localhost" }),
|
||||||
|
useConfig: () => null,
|
||||||
|
}));
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
|
||||||
|
|
||||||
|
function renderLogin() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(calls)) fn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Login", () => {
|
||||||
|
it("reads out why the code could not be sent", async () => {
|
||||||
|
calls.authEmailStart.mockRejectedValue({ status: 429, code: "otp_resend_cooldown", message: "" });
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("auth:send_otp") }));
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:otp_resend_cooldown"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads out a wrong code, and clears it on the next try", async () => {
|
||||||
|
calls.authEmailStart.mockResolvedValue(undefined);
|
||||||
|
calls.authEmailVerify.mockRejectedValueOnce({ status: 400, code: "invalid_code", message: "" });
|
||||||
|
calls.authEmailVerify.mockReturnValueOnce(new Promise(() => {}));
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("auth:send_otp") }));
|
||||||
|
await userEvent.type(await screen.findByLabelText(t("auth:otp_code")), "000000");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("auth:otp_btn") }));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:invalid_code"));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("auth:otp_btn") }));
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { loginReturnPath } from "@/lib/auth";
|
|||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
// Login is the passwordless sign-in (spec §B). Passkey and email-OTP are the
|
// Login is the passwordless sign-in (spec §B). Passkey and email-OTP are the
|
||||||
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
|
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
|
||||||
@@ -351,7 +352,7 @@ export function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
|
|
||||||
{otpSent && (
|
{otpSent && (
|
||||||
<Button
|
<Button
|
||||||
@@ -440,7 +441,7 @@ export function Login() {
|
|||||||
{t("bind_hint")}
|
{t("bind_hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -494,7 +495,7 @@ export function Login() {
|
|||||||
{t("op_hint")}
|
{t("op_hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -564,7 +565,7 @@ export function Login() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -574,16 +574,20 @@ export function MySubmissionsPage() {
|
|||||||
|
|
||||||
{/* File Dropzone */}
|
{/* File Dropzone */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-foreground">
|
<Label htmlFor="submission-file" className="text-xs font-semibold text-foreground">
|
||||||
{t("file_label")} <span className="text-destructive">*</span>
|
{t("file_label")} <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
{/* The real file input stays in the tab order (visually hidden),
|
||||||
|
so a keyboard reaches the picker; the drop zone below shows
|
||||||
|
its focus ring. */}
|
||||||
<input
|
<input
|
||||||
|
id="submission-file"
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept=".tar.gz"
|
accept=".tar.gz"
|
||||||
className="hidden"
|
className="peer sr-only"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -594,7 +598,7 @@ export function MySubmissionsPage() {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => !isSubmitting && fileInputRef.current?.click()}
|
onClick={() => !isSubmitting && fileInputRef.current?.click()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center border border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors min-h-[140px]",
|
"flex flex-col items-center justify-center border border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors min-h-[140px] peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
|
||||||
dragActive ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30",
|
dragActive ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30",
|
||||||
isSubmitting && "opacity-50 cursor-not-allowed",
|
isSubmitting && "opacity-50 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { BackLink } from "@/components/BackLink";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -442,7 +442,7 @@ function RestoreControls({
|
|||||||
{step === "stopping" ? t("stopping") : t("restoring")}
|
{step === "stopping" ? t("stopping") : t("restoring")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
|
|
||||||
<ConfirmFooter
|
<ConfirmFooter
|
||||||
onCancel={() => setOpen(false)}
|
onCancel={() => setOpen(false)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { hostFor } from "@/lib/config";
|
|||||||
import type { Phase, AutostartPolicy } from "@/lib/types";
|
import type { Phase, AutostartPolicy } from "@/lib/types";
|
||||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||||
import { PowerButton } from "@/components/PowerButton";
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
// notStreamingCopy explains why there is no live feed for a phase that has no
|
// notStreamingCopy explains why there is no live feed for a phase that has no
|
||||||
// streamable pod. The read path only has something to relay once a pod is up, so
|
// streamable pod. The read path only has something to relay once a pod is up, so
|
||||||
@@ -180,7 +181,7 @@ function CommandInput({ name }: { name: string }) {
|
|||||||
/>
|
/>
|
||||||
{sending && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 text-zinc-500" />}
|
{sending && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 text-zinc-500" />}
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="text-xs text-destructive">{err}</p>}
|
<InlineError message={err} className="text-xs" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export function ServerFiles() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => void load(dir)}
|
onClick={() => void load(dir)}
|
||||||
disabled={listLoading}
|
disabled={listLoading}
|
||||||
|
aria-label={t("refresh")}
|
||||||
title={t("refresh")}
|
title={t("refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", listLoading && "animate-spin")} />
|
<RefreshCw className={cn("h-4 w-4", listLoading && "animate-spin")} />
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ export function ServerLuckPerms() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Steve"
|
placeholder="Steve"
|
||||||
|
aria-label={t("luckperms_player_lookup")}
|
||||||
className="pl-9 h-9 text-xs font-mono bg-background/50 border-input focus-visible:ring-primary/30"
|
className="pl-9 h-9 text-xs font-mono bg-background/50 border-input focus-visible:ring-primary/30"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
@@ -434,7 +435,7 @@ export function ServerLuckPerms() {
|
|||||||
|
|
||||||
{/* Operations Feedback */}
|
{/* Operations Feedback */}
|
||||||
{formFeedback && (
|
{formFeedback && (
|
||||||
<div className={cn(
|
<div role={formFeedback.kind === "ok" ? "status" : "alert"} className={cn(
|
||||||
"p-3 rounded-md text-xs whitespace-pre-wrap break-all border transition-all animate-in fade-in duration-200",
|
"p-3 rounded-md text-xs whitespace-pre-wrap break-all border transition-all animate-in fade-in duration-200",
|
||||||
formFeedback.kind === "ok"
|
formFeedback.kind === "ok"
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
||||||
@@ -471,6 +472,8 @@ export function ServerLuckPerms() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveGroup(group)}
|
onClick={() => handleRemoveGroup(group)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
aria-label={t("luckperms_remove_group", { group })}
|
||||||
|
title={t("luckperms_remove_group", { group })}
|
||||||
className="text-muted-foreground hover:text-destructive hover:bg-muted p-0.5 rounded transition-all focus:outline-none"
|
className="text-muted-foreground hover:text-destructive hover:bg-muted p-0.5 rounded transition-all focus:outline-none"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -484,11 +487,12 @@ export function ServerLuckPerms() {
|
|||||||
|
|
||||||
<div className="border-t border-border/50 pt-4 flex flex-col sm:flex-row gap-3 items-end">
|
<div className="border-t border-border/50 pt-4 flex flex-col sm:flex-row gap-3 items-end">
|
||||||
<div className="grid gap-1.5 flex-1 w-full">
|
<div className="grid gap-1.5 flex-1 w-full">
|
||||||
<Label className="text-xs text-muted-foreground font-semibold">
|
<Label htmlFor="lp-group-name" className="text-xs text-muted-foreground font-semibold">
|
||||||
{t("luckperms_group_name")}
|
{t("luckperms_group_name")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
id="lp-group-name"
|
||||||
placeholder={t("luckperms_custom_group_placeholder")}
|
placeholder={t("luckperms_custom_group_placeholder")}
|
||||||
value={groupNameInput}
|
value={groupNameInput}
|
||||||
onChange={(e) => setGroupNameInput(e.target.value)}
|
onChange={(e) => setGroupNameInput(e.target.value)}
|
||||||
@@ -500,7 +504,7 @@ export function ServerLuckPerms() {
|
|||||||
onValueChange={(v) => setGroupNameInput(v)}
|
onValueChange={(v) => setGroupNameInput(v)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[120px] h-9 text-xs bg-background/50 border-input text-muted-foreground">
|
<SelectTrigger className="w-[120px] h-9 text-xs bg-background/50 border-input text-muted-foreground" aria-label={t("luckperms_presets")}>
|
||||||
<span>{t("luckperms_presets")}</span>
|
<span>{t("luckperms_presets")}</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-border">
|
<SelectContent className="border-border">
|
||||||
@@ -596,6 +600,8 @@ export function ServerLuckPerms() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() => handleRemovePermission(p.node, p.world)}
|
onClick={() => handleRemovePermission(p.node, p.world)}
|
||||||
|
aria-label={t("luckperms_remove_perm", { node: p.node })}
|
||||||
|
title={t("luckperms_remove_perm", { node: p.node })}
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-muted rounded transition-all focus:outline-none"
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-muted rounded transition-all focus:outline-none"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -640,7 +646,7 @@ export function ServerLuckPerms() {
|
|||||||
onValueChange={(v) => setPermNodeInput(v)}
|
onValueChange={(v) => setPermNodeInput(v)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[100px] h-9 text-xs shrink-0 bg-background/50 border-input text-muted-foreground">
|
<SelectTrigger className="w-[100px] h-9 text-xs shrink-0 bg-background/50 border-input text-muted-foreground" aria-label={t("luckperms_presets")}>
|
||||||
<span>{t("luckperms_presets")}</span>
|
<span>{t("luckperms_presets")}</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-border">
|
<SelectContent className="border-border">
|
||||||
@@ -718,6 +724,7 @@ export function ServerLuckPerms() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={clearHistory}
|
onClick={clearHistory}
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
|
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
|
||||||
|
aria-label={t("luckperms_clear_history")}
|
||||||
title={t("luckperms_clear_history")}
|
title={t("luckperms_clear_history")}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { api, humanizeError, type SetupState } from "@/lib/api";
|
import { api, humanizeError, type SetupState } from "@/lib/api";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
|
import { InlineError } from "@/components/MessageLine";
|
||||||
|
|
||||||
// Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The
|
// Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The
|
||||||
// `felis setup` MC-bind flow prints https://op.console.<root>/setup?token=<raw> —
|
// `felis setup` MC-bind flow prints https://op.console.<root>/setup?token=<raw> —
|
||||||
@@ -107,7 +108,7 @@ export function Setup() {
|
|||||||
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
|
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-4 pt-6 text-sm">
|
<CardContent className="space-y-4 pt-6 text-sm">
|
||||||
<p className="text-muted-foreground">{fatal}</p>
|
<p role="alert" className="text-muted-foreground">{fatal}</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t("setup_invalid_hint_prefix")}
|
{t("setup_invalid_hint_prefix")}
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||||
@@ -206,7 +207,7 @@ function EmailStep({
|
|||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
<Button type="submit" className="w-full" disabled={busy || !email.trim()}>
|
<Button type="submit" className="w-full" disabled={busy || !email.trim()}>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<>
|
<>
|
||||||
@@ -290,7 +291,7 @@ function PasskeyStep({
|
|||||||
<Fingerprint className="h-4 w-4 text-primary" /> {t("setup_passkey_step")}
|
<Fingerprint className="h-4 w-4 text-primary" /> {t("setup_passkey_step")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("setup_passkey_desc")}</p>
|
<p className="text-sm text-muted-foreground">{t("setup_passkey_desc")}</p>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<InlineError message={error} />
|
||||||
<Button className="w-full gap-2" onClick={enroll} disabled={busy}>
|
<Button className="w-full gap-2" onClick={enroll} disabled={busy}>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export function ImageAdmin() {
|
|||||||
onClick={() => handleRemove(img.image_ref)}
|
onClick={() => handleRemove(img.image_ref)}
|
||||||
disabled={deletingRef === img.image_ref}
|
disabled={deletingRef === img.image_ref}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
aria-label={t("delete_image_tooltip")}
|
||||||
title={t("delete_image_tooltip")}
|
title={t("delete_image_tooltip")}
|
||||||
>
|
>
|
||||||
{deletingRef === img.image_ref ? (
|
{deletingRef === img.image_ref ? (
|
||||||
|
|||||||
@@ -287,11 +287,11 @@ export function ImageBuildPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleTrigger} className="space-y-4">
|
<form onSubmit={handleTrigger} className="space-y-4">
|
||||||
<div className="space-y-1.5 p-3 rounded-lg border border-border bg-muted/20">
|
<div className="space-y-1.5 p-3 rounded-lg border border-border bg-muted/20">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("build_import_submission_label")}
|
{t("build_import_submission_label")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select onValueChange={handleSelectSubmission} disabled={triggering}>
|
<Select onValueChange={handleSelectSubmission} disabled={triggering}>
|
||||||
<SelectTrigger className="w-full text-xs h-9 bg-background [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:justify-between [&>span]:gap-2 pr-2">
|
<SelectTrigger id="build-import-submission" className="w-full text-xs h-9 bg-background [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:justify-between [&>span]:gap-2 pr-2">
|
||||||
<SelectValue placeholder={t("build_import_submission_placeholder")} />
|
<SelectValue placeholder={t("build_import_submission_placeholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export function SubmissionsPage() {
|
|||||||
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
|
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
|
||||||
onClick={() => handleApprove(sub)}
|
onClick={() => handleApprove(sub)}
|
||||||
disabled={!!busyId || !approveDigest}
|
disabled={!!busyId || !approveDigest}
|
||||||
|
aria-label={t("approve_btn")}
|
||||||
title={approveDigest ? t("approve_btn") : t("approve_needs_context")}
|
title={approveDigest ? t("approve_btn") : t("approve_needs_context")}
|
||||||
>
|
>
|
||||||
{isBusyApprove ? (
|
{isBusyApprove ? (
|
||||||
@@ -377,6 +378,7 @@ export function SubmissionsPage() {
|
|||||||
className="h-7 w-7 text-rose-500 hover:text-rose-600 border-rose-500/20 hover:bg-rose-500/10 focus-visible:ring-rose-500"
|
className="h-7 w-7 text-rose-500 hover:text-rose-600 border-rose-500/20 hover:bg-rose-500/10 focus-visible:ring-rose-500"
|
||||||
onClick={() => openRejectDialog(sub.id)}
|
onClick={() => openRejectDialog(sub.id)}
|
||||||
disabled={!!busyId}
|
disabled={!!busyId}
|
||||||
|
aria-label={t("reject_btn")}
|
||||||
title={t("reject_btn")}
|
title={t("reject_btn")}
|
||||||
>
|
>
|
||||||
{isBusyReject ? (
|
{isBusyReject ? (
|
||||||
@@ -404,6 +406,7 @@ export function SubmissionsPage() {
|
|||||||
className="h-7 w-7 text-destructive hover:text-destructive border-destructive/20 hover:bg-destructive/10 focus-visible:ring-destructive"
|
className="h-7 w-7 text-destructive hover:text-destructive border-destructive/20 hover:bg-destructive/10 focus-visible:ring-destructive"
|
||||||
onClick={() => setConfirmingDelete(sub.id)}
|
onClick={() => setConfirmingDelete(sub.id)}
|
||||||
disabled={!!busyId}
|
disabled={!!busyId}
|
||||||
|
aria-label={t("delete_submission_btn")}
|
||||||
title={t("delete_submission_btn")}
|
title={t("delete_submission_btn")}
|
||||||
>
|
>
|
||||||
{isBusyDelete ? (
|
{isBusyDelete ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||||
import { RoleBadge } from "@/components/RoleBadge";
|
import { RoleBadge } from "@/components/RoleBadge";
|
||||||
import { UserStatusBadge } from "@/components/UserStatusBadge";
|
import { UserStatusBadge } from "@/components/UserStatusBadge";
|
||||||
import {
|
import {
|
||||||
@@ -167,16 +167,18 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
|
<Label htmlFor="user-detail-username" className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_email")}</Label>
|
<Label htmlFor="user-detail-email" className="text-xs font-semibold text-muted-foreground">{t("users_field_email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
@@ -185,9 +187,9 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
|
|||||||
</div>
|
</div>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
|
<Label htmlFor="user-detail-role" className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
|
||||||
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
|
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger id="user-detail-role" className="h-9 text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -290,8 +292,9 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
|
|||||||
<form onSubmit={handleAdd} className="space-y-3 rounded-md border border-border/50 bg-muted/20 p-3">
|
<form onSubmit={handleAdd} className="space-y-3 rounded-md border border-border/50 bg-muted/20 p-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
<Label className="text-[11px] font-semibold text-muted-foreground">Minecraft UUID</Label>
|
<Label htmlFor="user-detail-uuid" className="text-[11px] font-semibold text-muted-foreground">Minecraft UUID</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-uuid"
|
||||||
value={newUUID}
|
value={newUUID}
|
||||||
onChange={(e) => setNewUUID(e.target.value)}
|
onChange={(e) => setNewUUID(e.target.value)}
|
||||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
@@ -299,9 +302,9 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28 space-y-1.5">
|
<div className="w-28 space-y-1.5">
|
||||||
<Label className="text-[11px] font-semibold text-muted-foreground">{t("users_link_source")}</Label>
|
<Label htmlFor="user-detail-link-source" className="text-[11px] font-semibold text-muted-foreground">{t("users_link_source")}</Label>
|
||||||
<Select value={newSource} onValueChange={setNewSource}>
|
<Select value={newSource} onValueChange={setNewSource}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger id="user-detail-link-source" className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -311,9 +314,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{err && (
|
<InlineError message={err} className="text-xs" />
|
||||||
<p className="text-xs text-destructive">{err}</p>
|
|
||||||
)}
|
|
||||||
<Button type="submit" size="sm" disabled={adding || !newUUID.trim()} className="h-8 text-xs gap-1">
|
<Button type="submit" size="sm" disabled={adding || !newUUID.trim()} className="h-8 text-xs gap-1">
|
||||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link className="h-3.5 w-3.5" />}
|
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link className="h-3.5 w-3.5" />}
|
||||||
{t("users_link_confirm")}
|
{t("users_link_confirm")}
|
||||||
@@ -339,6 +340,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
|
|||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 mr-0.5"
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 mr-0.5"
|
||||||
disabled={unlinking === acc.mc_uuid}
|
disabled={unlinking === acc.mc_uuid}
|
||||||
onClick={() => setUnlinkDlg(acc.mc_uuid)}
|
onClick={() => setUnlinkDlg(acc.mc_uuid)}
|
||||||
|
aria-label={t("users_unlink_tooltip")}
|
||||||
title={t("users_unlink_tooltip")}
|
title={t("users_unlink_tooltip")}
|
||||||
>
|
>
|
||||||
{unlinking === acc.mc_uuid ? (
|
{unlinking === acc.mc_uuid ? (
|
||||||
@@ -429,8 +431,9 @@ function QuotasCard({ userId }: { userId: string }) {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
|
<Label htmlFor="user-detail-quota-servers" className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-quota-servers"
|
||||||
type="number"
|
type="number"
|
||||||
value={maxServers}
|
value={maxServers}
|
||||||
onChange={(e) => setMaxServers(e.target.value)}
|
onChange={(e) => setMaxServers(e.target.value)}
|
||||||
@@ -439,8 +442,9 @@ function QuotasCard({ userId }: { userId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
|
<Label htmlFor="user-detail-quota-cpu" className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-quota-cpu"
|
||||||
type="number"
|
type="number"
|
||||||
value={maxCpu}
|
value={maxCpu}
|
||||||
onChange={(e) => setMaxCpu(e.target.value)}
|
onChange={(e) => setMaxCpu(e.target.value)}
|
||||||
@@ -449,8 +453,9 @@ function QuotasCard({ userId }: { userId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
|
<Label htmlFor="user-detail-quota-memory" className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-quota-memory"
|
||||||
type="number"
|
type="number"
|
||||||
value={maxMem}
|
value={maxMem}
|
||||||
onChange={(e) => setMaxMem(e.target.value)}
|
onChange={(e) => setMaxMem(e.target.value)}
|
||||||
@@ -459,8 +464,9 @@ function QuotasCard({ userId }: { userId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
|
<Label htmlFor="user-detail-quota-storage" className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-detail-quota-storage"
|
||||||
type="number"
|
type="number"
|
||||||
value={maxStorage}
|
value={maxStorage}
|
||||||
onChange={(e) => setMaxStorage(e.target.value)}
|
onChange={(e) => setMaxStorage(e.target.value)}
|
||||||
@@ -590,6 +596,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
|
|||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
|
||||||
disabled={revoking === s.token_hash}
|
disabled={revoking === s.token_hash}
|
||||||
onClick={() => setRevokeOneDlg(s.token_hash)}
|
onClick={() => setRevokeOneDlg(s.token_hash)}
|
||||||
|
aria-label={t("users_session_revoke_one")}
|
||||||
title={t("users_session_revoke_one")}
|
title={t("users_session_revoke_one")}
|
||||||
>
|
>
|
||||||
{revoking === s.token_hash ? (
|
{revoking === s.token_hash ? (
|
||||||
@@ -788,7 +795,7 @@ function DangerDialogs({
|
|||||||
{user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")}
|
{user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
<InlineError message={err} />
|
||||||
<ConfirmFooter onCancel={close} onConfirm={handleDisable} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")} confirmVariant={user.disabled ? "default" : "destructive"} />
|
<ConfirmFooter onCancel={close} onConfirm={handleDisable} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")} confirmVariant={user.disabled ? "default" : "destructive"} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -805,7 +812,7 @@ function DangerDialogs({
|
|||||||
{t("users_danger_delete_dlg_desc")}
|
{t("users_danger_delete_dlg_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
<InlineError message={err} />
|
||||||
<ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
|
<ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -822,7 +829,7 @@ function DangerDialogs({
|
|||||||
{t("users_danger_passkeys_dlg_desc")}
|
{t("users_danger_passkeys_dlg_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
<InlineError message={err} />
|
||||||
<ConfirmFooter onCancel={close} onConfirm={handleUnbindPasskeys} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_passkeys_yes")} />
|
<ConfirmFooter onCancel={close} onConfirm={handleUnbindPasskeys} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_passkeys_yes")} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(0); }}>
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(0); }}>
|
||||||
<SelectTrigger className="h-9 w-[120px] text-sm">
|
<SelectTrigger className="h-9 w-[120px] text-sm" aria-label={t("users_filter_role")}>
|
||||||
<SelectValue placeholder={t("users_filter_role_all")} />
|
<SelectValue placeholder={t("users_filter_role_all")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -92,7 +92,7 @@ export function UsersPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={disabledFilter} onValueChange={(v) => { setDisabledFilter(v); setPage(0); }}>
|
<Select value={disabledFilter} onValueChange={(v) => { setDisabledFilter(v); setPage(0); }}>
|
||||||
<SelectTrigger className="h-9 w-[130px] text-sm">
|
<SelectTrigger className="h-9 w-[130px] text-sm" aria-label={t("users_filter_status")}>
|
||||||
<SelectValue placeholder={t("users_filter_status_all")} />
|
<SelectValue placeholder={t("users_filter_status_all")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export function ServersPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
aria-label={t("fleet_refresh")}
|
||||||
title={t("fleet_refresh")}
|
title={t("fleet_refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
@@ -233,7 +234,7 @@ export function ServersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
<div role="alert" className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
{humanizeError(error)}
|
{humanizeError(error)}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +318,7 @@ export function ServersPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-44">
|
<SelectTrigger className="w-44" aria-label={t("fleet_filter_phase")}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
Reference in new issue
Block a user