fix(panel): 错误提示改为读屏即时播报,图标按钮、筛选下拉与表单字段补齐可读名称,上传区可用键盘选择文件,并加全页面无障碍巡检

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:35:14 +08:00
1 parent 1a8cccf245
commit 6c3421fe8c
37 files changed
+375 -79

No files matched your search

+107
View File
@@ -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();
});
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
trace: "retain-on-failure",
},
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",
testMatch: /mobile\.spec\.ts/,
+6 -2
View File
@@ -117,11 +117,15 @@ function UserStrip() {
}
function LangToggle() {
const { i18n } = useTranslation();
const { t, i18n } = useTranslation("common");
return (
<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" />
</SelectPrimitive.Trigger>
<SelectContent align="start" className="min-w-[6rem]">
+10 -9
View File
@@ -24,6 +24,7 @@ import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
import { InlineError } from "@/components/MessageLine";
// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
@@ -154,9 +155,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</div>
<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)}>
<SelectTrigger>
<SelectTrigger id="create-server-image">
<SelectValue
placeholder={
images.loading
@@ -179,9 +180,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<div className="grid grid-cols-2 gap-4">
<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)}>
<SelectTrigger>
<SelectTrigger id="create-server-memory">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -194,9 +195,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</Select>
</div>
<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)}>
<SelectTrigger>
<SelectTrigger id="create-server-storage">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -211,12 +212,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</div>
<div className="grid gap-2">
<Label>{t("create_server_policy")}</Label>
<Label htmlFor="create-server-policy">{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger>
<SelectTrigger id="create-server-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
</div>
<ConfirmFooter
+6 -4
View File
@@ -95,10 +95,11 @@ export function CreateUserDialog({ onCreated }: Props) {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<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")} *
</Label>
<Input
id="create-user-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("users_create_username_placeholder")}
@@ -109,10 +110,11 @@ export function CreateUserDialog({ onCreated }: Props) {
{/* Email */}
<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")}
</Label>
<Input
id="create-user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -123,11 +125,11 @@ export function CreateUserDialog({ onCreated }: Props) {
{/* Role */}
<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")}
</Label>
<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 />
</SelectTrigger>
<SelectContent>
+12 -11
View File
@@ -23,6 +23,7 @@ import { api, humanizeError } from "@/lib/api";
import { splitImageRef } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { AutostartPolicy } from "@/lib/types";
import { InlineError } from "@/components/MessageLine";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
@@ -231,9 +232,9 @@ export function EditServerDialog({
</div>
<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)}>
<SelectTrigger>
<SelectTrigger id="edit-server-image">
<SelectValue
placeholder={
images.loading
@@ -290,9 +291,9 @@ export function EditServerDialog({
<div className="grid grid-cols-2 gap-4">
<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)}>
<SelectTrigger>
<SelectTrigger id="edit-server-memory">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -310,8 +311,8 @@ export function EditServerDialog({
</div>
<div className="grid gap-2">
{/* Storage is immutable but displayed to maintain visual consistency */}
<Label>{t("create_server_storage")}</Label>
<Input value={currentStorage} disabled className="opacity-60 cursor-not-allowed select-none bg-muted" />
<Label htmlFor="edit-server-storage">{t("create_server_storage")}</Label>
<Input id="edit-server-storage" value={currentStorage} disabled className="opacity-60 cursor-not-allowed select-none bg-muted" />
</div>
</div>
@@ -326,12 +327,12 @@ export function EditServerDialog({
/>
</div>
<div className="grid gap-2">
<Label>{t("create_server_policy")}</Label>
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger>
<SelectTrigger id="edit-server-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -346,9 +347,9 @@ export function EditServerDialog({
</div>
<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)}>
<SelectTrigger>
<SelectTrigger id="edit-server-idle">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -372,7 +373,7 @@ export function EditServerDialog({
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
</div>
<ConfirmFooter
+53
View File
@@ -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();
});
});
+17
View File
@@ -18,10 +18,15 @@ const STYLE = {
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) {
if (compact) {
return (
<p
role={ROLE[kind]}
className={cn(
"text-xs font-medium rounded-md p-2.5",
COMPACT_STYLE,
@@ -35,6 +40,7 @@ export function MessageLine({ kind, message, compact, className }: Props) {
const Icon = ICON[kind];
return (
<div
role={ROLE[kind]}
className={cn(
"flex items-center gap-2 rounded-md border p-3 text-sm",
STYLE[kind],
@@ -46,3 +52,14 @@ export function MessageLine({ kind, message, compact, className }: Props) {
</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>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<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 && (
<button
onClick={onRetry}
+1 -1
View File
@@ -175,7 +175,7 @@ export function BansSection({ name }: { name: string }) {
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
</div>
) : 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 ? (
<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>
@@ -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")}
</div>
) : 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 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
{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")}
</div>
) : 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 ? (
<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>
+1 -1
View File
@@ -37,7 +37,7 @@ export function FeedbackLine({ fb }: { fb: Feedback }) {
if (!fb) return null;
return (
<p
role="status"
role={fb.kind === "ok" ? "status" : "alert"}
className={fb.kind === "ok" ? "text-xs text-emerald-500" : "text-xs text-destructive"}
>
{fb.msg}
@@ -142,6 +142,8 @@
"users_search_btn": "Search",
"users_filter_role_all": "All Roles",
"users_filter_status_all": "All Status",
"users_filter_role": "Filter by role",
"users_filter_status": "Filter by status",
"users_status_active": "Active",
"users_status_disabled": "Disabled",
"users_role_admin": "Admin",
@@ -17,6 +17,7 @@
"brand_tagline": "K8s-native Minecraft orchestration",
"page_title": "Felis · Console",
"toggle_theme": "Toggle theme",
"change_language": "Language",
"pagination_prev": "Previous",
"pagination_next": "Next",
"pagination_of": "Page {{current}} of {{total}}",
+1
View File
@@ -12,6 +12,7 @@
"fleet_distribution": "Phase distribution",
"fleet_search_placeholder": "Search name, subdomain or owner…",
"fleet_filter_all": "All phases",
"fleet_filter_phase": "Filter by phase",
"fleet_count": "{{count}} servers",
"fleet_count_filtered": "{{shown}} / {{total}} servers",
"fleet_col_status": "Status",
@@ -156,9 +156,12 @@
"luckperms_actions_column": "Actions",
"luckperms_custom_group_placeholder": "Enter custom group name...",
"luckperms_batch_players": "Player Usernames List",
"luckperms_player_lookup": "Look up a player by name",
"luckperms_recent_actions": "Recent Actions History",
"luckperms_no_recent_actions": "No recent actions.",
"luckperms_revert": "Revert",
"luckperms_remove_group": "Remove group {{group}}",
"luckperms_remove_perm": "Remove permission {{node}}",
"luckperms_reverting": "Reverting...",
"luckperms_revert_success": "Action reverted successfully!",
"luckperms_presets": "Presets",
@@ -142,6 +142,8 @@
"users_search_btn": "搜索",
"users_filter_role_all": "全部角色",
"users_filter_status_all": "全部状态",
"users_filter_role": "按角色筛选",
"users_filter_status": "按状态筛选",
"users_status_active": "正常",
"users_status_disabled": "已禁用",
"users_role_admin": "管理员",
@@ -17,6 +17,7 @@
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
"page_title": "Felis · 控制台",
"toggle_theme": "切换主题",
"change_language": "切换语言",
"pagination_prev": "上一页",
"pagination_next": "下一页",
"pagination_of": "第 {{current}} / {{total}} 页",
+1
View File
@@ -12,6 +12,7 @@
"fleet_distribution": "状态分布",
"fleet_search_placeholder": "搜索名称、域名或所有者…",
"fleet_filter_all": "全部状态",
"fleet_filter_phase": "按状态筛选",
"fleet_count": "共 {{count}} 台",
"fleet_count_filtered": "{{shown}} / {{total}} 台",
"fleet_col_status": "状态",
@@ -156,9 +156,12 @@
"luckperms_actions_column": "操作",
"luckperms_custom_group_placeholder": "输入自定义组名...",
"luckperms_batch_players": "玩家用户名列表",
"luckperms_player_lookup": "按名称查询玩家",
"luckperms_recent_actions": "最近操作历史",
"luckperms_no_recent_actions": "暂无最近操作历史。",
"luckperms_revert": "撤销",
"luckperms_remove_group": "移除权限组 {{group}}",
"luckperms_remove_perm": "移除权限 {{node}}",
"luckperms_reverting": "正在撤销...",
"luckperms_revert_success": "已成功撤销该操作!",
"luckperms_presets": "预设",
+2 -1
View File
@@ -119,7 +119,8 @@ describe("Account passkey delete", () => {
await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
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);
});
+5 -5
View File
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { MessageLine, InlineError } from "@/components/MessageLine";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { formatAbsolute } from "@/lib/format";
@@ -361,7 +361,7 @@ export function Account() {
</div>
</li>
)}
{emailError && <p className="text-sm text-destructive ml-9">{emailError}</p>}
<InlineError message={emailError} className="ml-9" />
</ol>
)}
</CardContent>
@@ -405,7 +405,7 @@ export function Account() {
required
/>
</div>
{passkeyError && <p className="text-sm text-destructive">{passkeyError}</p>}
<InlineError message={passkeyError} />
</div>
<DialogFooter>
<Button
@@ -613,7 +613,7 @@ function LinkForm({
{submitting ? t("verifying") : t("verify_btn")}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
</form>
</div>
</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>
+69
View File
@@ -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();
});
});
+5 -4
View File
@@ -12,6 +12,7 @@ import { loginReturnPath } from "@/lib/auth";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
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
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
@@ -351,7 +352,7 @@ export function Login() {
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
{otpSent && (
<Button
@@ -440,7 +441,7 @@ export function Login() {
{t("bind_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
<Button
type="submit"
className="w-full"
@@ -494,7 +495,7 @@ export function Login() {
{t("op_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
<Button
type="submit"
className="w-full"
@@ -564,7 +565,7 @@ export function Login() {
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
<Button
type="submit"
+7 -3
View File
@@ -574,16 +574,20 @@ export function MySubmissionsPage() {
{/* File Dropzone */}
<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>
</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
id="submission-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".tar.gz"
className="hidden"
className="peer sr-only"
disabled={isSubmitting}
/>
@@ -594,7 +598,7 @@ export function MySubmissionsPage() {
onDrop={handleDrop}
onClick={() => !isSubmitting && fileInputRef.current?.click()}
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",
isSubmitting && "opacity-50 cursor-not-allowed",
)}
+2 -2
View File
@@ -18,7 +18,7 @@ import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { MessageLine, InlineError } from "@/components/MessageLine";
import {
Dialog,
DialogContent,
@@ -442,7 +442,7 @@ function RestoreControls({
{step === "stopping" ? t("stopping") : t("restoring")}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
<ConfirmFooter
onCancel={() => setOpen(false)}
+2 -1
View File
@@ -16,6 +16,7 @@ import { hostFor } from "@/lib/config";
import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton";
import { InlineError } from "@/components/MessageLine";
// 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
@@ -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" />}
</div>
{err && <p className="text-xs text-destructive">{err}</p>}
<InlineError message={err} className="text-xs" />
</div>
);
}
+1
View File
@@ -396,6 +396,7 @@ export function ServerFiles() {
variant="ghost"
onClick={() => void load(dir)}
disabled={listLoading}
aria-label={t("refresh")}
title={t("refresh")}
>
<RefreshCw className={cn("h-4 w-4", listLoading && "animate-spin")} />
+11 -4
View File
@@ -343,6 +343,7 @@ export function ServerLuckPerms() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
autoComplete="off"
disabled={submitting}
@@ -434,7 +435,7 @@ export function ServerLuckPerms() {
{/* Operations Feedback */}
{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",
formFeedback.kind === "ok"
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
@@ -471,6 +472,8 @@ export function ServerLuckPerms() {
type="button"
onClick={() => handleRemoveGroup(group)}
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"
>
<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="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")}
</Label>
<div className="flex gap-2">
<Input
id="lp-group-name"
placeholder={t("luckperms_custom_group_placeholder")}
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
@@ -500,7 +504,7 @@ export function ServerLuckPerms() {
onValueChange={(v) => setGroupNameInput(v)}
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>
</SelectTrigger>
<SelectContent className="border-border">
@@ -596,6 +600,8 @@ export function ServerLuckPerms() {
size="sm"
disabled={submitting}
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"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -640,7 +646,7 @@ export function ServerLuckPerms() {
onValueChange={(v) => setPermNodeInput(v)}
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>
</SelectTrigger>
<SelectContent className="border-border">
@@ -718,6 +724,7 @@ export function ServerLuckPerms() {
type="button"
onClick={clearHistory}
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
aria-label={t("luckperms_clear_history")}
title={t("luckperms_clear_history")}
>
<Trash2 className="h-3.5 w-3.5" />
+4 -3
View File
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { api, humanizeError, type SetupState } from "@/lib/api";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { InlineError } from "@/components/MessageLine";
// 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> —
@@ -107,7 +108,7 @@ export function Setup() {
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
<Card>
<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">
{t("setup_invalid_hint_prefix")}
<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}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<InlineError message={error} />
<Button type="submit" className="w-full" disabled={busy || !email.trim()}>
{busy ? (
<>
@@ -290,7 +291,7 @@ function PasskeyStep({
<Fingerprint className="h-4 w-4 text-primary" /> {t("setup_passkey_step")}
</div>
<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}>
{busy ? (
<>
+1
View File
@@ -307,6 +307,7 @@ export function ImageAdmin() {
onClick={() => handleRemove(img.image_ref)}
disabled={deletingRef === img.image_ref}
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")}
>
{deletingRef === img.image_ref ? (
+2 -2
View File
@@ -287,11 +287,11 @@ export function ImageBuildPage() {
</DialogHeader>
<form onSubmit={handleTrigger} className="space-y-4">
<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")}
</Label>
<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")} />
</SelectTrigger>
<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"
onClick={() => handleApprove(sub)}
disabled={!!busyId || !approveDigest}
aria-label={t("approve_btn")}
title={approveDigest ? t("approve_btn") : t("approve_needs_context")}
>
{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"
onClick={() => openRejectDialog(sub.id)}
disabled={!!busyId}
aria-label={t("reject_btn")}
title={t("reject_btn")}
>
{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"
onClick={() => setConfirmingDelete(sub.id)}
disabled={!!busyId}
aria-label={t("delete_submission_btn")}
title={t("delete_submission_btn")}
>
{isBusyDelete ? (
+25 -18
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { MessageLine } from "@/components/MessageLine";
import { MessageLine, InlineError } from "@/components/MessageLine";
import { RoleBadge } from "@/components/RoleBadge";
import { UserStatusBadge } from "@/components/UserStatusBadge";
import {
@@ -167,16 +167,18 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
</CardHeader>
<CardContent className="space-y-4">
<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
id="user-detail-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-9 text-sm"
/>
</div>
<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
id="user-detail-email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
@@ -185,9 +187,9 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
</div>
{!isSelf && (
<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)}>
<SelectTrigger className="h-9 text-sm">
<SelectTrigger id="user-detail-role" className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<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">
<div className="flex gap-2">
<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
id="user-detail-uuid"
value={newUUID}
onChange={(e) => setNewUUID(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
@@ -299,9 +302,9 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
/>
</div>
<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}>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger id="user-detail-link-source" className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -311,9 +314,7 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
</Select>
</div>
</div>
{err && (
<p className="text-xs text-destructive">{err}</p>
)}
<InlineError message={err} className="text-xs" />
<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" />}
{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"
disabled={unlinking === acc.mc_uuid}
onClick={() => setUnlinkDlg(acc.mc_uuid)}
aria-label={t("users_unlink_tooltip")}
title={t("users_unlink_tooltip")}
>
{unlinking === acc.mc_uuid ? (
@@ -429,8 +431,9 @@ function QuotasCard({ userId }: { userId: string }) {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<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
id="user-detail-quota-servers"
type="number"
value={maxServers}
onChange={(e) => setMaxServers(e.target.value)}
@@ -439,8 +442,9 @@ function QuotasCard({ userId }: { userId: string }) {
/>
</div>
<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
id="user-detail-quota-cpu"
type="number"
value={maxCpu}
onChange={(e) => setMaxCpu(e.target.value)}
@@ -449,8 +453,9 @@ function QuotasCard({ userId }: { userId: string }) {
/>
</div>
<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
id="user-detail-quota-memory"
type="number"
value={maxMem}
onChange={(e) => setMaxMem(e.target.value)}
@@ -459,8 +464,9 @@ function QuotasCard({ userId }: { userId: string }) {
/>
</div>
<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
id="user-detail-quota-storage"
type="number"
value={maxStorage}
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"
disabled={revoking === s.token_hash}
onClick={() => setRevokeOneDlg(s.token_hash)}
aria-label={t("users_session_revoke_one")}
title={t("users_session_revoke_one")}
>
{revoking === s.token_hash ? (
@@ -788,7 +795,7 @@ function DangerDialogs({
{user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")}
</DialogDescription>
</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"} />
</DialogContent>
</Dialog>
@@ -805,7 +812,7 @@ function DangerDialogs({
{t("users_danger_delete_dlg_desc")}
</DialogDescription>
</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")} />
</DialogContent>
</Dialog>
@@ -822,7 +829,7 @@ function DangerDialogs({
{t("users_danger_passkeys_dlg_desc")}
</DialogDescription>
</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")} />
</DialogContent>
</Dialog>
+2 -2
View File
@@ -81,7 +81,7 @@ export function UsersPage() {
/>
</div>
<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")} />
</SelectTrigger>
<SelectContent>
@@ -92,7 +92,7 @@ export function UsersPage() {
</SelectContent>
</Select>
<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")} />
</SelectTrigger>
<SelectContent>
+3 -2
View File
@@ -214,6 +214,7 @@ export function ServersPage() {
size="sm"
onClick={reload}
disabled={loading}
aria-label={t("fleet_refresh")}
title={t("fleet_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
@@ -233,7 +234,7 @@ export function ServersPage() {
) : (
<>
{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" />
{humanizeError(error)}
</div>
@@ -317,7 +318,7 @@ export function ServersPage() {
setPage(1);
}}
>
<SelectTrigger className="w-44">
<SelectTrigger className="w-44" aria-label={t("fleet_filter_phase")}>
<SelectValue />
</SelectTrigger>
<SelectContent>