feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1923 -1444

No files matched your search

+4 -6
View File
@@ -6,7 +6,6 @@ import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
import { Login } from "@/pages/Login";
import { ChangePassword } from "@/pages/ChangePassword";
import { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard";
import { ServersPage } from "@/pages/servers/ServersPage";
@@ -35,19 +34,18 @@ export default function App() {
<TierProvider>
<BrowserRouter>
<Routes>
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
{/* Pre-app sign-in surface (spec §B, passwordless). It sits OUTSIDE
RequireAuth — RequireAuth redirects here — and outside AppShell, so
they render their own centered chrome with no nav/tier dependency. */}
it renders its own centered chrome with no nav/tier dependency. */}
<Route path="/login" element={<Login />} />
<Route path="/change-password" element={<ChangePassword />} />
{/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth:
the visitor arrives from the `felis setup` link with no session, and
redeeming the one-time token is what mints one. */}
<Route path="/setup" element={<Setup />} />
{/* Everything else requires a session. RequireAuth gates the whole app:
no/expired session → /login, forced first-login change →
/change-password, transient /me failure → still renders (graded ZT). */}
no/expired session → /login, transient /me failure → still renders
(graded ZT). */}
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
{/* User-Side — app-tier */}
+65 -147
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus, Loader2, Copy, Check } from "lucide-react";
import { Plus, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -27,39 +27,24 @@ interface Props {
onCreated: (id: string) => void;
}
function generateRandomPassword(length = 16): string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$";
let password = "";
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
// Passwordless create (spec §B): the account is minted with no credential at all.
// The new user signs in with an in-game /link bind code (or email-OTP / passkey
// once their address is verified), so there is nothing to hand over here — on
// success we just jump to the new user's detail page.
export function CreateUserDialog({ onCreated }: Props) {
const { t } = useTranslation("admin");
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState<"user" | "admin">("user");
const [mustChange, setMustChange] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Success state fields
const [createdUser, setCreatedUser] = useState<any | null>(null);
const [generatedPassword, setGeneratedPassword] = useState("");
const [copied, setCopied] = useState(false);
function reset() {
setUsername("");
setEmail("");
setRole("user");
setMustChange(true);
setErr(null);
setCreatedUser(null);
setGeneratedPassword("");
setCopied(false);
}
async function handleSubmit(e: React.FormEvent) {
@@ -72,21 +57,19 @@ export function CreateUserDialog({ onCreated }: Props) {
return;
}
const genPassword = generateRandomPassword();
setSubmitting(true);
try {
const u = await api.createUser({
username: username.trim(),
email: email.trim() || undefined,
role,
password: genPassword,
must_change_password: mustChange,
});
setGeneratedPassword(genPassword);
setCreatedUser(u);
setOpen(false);
reset();
onCreated(u.id);
} catch (e: any) {
if (e && e.code === "already_exists") {
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
setErr(t("users_create_validation_username_taken"));
} else {
setErr(humanizeError(e));
}
@@ -95,27 +78,6 @@ export function CreateUserDialog({ onCreated }: Props) {
}
}
const handleCopy = async () => {
if (!createdUser) return;
const text = `Username: ${createdUser.username}\nPassword: ${generatedPassword}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
// ignore
}
};
const handleDone = () => {
const id = createdUser?.id;
setOpen(false);
reset();
if (id) {
onCreated(id);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
@@ -126,113 +88,69 @@ export function CreateUserDialog({ onCreated }: Props) {
</DialogTrigger>
<DialogContent className="sm:max-w-md" hideClose={submitting}>
<DialogHeader>
<DialogTitle>{createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")}</DialogTitle>
<DialogDescription>
{createdUser
? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。"
: t("users_create_desc")}
</DialogDescription>
<DialogTitle>{t("users_create_title")}</DialogTitle>
<DialogDescription>{t("users_create_desc")}</DialogDescription>
</DialogHeader>
{createdUser ? (
<div className="space-y-4">
<div className="rounded-md border border-border/50 bg-muted/20 p-4 space-y-3">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
<div className="font-mono text-sm font-semibold select-all">{createdUser.username}</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_password")}</Label>
<div className="font-mono text-sm font-semibold text-emerald-600 dark:text-emerald-400 select-all">
{generatedPassword}
</div>
</div>
</div>
<DialogFooter className="flex flex-row justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCopy} className="gap-1.5">
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
{copied ? t("common:copied") || "已复制" : t("common:copy") || "复制凭据"}
</Button>
<Button type="button" onClick={handleDone}>
{t("common:done") || "完成"}
</Button>
</DialogFooter>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_username")} *
</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("users_create_username_placeholder")}
className="h-9 text-sm"
autoFocus
/>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_username")} *
</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("users_create_username_placeholder")}
className="h-9 text-sm"
autoFocus
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_email")}
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="h-9 text-sm"
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_email")}
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="h-9 text-sm"
/>
</div>
{/* Role */}
<div className="space-y-1.5">
<Label 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">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("users_role_user")}</SelectItem>
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Role */}
<div className="space-y-1.5">
<Label 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">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("users_role_user")}</SelectItem>
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Must change password toggle */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={mustChange}
onChange={(e) => setMustChange(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm text-foreground">
{t("users_create_must_change")}
</span>
</label>
{err && <MessageLine kind="error" message={err} />}
{err && <MessageLine kind="error" message={err} />}
<DialogFooter>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("users_create_btn")}
</Button>
</DialogFooter>
</form>
)}
<DialogFooter>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("users_create_btn")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
}
+2 -2
View File
@@ -237,10 +237,10 @@ export function EditServerDialog({
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="es-cpu">CPU 限制</Label>
<Label htmlFor="es-cpu">{t("edit_server_cpu")}</Label>
<Input
id="es-cpu"
placeholder='例如 1, 2, 500m'
placeholder={t("edit_server_cpu_placeholder")}
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
/>
+1 -3
View File
@@ -8,7 +8,6 @@ import { useTier } from "@/lib/tier";
//
// loading → a full-screen spinner (never flash login during boot /me)
// unauthenticated → /login (a genuine 401: no/expired session)
// mustChangePassword → /change-password (forced first-login change)
// otherwise → render the app (<Outlet/>)
//
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
@@ -16,7 +15,7 @@ import { useTier } from "@/lib/tier";
// app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier();
const { loading, unauthenticated } = useTier();
const { t } = useTranslation("common");
if (loading) {
@@ -28,6 +27,5 @@ export function RequireAuth() {
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
if (mustChangePassword) return <Navigate to="/change-password" replace />;
return <Outlet />;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<div className="flex items-center gap-1.5 shrink-0 mr-1">
<span>
{running
? `${server.players ?? 0}${server.maxPlayers ? `/${server.maxPlayers}` : ""}`
? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}`
: "—"}
</span>
<Users className="h-3.5 w-3.5" />
+31 -5
View File
@@ -2,7 +2,7 @@
"title": "Account",
"subtitle": "Identity and Minecraft linking.",
"session": "Session",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by local password sign-in or the platform's identity proxy (Zero-Trust / Access).",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by a passkey / email sign-in or the platform's identity proxy (Zero-Trust / Access).",
"sign_out": "Sign out",
"signing_out": "Signing out…",
"minecraft_link": "Minecraft link",
@@ -21,7 +21,6 @@
"email_verification": "Email Verification",
"email_desc": "Verify your email address to secure your account.",
"email_verified": "Verified",
"email_unverified": "Unverified",
"send_code": "Send Code",
"sending_code": "Sending…",
"email_step1": "Enter Email Address",
@@ -31,16 +30,43 @@
"email_verify_btn": "Verify",
"email_verifying": "Verifying…",
"email_otp_sent": "Verification code sent.",
"otp_code_placeholder": "6-digit code",
"change_email": "Change email",
"continue_btn": "Continue",
"passkeys": "Passkeys",
"passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
"no_passkeys": "No registered passkeys.",
"loading_passkeys": "Loading passkeys…",
"add_passkey": "Add Passkey",
"passkey_name": "Device Nickname",
"passkey_name_placeholder": "e.g., My Phone, YubiKey",
"registering_passkey": "Registering…",
"delete_passkey": "Delete",
"deleting_passkey": "Deleting…",
"created_at": "Registered at: ",
"last_used": "Last used: ",
"never": "Never"
"never": "Never",
"migration": "Account migration",
"migration_desc": "Move everything a retired account owns onto this one. Migration starts in-game and finishes here.",
"account_id": "Account ID",
"migration_checking": "Checking migration status…",
"migration_none_prefix": "No migration in progress. To move this account's servers to another account, run ",
"migration_none_suffix": " in-game on the account being retired.",
"migration_confirm_title": "Confirm it's you",
"migration_confirm_desc": "Migration retires this account, so it needs a step-up check first.",
"migration_confirming": "Confirming…",
"migration_confirm_passkey_btn": "Confirm with passkey",
"migration_confirm_otp_btn": "Send a code to my email",
"migration_issue_title": "Name the destination account",
"migration_issue_desc": "Paste the Account ID shown on the destination account's own page here, then issue a one-time transfer code.",
"migration_target_placeholder": "Destination Account ID",
"migration_issuing": "Issuing…",
"migration_issue_btn": "Issue code",
"migration_code_title": "Transfer code (shown once)",
"migration_code_desc": "Sign in as the destination account and redeem this code there before it expires",
"migration_code_pending": "A transfer code has been issued. Redeem it from the destination account before it expires",
"migration_redeem_title": "Redeem a transfer code",
"migration_redeem_desc": "Received a code from an account being retired? Redeem it here to take over its servers.",
"migration_redeem_placeholder": "Transfer code",
"migration_redeeming": "Redeeming…",
"migration_redeem_btn": "Redeem",
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account."
}
+11 -32
View File
@@ -1,12 +1,8 @@
{
"title": "Admin",
"subtitle": "Server & content administration",
"signed_in_as": " · signed in as {{email}}",
"servers": "Servers",
"servers_desc": "Create platform servers from the structured form and manage the ones you operate.",
"images": "Images",
"images_desc": "The platform image whitelist — the value space the create form draws from.",
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
"images_title": "Images",
"images_subtitle": "The platform image whitelist. Only enabled images can back a new server. You can add external images or delete unwanted images from the whitelist.",
"add_image_title": "Add External Image",
@@ -26,21 +22,14 @@
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
"base_image_label": "Base Image",
"base_image_placeholder": "e.g. library/postgres:15",
"build_history_title": "Build History",
"view_logs_btn": "Logs",
"cancel_build_btn": "Cancel",
"no_builds_title": "No Builds Found",
"no_builds_hint": "You can trigger your first image build task using the form on the top right.",
"build_log_title": "Build Log Terminal",
"log_streaming": "Streaming...",
"log_finished": "Finished",
"no_images_title": "No images whitelisted",
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
"enabled": "enabled",
"disabled": "disabled",
"footer_kubectl": "kubectl / CRD operations",
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
"footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power.",
"table_ref": "Image Reference",
"table_source": "Source",
"table_status": "Status",
@@ -49,6 +38,8 @@
"table_requester": "Requester",
"table_created_at": "Created At",
"table_duration": "Duration",
"build_duration_seconds": "{{s}}s",
"build_duration_minutes": "{{m}}m {{s}}s",
"filter_all": "All",
"filter_enabled": "Enabled",
"filter_disabled": "Disabled",
@@ -74,9 +65,7 @@
"reject_reason": "Rejection Reason",
"updates_title": "Maintenance Window",
"updates_subtitle": "Configure the platform-wide maintenance window. A Scheduled auto-update component may only be applied by Felis within this window; outside it, updates are notify-only.",
"updates_current_title": "Current Setting",
"updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.",
"updates_current_set": "Felis may apply auto-updates between the following period:",
"updates_start_label": "Start Time",
"updates_end_label": "End Time",
"updates_set_title": "Configure Maintenance Window",
@@ -107,14 +96,10 @@
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
"users_create_btn": "Create User",
"users_create_title": "Create New User",
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
"users_create_success_title": "User Created Successfully",
"users_create_success_desc": "Please copy and save the initial credentials. Once you close this dialog, the initial password cannot be viewed again!",
"users_create_desc": "Create a new platform account. Accounts are passwordless — the user signs in with an in-game /link bind code, or with email verification / a passkey once bound.",
"users_create_username_placeholder": "e.g. alice",
"users_create_validation_username": "Username is required.",
"users_create_validation_username_taken": "This username is already taken.",
"users_create_validation_password": "Password must be at least 8 characters.",
"users_create_must_change": "Require password change on first login",
"users_search_placeholder": "Search username or email...",
"users_search_btn": "Search",
"users_filter_role_all": "All Roles",
@@ -130,7 +115,6 @@
"users_col_status": "Status",
"users_col_created": "Created",
"users_view_detail": "Details",
"users_total_count": "{{count}} total users",
"users_empty_title": "No Users Found",
"users_empty_hint": "No users match the current filters.",
"users_back_to_list": "Back to user list",
@@ -138,7 +122,6 @@
"users_field_username": "Username",
"users_field_email": "Email",
"users_field_role": "Role",
"users_field_password": "Initial Password",
"users_save_btn": "Save Changes",
"users_save_ok": "Changes saved successfully.",
"users_linked_accounts": "Linked Minecraft Accounts",
@@ -174,25 +157,21 @@
"users_danger_enable": "Enable User",
"users_danger_enable_desc": "Allow this user to log in again.",
"users_danger_enable_btn": "Enable",
"users_danger_reset_pw": "Reset Password",
"users_danger_reset_pw_desc": "A random password will be generated and the user will be forced to change it on next login. All active sessions are revoked immediately.",
"users_danger_reset_pw_desc_email": "A random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All active sessions are revoked immediately.",
"users_danger_reset_pw_btn": "Reset Password",
"users_danger_reset_pw_confirm": "Reset Password",
"users_pw_reset_ok": "Password reset. The new password was sent to {{email}}.",
"users_pw_reset_ok_no_email": "Password reset. Since this user has no email address, it was logged server-side.",
"users_danger_disable_dlg_title": "Disable User",
"users_danger_disable_dlg_desc": "This user will be unable to log in. All active sessions will be revoked immediately.",
"users_danger_enable_dlg_title": "Enable User",
"users_danger_enable_dlg_desc": "This user will be able to log in again.",
"users_danger_reset_pw_dlg_title": "Reset Password",
"users_danger_reset_pw_dlg_desc_email": "A random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All existing sessions will be revoked.",
"users_danger_reset_pw_dlg_desc_no_email": "A random password will be generated. Since this user has no email address, it will be logged server-side. The user will be forced to change it on next login. All existing sessions will be revoked.",
"users_danger_delete_dlg_title": "Delete User",
"users_danger_delete_dlg_desc": "This action is permanent. The user's owned servers will be released, all sessions revoked, and the account permanently disabled. This cannot be undone through the panel.",
"users_danger_delete": "Delete User",
"users_danger_delete_desc": "Soft-delete this user. Owned servers are released, all sessions are revoked, and the account is permanently disabled. This action cannot be undone through the panel.",
"users_danger_delete_btn": "Delete User",
"users_danger_delete_confirm": "This action is permanent. The user's servers will be released and their sessions revoked. Are you absolutely sure?",
"users_danger_delete_yes": "Yes, Delete Permanently"
"users_danger_delete_yes": "Yes, Delete Permanently",
"add_image_desc": "Register an external Docker image reference on the whitelist for later server creation.",
"images_search_placeholder": "Search image name or source...",
"search_no_results": "No matches",
"search_no_results_hint": "Try a different search term or filter.",
"submissions_search_placeholder": "Search modpack name or submitter...",
"trigger_build_desc": "Enter the build parameters to launch a Kaniko pipeline job in an isolated namespace.",
"builds_search_placeholder": "Search build ID, image reference, or status..."
}
+12 -20
View File
@@ -1,18 +1,12 @@
{
"login_title": "Sign in to Felis",
"login_subtitle": "Operator console",
"username": "Username or Email",
"password": "Password",
"sign_in": "Sign in",
"login_subtitle": "Player console",
"login_subtitle_op": "Operator console",
"signing_in": "Signing in…",
"tab_password": "Password",
"tab_bind": "Bind Code",
"tab_email": "Email OTP",
"other_login_methods": "Other sign-in options",
"or_divider": "or",
"tab_email_btn": "Sign in with Email OTP",
"tab_bind_btn": "Sign in with Bind Code",
"back_to_password": "Back to password login",
"tab_op_btn": "Operator sign-in",
"back_to_login": "Back to sign-in options",
"email_address": "Email Address",
"email_placeholder": "Enter your registered email",
"otp_code": "Verification Code",
@@ -23,20 +17,18 @@
"otp_btn": "Verify & Sign In",
"resend_in": "s",
"passkey_btn": "Sign in with Passkey",
"passkey_email_hint": "Enter your registered email above to use a passkey, or leave it empty to sign in with a discoverable passkey.",
"bind_code": "Bind Code",
"bind_code_placeholder": "e.g., ABCD2345",
"bind_hint": "Type /login in-game to generate a one-time bind code.",
"bind_hint": "Type /link in-game to generate a one-time bind code.",
"bind_btn": "Verify & Sign In",
"binding": "Verifying…",
"change_password_title": "Set a new password",
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
"change_password_subtitle_voluntary": "Update your console password.",
"current_password": "Current password",
"new_password": "New password",
"confirm_new_password": "Confirm new password",
"password_mismatch": "Passwords don't match.",
"password_min_length": "At least {{min}} characters.",
"change_password_btn": "Change password",
"op_hint": "Staff only: a code is emailed to you, and an online operator must approve the request in-game before you can sign in.",
"op_start_btn": "Request operator sign-in",
"op_approve_hint": "A code has been emailed to you. Ask an online operator to approve this request in-game:",
"op_waiting": "Waiting for in-game approval…",
"op_approved": "Approved — enter the code from your email.",
"op_restart": "Start over",
"saving": "Saving…",
"setup_title": "Set up your account",
"setup_welcome": "Welcome, {{name}}",
@@ -5,8 +5,6 @@
"not_yours_title": "No permission to access backups",
"not_yours_body": "Only the owner or an admin can view and restore this server's backups.",
"latest_title": "Latest backup",
"latest_note": "Default restore target. You can also select and restore an older backup from the history below.",
"history_title": "Backup history",
"history_note": "Historical backups can be used for restore before they expire. They are automatically cleaned up when they expire.",
"reason_inactive": "Idle archive",
"reason_manual": "Manual backup",
@@ -29,7 +27,6 @@
"expired_cannot_restore": "This backup has expired and can no longer be restored.",
"col_created": "Backup Time",
"col_size": "Size",
"col_reason": "Type / Reason",
"col_expires": "Expires",
"col_owner": "Former Owner",
"col_actions": "Actions"
+1 -4
View File
@@ -15,10 +15,7 @@
"loading_config": "Loading config…",
"brand_name": "Felis",
"brand_tagline": "K8s-native Minecraft orchestration",
"page_title": "Felis · Control Panel",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "Switch to {{lang}}",
"page_title": "Felis · Console",
"toggle_theme": "Toggle theme",
"pagination_prev": "Previous",
"pagination_next": "Next",
@@ -1,20 +1,16 @@
{
"title": "Dashboard",
"subtitle": "Your fleet at a glance.",
"no_servers_title": "No servers yet",
"no_servers_hint": "Create your first server or claim an unowned one.",
"go_to_my_servers": "Go to My servers",
"stat_servers": "Servers",
"stat_running": "Running",
"stat_players_online": "Players online",
"fleet": "Fleet",
"manage": "Manage",
"loading_scene": "Loading scene…",
"preparing_scene": "Preparing scene…",
"fleet_load": "Fleet Load & Health",
"active_load": "Player Load Rate",
"status_distribution": "Node Status Distribution",
"account_status": "Account Status",
"account_linked_title": "Account Linked",
"account_linked_desc": "Your identity is bound to a Minecraft UUID. You have full server ownership and wake permissions.",
"account_unlinked_title": "Account Unlinked",
+3 -5
View File
@@ -1,10 +1,8 @@
{
"local_auth_disabled": "Password sign-in is turned off here — reach this console through your organization's secure access.",
"invalid_credentials": "Incorrect username or password.",
"weak_password": "Pick a password between 8 and 72 characters.",
"password_unchanged": "Your new password must differ from the current one.",
"local_auth_disabled": "Direct sign-in is turned off here — reach this console through your organization's secure access.",
"staff_account": "This is a staff account — use Operator sign-in instead.",
"not_linked": "Link your Minecraft account before claiming (Account → Link).",
"invalid_code": "That link code is invalid or expired — run /link again in-game for a fresh code.",
"invalid_code": "That code is invalid or expired — request a fresh one and try again.",
"already_linked": "That Minecraft account is already linked to another user.",
"quota_exceeded": "You have reached your server quota.",
"already_claimed": "Someone else just claimed this server.",
-5
View File
@@ -1,11 +1,6 @@
{
"title": "SysAdmin",
"subtitle": "Platform observability cockpit — a window, not a lever.",
"cluster_wide_fleet": "Cluster-wide fleet",
"open_fleet_table": "Open fleet table →",
"footer_pre": "Control-plane scaling, RBAC, Secrets and cluster lifecycle are ",
"footer_kubectl": "kubectl / CRD operations",
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
"fleet_title": "Fleet",
"fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.",
"fleet_live": "Live",
+8 -35
View File
@@ -5,11 +5,8 @@
"filter_status_all": "All statuses",
"search_no_match": "No matching servers found.",
"search_clear_btn": "Clear filters",
"servers_count": "{{count}} servers",
"servers_count_filtered": "Showing {{shown}} / {{total}} servers",
"no_servers_linked": "No servers linked to you",
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
"my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here.",
"phase_running": "Running",
"phase_starting": "Starting",
"phase_stopping": "Stopping",
@@ -35,7 +32,6 @@
"console_offline_title": "Console is offline",
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
"my_servers_breadcrumb": "My servers",
"console_card_title": "Console",
"command_placeholder": "Type a command… e.g. list",
"log_connecting": "Connecting…",
"log_live": "Live",
@@ -74,7 +70,6 @@
"access_whitelist_empty": "No players on the whitelist yet.",
"access_whitelist_empty_hint": "Add a player above to let them join.",
"access_whitelist_remove": "Remove {{player}} from the whitelist",
"access_whitelist_remove_q": "Remove?",
"access_remove": "Remove",
"access_whitelist_load_error": "Couldn't load the whitelist.",
"access_whitelist_added": "Added {{player}} to the whitelist.",
@@ -89,14 +84,11 @@
"access_online_raw": "View raw server reply",
"access_updated_at": "Updated {{time}}",
"access_kick_btn": "Kick",
"access_kick_q": "Kick?",
"access_ban_q": "Ban & block rejoin?",
"access_kicked": "Kicked {{player}}.",
"access_ban_title": "Bans",
"access_ban_desc": "Banning kicks a player and blocks them from rejoining. Expand to view the current ban list and pardon in one tap, or enter a full player ID to ban directly.",
"access_ban_btn": "Ban",
"access_pardon_btn": "Pardon",
"access_pardon_q": "Pardon & allow rejoin?",
"access_ban_confirm": "Ban {{player}}? They'll be kicked and blocked from rejoining.",
"access_ban_confirm_yes": "Ban",
"access_ban_empty": "No banned players.",
@@ -129,41 +121,20 @@
"create_server_policy_allowlist": "Allowlist — listed players wake it",
"create_server_cancel": "Cancel",
"create_server_submit": "Create",
"create_server_creating": "Creating…",
"no_servers_managed": "No servers under your management yet",
"no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.",
"server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here.",
"edit_server_title": "Edit Server Config",
"edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU",
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
"edit_server_unchanged": "Leave unchanged",
"edit_server_immutable": "Unchanged (Immutable)",
"edit_server_submit": "Save Config",
"edit_server_updating": "Saving…",
"luckperms_dialog_title": "LuckPerms Permissions",
"luckperms_dialog_desc": "Set or unset LuckPerms permission nodes and parent groups on the running server (requires the LuckPerms plugin to be active).",
"luckperms_tab_group": "Groups",
"luckperms_tab_permission": "Permissions",
"luckperms_player_name": "Player Username",
"luckperms_group_name": "Group Name",
"luckperms_node": "Permission Node",
"luckperms_action": "Action",
"luckperms_action_add": "Add to Group (add)",
"luckperms_action_remove": "Remove from Group (remove)",
"luckperms_action_set": "Set Permission (set)",
"luckperms_action_unset": "Unset Permission (unset)",
"luckperms_value": "Value",
"luckperms_value_grant": "Grant (True)",
"luckperms_value_deny": "Deny (False)",
"luckperms_world": "World Context (Optional)",
"luckperms_confirm": "Apply Changes",
"luckperms_executing": "Executing command…",
"luckperms_success": "LuckPerms command executed successfully:",
"luckperms_error_invalid_player": "Invalid player name (1–16 chars: letters, digits, underscore)",
"luckperms_error_invalid_node": "Invalid permission node (allowed: letters, digits, ., -, _, *, 1-64 chars)",
"luckperms_error_invalid_group": "Invalid group name (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_error_invalid_world": "Invalid world context (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_title": "LuckPerms Permissions",
"luckperms_desc": "Manage player permission nodes and parent groups on this server (requires the LuckPerms plugin to be active).",
"luckperms_back_to_console": "Back to console",
"luckperms_select_player_prompt": "Please select an online player from the list, or enter a username to query.",
@@ -175,18 +146,20 @@
"luckperms_value_column": "Value",
"luckperms_world_column": "World",
"luckperms_actions_column": "Actions",
"luckperms_query_btn": "Query Player",
"luckperms_custom_group_placeholder": "Enter custom group name...",
"luckperms_batch_players": "Player Usernames List",
"luckperms_batch_players_placeholder": "Enter player names, support multiple (separated by commas or spaces)",
"luckperms_recent_actions": "Recent Actions History",
"luckperms_no_recent_actions": "No recent actions.",
"luckperms_revert": "Revert",
"luckperms_reverting": "Reverting...",
"luckperms_revert_success": "Action reverted successfully!",
"luckperms_quick_presets": "Common Presets",
"luckperms_presets": "Presets",
"luckperms_batch_status": "Batch Progress",
"luckperms_success_count": "Success: {{count}}",
"luckperms_failed_count": "Failed: {{count}}"
"luckperms_no_parent_groups": "No parent groups assigned",
"luckperms_global": "global",
"luckperms_no_perms": "No explicit permission nodes assigned",
"luckperms_rcon_output": "RCON Console Output",
"luckperms_clear_history": "Clear history",
"edit_server_cpu": "CPU Limit",
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
"owned_filter_mine": "me"
}
@@ -7,12 +7,9 @@
"display_name_placeholder": "e.g., Pixelmon Adventure Pack",
"file_label": "Build Context (.tar.gz)",
"file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse",
"file_selected": "Selected file: {{name}} ({{size}})",
"submit_btn": "Submit",
"submitting_create": "Creating submission...",
"submitting_upload": "Uploading build context...",
"submit_success": "Modpack submitted successfully!",
"list_card_title": "Submission History",
"filter_all": "All Statuses",
"status_pending_review": "Pending Review",
"status_approved": "Approved",
@@ -21,7 +18,6 @@
"table_status": "Status",
"table_created_at": "Submitted At",
"table_reviewed_by": "Reviewed By",
"table_image_ref": "Image Reference",
"table_reject_reason": "Reject Reason",
"no_submissions_title": "No Submissions Found",
"no_submissions_hint": "You haven't submitted any modpacks yet.",
@@ -29,5 +25,9 @@
"error_file_type": "Please upload a valid .tar.gz file.",
"error_file_size": "File exceeds the allowed size limit.",
"error_name_required": "Display name is required.",
"error_file_required": "Build context file is required."
"error_file_required": "Build context file is required.",
"field_context_ref": "Context Reference",
"field_image_ref": "Image Reference",
"clear_btn": "Clear",
"file_hint": "Supports .tar.gz (max 1GB)"
}
+34 -8
View File
@@ -2,7 +2,7 @@
"title": "账户",
"subtitle": "身份验证与 Minecraft 关联。",
"session": "会话",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发。",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由 Passkey / 邮箱登录还是平台身份代理(Zero-Trust / Access)签发。",
"sign_out": "退出登录",
"signing_out": "退出中…",
"minecraft_link": "Minecraft 关联",
@@ -10,18 +10,17 @@
"linked_title": "Minecraft 账户已关联。",
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、启动、停止)已解锁。",
"uuid_label": "UUID",
"step1_title": "在游戏中获取验证码",
"step1_title": "在游戏中获取绑定码",
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性绑定码(约 10 分钟内有效)。",
"step2_title": "在此输入",
"link_code": "关联码",
"link_code": "绑定码",
"link_code_placeholder": "ABCD2345",
"verify_btn": "关联",
"verifying": "验证中…",
"email_verification": "邮箱验证",
"email_desc": "验证你的电子邮箱以确保账号安全。",
"email_verified": "已验证",
"email_unverified": "未验证",
"send_code": "获取验证码",
"sending_code": "发送中…",
"email_step1": "输入电子邮箱",
@@ -31,16 +30,43 @@
"email_verify_btn": "验证",
"email_verifying": "验证中…",
"email_otp_sent": "验证码已发送。",
"otp_code_placeholder": "6 位验证码",
"change_email": "修改邮箱",
"continue_btn": "继续",
"passkeys": "Passkey 注册管理",
"passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
"no_passkeys": "未绑定任何 Passkey。",
"loading_passkeys": "加载 Passkey 列表中…",
"add_passkey": "注册新 Passkey",
"passkey_name": "设备昵称",
"passkey_name_placeholder": "例如:我的手机, YubiKey",
"registering_passkey": "注册中…",
"delete_passkey": "删除",
"deleting_passkey": "删除中…",
"created_at": "注册时间:",
"last_used": "上次使用:",
"never": "从未"
"never": "从未",
"migration": "账户迁移",
"migration_desc": "将被弃用账户名下的所有服务器转移到本账户。迁移在游戏内发起,在此完成。",
"account_id": "账户 ID",
"migration_checking": "正在检查迁移状态…",
"migration_none_prefix": "当前没有进行中的迁移。若要将本账户的服务器转移到其他账户,请用被弃用的账户在游戏内输入 ",
"migration_none_suffix": " 。",
"migration_confirm_title": "确认身份",
"migration_confirm_desc": "迁移会停用本账户,因此需要先完成一次身份核验。",
"migration_confirming": "确认中…",
"migration_confirm_passkey_btn": "使用 Passkey 确认",
"migration_confirm_otp_btn": "发送验证码到我的邮箱",
"migration_issue_title": "指定目标账户",
"migration_issue_desc": "将目标账户本页面显示的「账户 ID」粘贴到此处,然后签发一次性转移码。",
"migration_target_placeholder": "目标账户 ID",
"migration_issuing": "签发中…",
"migration_issue_btn": "签发转移码",
"migration_code_title": "转移码(仅显示一次)",
"migration_code_desc": "请在过期前登录目标账户并在其页面兑换此码",
"migration_code_pending": "转移码已签发。请在过期前用目标账户完成兑换",
"migration_redeem_title": "兑换转移码",
"migration_redeem_desc": "收到了被弃用账户的转移码?在此兑换即可接管其服务器。",
"migration_redeem_placeholder": "转移码",
"migration_redeeming": "兑换中…",
"migration_redeem_btn": "兑换",
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。"
}
+11 -32
View File
@@ -1,12 +1,8 @@
{
"title": "管理",
"subtitle": "服务器与内容管理",
"signed_in_as": " · 当前登录:{{email}}",
"servers": "服务器",
"servers_desc": "通过结构化表单创建并管理平台服务器。",
"images": "镜像",
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
"images_title": "镜像",
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。",
"add_image_title": "添加外部镜像",
@@ -26,21 +22,14 @@
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
"base_image_label": "基础镜像",
"base_image_placeholder": "例如: library/postgres:15",
"build_history_title": "构建历史",
"view_logs_btn": "日志",
"cancel_build_btn": "取消",
"no_builds_title": "暂无构建任务",
"no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。",
"build_log_title": "构建日志终端",
"log_streaming": "实时输出中",
"log_finished": "已结束",
"no_images_title": "无白名单镜像",
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
"enabled": "已启用",
"disabled": "已禁用",
"footer_kubectl": "kubectl / CRD",
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。",
"table_ref": "镜像名称",
"table_source": "来源",
"table_status": "状态",
@@ -49,6 +38,8 @@
"table_requester": "发起人",
"table_created_at": "创建时间",
"table_duration": "耗时",
"build_duration_seconds": "{{s}}秒",
"build_duration_minutes": "{{m}}分{{s}}秒",
"filter_all": "全部",
"filter_enabled": "已启用",
"filter_disabled": "已禁用",
@@ -74,9 +65,7 @@
"reject_reason": "驳回理由",
"updates_title": "维护窗口",
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
"updates_current_title": "当前设置",
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
"updates_current_set": "Felis 可在以下时间段内自动执行更新:",
"updates_start_label": "开始时间",
"updates_end_label": "结束时间",
"updates_set_title": "配置维护窗口",
@@ -107,14 +96,10 @@
"users_subtitle": "管理平台用户账号、配额和会话。",
"users_create_btn": "创建用户",
"users_create_title": "创建新用户",
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
"users_create_success_title": "用户创建成功",
"users_create_success_desc": "请复制并妥善保管该用户的初始凭据。关闭此对话框后,此初始密码将无法再次查看!",
"users_create_desc": "创建一个新的平台账号。账号无密码——用户通过游戏内 /link 绑定码登录,绑定后也可使用邮箱验证码 / Passkey 登录。",
"users_create_username_placeholder": "例如: alice",
"users_create_validation_username": "用户名为必填项。",
"users_create_validation_username_taken": "该用户名已被使用。",
"users_create_validation_password": "密码至少需要 8 个字符。",
"users_create_must_change": "要求首次登录修改密码",
"users_search_placeholder": "搜索用户名或邮箱...",
"users_search_btn": "搜索",
"users_filter_role_all": "全部角色",
@@ -130,7 +115,6 @@
"users_col_status": "状态",
"users_col_created": "创建时间",
"users_view_detail": "详情",
"users_total_count": "共 {{count}} 个用户",
"users_empty_title": "未找到用户",
"users_empty_hint": "没有匹配当前筛选条件的用户。",
"users_back_to_list": "返回用户列表",
@@ -138,7 +122,6 @@
"users_field_username": "用户名",
"users_field_email": "邮箱",
"users_field_role": "角色",
"users_field_password": "初始密码",
"users_save_btn": "保存更改",
"users_save_ok": "更改保存成功。",
"users_linked_accounts": "已关联的 Minecraft 账号",
@@ -174,25 +157,21 @@
"users_danger_enable": "启用用户",
"users_danger_enable_desc": "允许此用户重新登录。",
"users_danger_enable_btn": "启用",
"users_danger_reset_pw": "重置密码",
"users_danger_reset_pw_desc": "将生成随机密码,用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
"users_danger_reset_pw_desc_email": "将生成随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
"users_danger_reset_pw_btn": "重置密码",
"users_danger_reset_pw_confirm": "确认重置",
"users_pw_reset_ok": "密码已重置,新密码已发送至 {{email}}。",
"users_pw_reset_ok_no_email": "密码已重置。该用户未设置邮箱,新密码已记录在服务端日志中。",
"users_danger_disable_dlg_title": "禁用用户",
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
"users_danger_enable_dlg_title": "启用用户",
"users_danger_enable_dlg_desc": "此用户将可以重新登录。",
"users_danger_reset_pw_dlg_title": "重置密码",
"users_danger_reset_pw_dlg_desc_email": "将生成随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
"users_danger_reset_pw_dlg_desc_no_email": "将生成随机密码。由于此用户未设置邮箱地址,密码将记录在服务端日志中。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
"users_danger_delete_dlg_title": "删除用户",
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
"users_danger_delete": "删除用户",
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
"users_danger_delete_btn": "删除用户",
"users_danger_delete_confirm": "此操作不可逆。该用户的服务器将被释放,会话将被撤销。确定要执行吗?",
"users_danger_delete_yes": "是的,永久删除"
"users_danger_delete_yes": "是的,永久删除",
"add_image_desc": "将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。",
"images_search_placeholder": "搜索镜像名称或来源...",
"search_no_results": "无匹配结果",
"search_no_results_hint": "尝试更换搜索词或筛选条件。",
"submissions_search_placeholder": "搜索模组包名称或提交人...",
"trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。",
"builds_search_placeholder": "搜索构建 ID、镜像引用或状态..."
}
+12 -20
View File
@@ -1,18 +1,12 @@
{
"login_title": "登录 Felis",
"login_subtitle": "运维控制台",
"username": "用户名或邮箱",
"password": "密码",
"sign_in": "登录",
"login_subtitle": "玩家控制台",
"login_subtitle_op": "运维控制台",
"signing_in": "登录中…",
"tab_password": "账号密码",
"tab_bind": "游戏绑定码",
"tab_email": "邮箱验证码",
"other_login_methods": "其他登录方式",
"or_divider": "或",
"tab_email_btn": "使用邮箱验证码登录",
"tab_bind_btn": "使用游戏绑定码登录",
"back_to_password": "返回密码登录",
"tab_op_btn": "管理员登录",
"back_to_login": "返回其他登录方式",
"email_address": "邮箱地址",
"email_placeholder": "请输入绑定的邮箱",
"otp_code": "验证码",
@@ -23,20 +17,18 @@
"otp_btn": "验证并登录",
"resend_in": "秒后重试",
"passkey_btn": "使用 Passkey 登录",
"passkey_email_hint": "使用 Passkey 请在上方输入绑定邮箱,或留空直接免密登录。",
"bind_code": "绑定码",
"bind_code_placeholder": "例如:ABCD2345",
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
"bind_hint": "在游戏内输入 /link 即可获取一次性绑定码",
"bind_btn": "验证并登录",
"binding": "验证中…",
"change_password_title": "设置新密码",
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
"change_password_subtitle_voluntary": "修改控制台登录密码。",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"password_mismatch": "两次输入的密码不一致。",
"password_min_length": "密码至少 {{min}} 个字符。",
"change_password_btn": "修改密码",
"op_hint": "仅限管理员:验证码将发送至您的邮箱,且需要一位在线管理员在游戏内批准此次登录。",
"op_start_btn": "发起管理员登录",
"op_approve_hint": "验证码已发送至您的邮箱。请让一位在线管理员在游戏内批准此次请求:",
"op_waiting": "等待游戏内批准…",
"op_approved": "已批准——请输入邮件中的验证码。",
"op_restart": "重新开始",
"saving": "保存中…",
"setup_title": "初始化你的账户",
"setup_welcome": "欢迎,{{name}}",
@@ -5,8 +5,6 @@
"not_yours_title": "无权访问备份",
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
"latest_title": "最新备份",
"latest_note": "默认的恢复目标。你也可以从下方的历史备份中选择更早的备份进行恢复。",
"history_title": "历史备份",
"history_note": "历史备份在过期前均可用于恢复。到期后系统会自动清理,无需手动删除或管理。",
"reason_inactive": "闲置自动回收",
"reason_manual": "手动备份",
@@ -29,7 +27,6 @@
"expired_cannot_restore": "此备份已过期,无法恢复。",
"col_created": "创建时间",
"col_size": "大小",
"col_reason": "类型 / 原因",
"col_expires": "过期时间",
"col_owner": "原所有者",
"col_actions": "操作"
+1 -4
View File
@@ -15,10 +15,7 @@
"loading_config": "正在加载配置…",
"brand_name": "Felis",
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
"page_title": "Felis · 控制面板",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "切换至 {{lang}}",
"page_title": "Felis · 控制台",
"toggle_theme": "切换主题",
"pagination_prev": "上一页",
"pagination_next": "下一页",
@@ -1,20 +1,16 @@
{
"title": "仪表盘",
"subtitle": "服务器运行状态一览。",
"no_servers_title": "暂无服务器",
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
"go_to_my_servers": "前往我的服务器",
"stat_servers": "服务器",
"stat_running": "运行中",
"stat_players_online": "在线玩家",
"fleet": "服务器概览",
"manage": "管理",
"loading_scene": "正在加载 3D 场景…",
"preparing_scene": "正在准备场景…",
"fleet_load": "负载与健康度",
"active_load": "玩家在线负载",
"status_distribution": "节点状态分布",
"account_status": "账号绑定状态",
"account_linked_title": "已关联游戏身份",
"account_linked_desc": "您的 Web 身份已成功绑定至 Minecraft 角色。拥有完整的所有权认领及启动权限。",
"account_unlinked_title": "未关联游戏角色",
+3 -5
View File
@@ -1,10 +1,8 @@
{
"local_auth_disabled": "当前部署已禁用本地密码登录,请通过组织的安全入口访问控制台。",
"invalid_credentials": "用户名或密码错误。",
"weak_password": "密码长度需在 8 到 72 个字符之间。",
"password_unchanged": "新密码不可与当前密码相同。",
"local_auth_disabled": "当前部署已禁用本地登录,请通过组织的安全入口访问控制台。",
"staff_account": "该账户为管理员账户——请使用管理员登录。",
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
"invalid_code": "关联码无效或已过期——请在游戏中重新输入 /link 获取新码。",
"invalid_code": "代码无效或已过期——请重新获取后再试。",
"already_linked": "该 Minecraft 账户已关联至其他用户。",
"quota_exceeded": "服务器数量已达配额上限。",
"already_claimed": "该服务器已被他人抢先认领。",
-5
View File
@@ -1,11 +1,6 @@
{
"title": "系统管理",
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
"cluster_wide_fleet": "全平台服务器",
"open_fleet_table": "查看全平台服务器列表 →",
"footer_pre": "控制面扩缩、RBAC、Secrets 及集群生命周期属于 ",
"footer_kubectl": "kubectl / CRD",
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
"fleet_title": "全平台服务器",
"fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。",
"fleet_live": "实时刷新",
+8 -35
View File
@@ -5,11 +5,8 @@
"filter_status_all": "全部状态",
"search_no_match": "没有匹配的服务器。",
"search_clear_btn": "清除筛选",
"servers_count": "共 {{count}} 台",
"servers_count_filtered": "显示 {{shown}} / {{total}} 台",
"no_servers_linked": "暂无关联的服务器",
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此。",
"phase_running": "运行中",
"phase_starting": "启动中",
"phase_stopping": "停止中",
@@ -35,7 +32,6 @@
"console_offline_title": "控制台未连接",
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
"my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台",
"command_placeholder": "输入命令…如 list",
"log_connecting": "连接中…",
"log_live": "实时",
@@ -74,7 +70,6 @@
"access_whitelist_empty": "白名单暂无玩家。",
"access_whitelist_empty_hint": "在上方添加玩家即可放行进服。",
"access_whitelist_remove": "将 {{player}} 移出白名单",
"access_whitelist_remove_q": "移除?",
"access_remove": "移除",
"access_whitelist_load_error": "无法加载白名单。",
"access_whitelist_added": "已将 {{player}} 加入白名单。",
@@ -89,14 +84,11 @@
"access_online_raw": "查看服务器原始返回",
"access_updated_at": "更新于 {{time}}",
"access_kick_btn": "踢出",
"access_kick_q": "踢出?",
"access_ban_q": "封禁并禁止再进?",
"access_kicked": "已踢出 {{player}}。",
"access_ban_title": "封禁",
"access_ban_desc": "封禁会将玩家踢出并禁止再次进入。展开可查看当前封禁名单并一键解封,也可输入完整玩家 ID 直接封禁。",
"access_ban_btn": "封禁",
"access_pardon_btn": "解封",
"access_pardon_q": "解封并允许再进?",
"access_ban_confirm": "确认封禁 {{player}}?此玩家将被踢出并无法再进入。",
"access_ban_confirm_yes": "确认封禁",
"access_ban_empty": "暂无封禁玩家。",
@@ -129,41 +121,20 @@
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
"create_server_cancel": "取消",
"create_server_submit": "创建",
"create_server_creating": "创建中…",
"no_servers_managed": "你还没有管理的服务器",
"no_servers_managed_hint": "使用「新建服务器」在线分配。",
"server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达。",
"edit_server_title": "编辑服务器配置",
"edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU",
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
"edit_server_unchanged": "保持不变",
"edit_server_immutable": "保持不变 (不可修改)",
"edit_server_submit": "保存配置",
"edit_server_updating": "正在保存…",
"luckperms_dialog_title": "LuckPerms 权限管理",
"luckperms_dialog_desc": "在运行中的服务器上设置或取消玩家的权限节点与用户组(要求 LuckPerms 插件处于运行状态)。",
"luckperms_tab_group": "用户组管理",
"luckperms_tab_permission": "细粒度权限",
"luckperms_player_name": "玩家用户名",
"luckperms_group_name": "用户组名称",
"luckperms_node": "权限节点",
"luckperms_action": "操作类型",
"luckperms_action_add": "添加至用户组 (add)",
"luckperms_action_remove": "从用户组移除 (remove)",
"luckperms_action_set": "设置权限节点 (set)",
"luckperms_action_unset": "取消权限节点 (unset)",
"luckperms_value": "权限值 (Value)",
"luckperms_value_grant": "允许 (True)",
"luckperms_value_deny": "拒绝 (False)",
"luckperms_world": "世界范围 context (可选)",
"luckperms_confirm": "执行变更",
"luckperms_executing": "正在执行命令…",
"luckperms_success": "LuckPerms 命令成功执行:",
"luckperms_error_invalid_player": "玩家用户名格式错误 (支持1-16位英文字母、数字和下划线)",
"luckperms_error_invalid_node": "权限节点格式错误 (支持1-64位字母、数字、点号、横线、下划线及通配符*)",
"luckperms_error_invalid_group": "用户组名称格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_error_invalid_world": "世界范围格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_title": "LuckPerms 权限管理",
"luckperms_desc": "精细化管理服务器上玩家的权限节点与用户组(需要 LuckPerms 插件处于运行状态)。",
"luckperms_back_to_console": "返回控制台",
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
@@ -175,18 +146,20 @@
"luckperms_value_column": "状态值",
"luckperms_world_column": "生效世界",
"luckperms_actions_column": "操作",
"luckperms_query_btn": "查询玩家",
"luckperms_custom_group_placeholder": "输入自定义组名...",
"luckperms_batch_players": "玩家用户名列表",
"luckperms_batch_players_placeholder": "输入玩家用户名,支持输入多个(用英文逗号或空格分隔)",
"luckperms_recent_actions": "最近操作历史",
"luckperms_no_recent_actions": "暂无最近操作历史。",
"luckperms_revert": "撤销",
"luckperms_reverting": "正在撤销...",
"luckperms_revert_success": "已成功撤销该操作!",
"luckperms_quick_presets": "常用快速预设",
"luckperms_presets": "预设",
"luckperms_batch_status": "批量执行进度",
"luckperms_success_count": "成功: {{count}}",
"luckperms_failed_count": "失败: {{count}}"
"luckperms_no_parent_groups": "未分配任何父组",
"luckperms_global": "全局",
"luckperms_no_perms": "尚未分配任何权限节点",
"luckperms_rcon_output": "RCON 控制台输出",
"luckperms_clear_history": "清除历史记录",
"edit_server_cpu": "CPU 限制",
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
"owned_filter_mine": "我"
}
@@ -7,12 +7,9 @@
"display_name_placeholder": "例如:Pixelmon 冒险包",
"file_label": "构建上下文 (.tar.gz)",
"file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件",
"file_selected": "已选择文件: {{name}} ({{size}})",
"submit_btn": "提交",
"submitting_create": "正在创建提交...",
"submitting_upload": "正在上传构建上下文...",
"submit_success": "模组包提交成功!",
"list_card_title": "提交历史",
"filter_all": "全部状态",
"status_pending_review": "等待审核",
"status_approved": "审核通过",
@@ -21,7 +18,6 @@
"table_status": "状态",
"table_created_at": "提交时间",
"table_reviewed_by": "审核人",
"table_image_ref": "镜像引用",
"table_reject_reason": "拒绝原因",
"no_submissions_title": "暂无提交记录",
"no_submissions_hint": "您还没有提交过任何模组包。",
@@ -29,5 +25,9 @@
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
"error_file_size": "文件超过了允许的大小限制。",
"error_name_required": "必须填写显示名称。",
"error_file_required": "必须上传构建上下文文件。"
"error_file_required": "必须上传构建上下文文件。",
"field_context_ref": "构建上下文引用",
"field_image_ref": "目标镜像引用",
"clear_btn": "清除",
"file_hint": "支持 .tar.gz 格式 (最大 1GB)"
}
+101 -60
View File
@@ -62,57 +62,12 @@ describe("api.me wire shape", () => {
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("surfaces must_change_password from GET /me verbatim", async () => {
// handleMe always emits must_change_password; the forced-change gate routes on
// it, so the snake_case key must survive the untyped boundary unchanged.
const body = {
user_id: "u4",
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: true,
};
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.must_change_password).toBe(true);
});
});
describe("local-password auth wire shapes", () => {
describe("session auth wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("login POSTs {username, password} and returns must_change_password", async () => {
// EXACTLY handlers_auth.go handleLogin's request body and response.
const fetchSpy = fakeFetch({
user_id: "u1",
role: "admin",
must_change_password: true,
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.login("owner", "s3cret");
expect(res.must_change_password).toBe(true);
expect(res.user_id).toBe("u1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/login");
expect((opts as RequestInit).method).toBe("POST");
expect((opts as RequestInit).credentials).toBe("include");
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
// other type to kill the cross-site form-POST forgery vector). This pins the
// panel half of that contract: a refactor that drops the header silently breaks
// login, and only this assertion would catch it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
username: "owner",
password: "s3cret",
});
});
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
@@ -150,31 +105,117 @@ describe("local-password auth wire shapes", () => {
});
});
it("changePassword POSTs {current_password, new_password}", async () => {
const fetchSpy = fakeFetch({ ok: true });
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "staff_account" })).toMatch(/operator/i);
});
// Op-login (the staff door): start hands back the approval handle the panel shows
// as `/felis web op approve <id>`; status is polled; finish spends the mailed code.
// EXACTLY handlers_op_login.go's request/response keys.
it("opLoginStart POSTs {email} and surfaces {request_id, expires_at}", async () => {
const fetchSpy = fakeFetch({
request_id: "req-1",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
await api.changePassword("old-pw", "brand-new-pw");
const res = await api.opLoginStart("o[email protected]");
expect(res.request_id).toBe("req-1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/change-password");
expect(String(url)).toBe("/auth/op-login/start");
expect((opts as RequestInit).method).toBe("POST");
// Same JSON content-type contract as login — the change-password route guards on
// it too (defense-in-depth), so the panel must keep sending it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
current_password: "old-pw",
new_password: "brand-new-pw",
email: "o[email protected]",
});
});
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
const fetchSpy = fakeFetch({ approved: true });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginStatus("req-1");
expect(res.approved).toBe(true);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/status/req-1");
expect((opts as RequestInit).method).toBe("GET");
});
it("opLoginFinish POSTs {request_id, code}", async () => {
const fetchSpy = fakeFetch({ user_id: "u9", role: "admin" });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginFinish("req-1", "123456");
expect(res.user_id).toBe("u9");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/finish");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
request_id: "req-1",
code: "123456",
});
});
});
// Pin the §B3 migration wire shapes (handlers_account_migrate.go). The status union
// ({active:false} | {active:true, state, ...}) and the issue/redeem bodies cross the
// untyped fetch().json() boundary, so a key drift leaves the Account migration card
// inert while typecheck/build stay green.
describe("account migration wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("migrateStatus GETs /account/migrate and surfaces the state-machine fields", async () => {
const fetchSpy = fakeFetch({
active: true,
state: "confirmed",
confirm_factor: "email_otp",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateStatus();
expect(res.active).toBe(true);
expect(res.state).toBe("confirmed");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("migrateIssueCode POSTs {target_user_id} and surfaces the one-time code", async () => {
const fetchSpy = fakeFetch({
code: "MIGR-1234",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateIssueCode("u2");
expect(res.code).toBe("MIGR-1234");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/issue-code");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
target_user_id: "u2",
});
});
it("migrateRedeem POSTs {code} and surfaces the moved servers", async () => {
const fetchSpy = fakeFetch({ migrated: true, servers_moved: 2, servers: ["a", "b"] });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateRedeem("MIGR-1234");
expect(res.servers_moved).toBe(2);
expect(res.servers).toEqual(["a", "b"]);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/redeem");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
code: "MIGR-1234",
});
});
});
+63 -26
View File
@@ -12,7 +12,6 @@ import type {
KickResult,
LinkResult,
LinkStatus,
LoginResult,
BindResult,
PatchUserRequest,
PlayersResult,
@@ -108,13 +107,10 @@ export interface SetupState {
}
export const api = {
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
// side effect — the panel never sees it — and returns only what to route on next
// (must_change_password forces the change card before any other surface). The
// username/password pair is the ONLY local credential; Passkey/PWA are Phase
// B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment.
login: (username: string, password: string) =>
request<LoginResult>("POST", "/auth/login", { username, password }),
// Session doors (spec §B). The product is passwordless: a session is minted only
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
// Zero-Trust-only deployment.
// logout is idempotent server-side (clears the session row + cookie); calling it
// without a session still resolves 200. After it, refreshing /me yields 401, which
@@ -142,14 +138,21 @@ export const api = {
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
// changePassword is callable during the first-login lockdown (the route is
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
// every OTHER session. The caller's own session is kept, so no re-login is needed.
changePassword: (current_password: string, new_password: string) =>
request<{ ok: boolean }>("POST", "/auth/change-password", {
current_password,
new_password,
// Op-login (spec §B): the staff door. start mails an OTP to a staff address and
// returns a request handle; an online admin vouches in-game with
// `/felis web op approve <request_id>`; the panel polls status until approved,
// then finish redeems {request_id, code} into a session. start answers 202 with a
// request_id for ANY well-formed address (anti-enumeration), so the UI just waits.
opLoginStart: (email: string) =>
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
opLoginStatus: (id: string) =>
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`),
opLoginFinish: (request_id: string, code: string) =>
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
request_id,
code,
}),
// Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL
@@ -369,6 +372,46 @@ export const api = {
passkeyDelete: (id: string) =>
request<void>("DELETE", `/account/passkey/credentials/${id}`),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
// code) → redeem (the TARGET account spends the code; the source's servers move to
// it and the source is retired).
migrateStatus: () =>
request<{
active: boolean;
state?: string;
target_user_id?: string;
confirm_factor?: string;
code_expires_at?: string;
}>("GET", "/account/migrate"),
migrateConfirmOTPStart: () =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/migrate/confirm/otp/start"),
migrateConfirmOTPVerify: (code: string) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/otp/verify", { code }),
migrateConfirmPasskeyBegin: () =>
request<any>("POST", "/account/migrate/confirm/passkey/begin"),
migrateConfirmPasskeyFinish: (assertion: any) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/passkey/finish", {
assertion,
}),
migrateIssueCode: (target_user_id: string) =>
request<{ code: string; expires_at: string }>("POST", "/account/migrate/issue-code", {
target_user_id,
}),
migrateRedeem: (code: string) =>
request<{ migrated: boolean; servers_moved: number; servers: string[] }>(
"POST",
"/account/migrate/redeem",
{ code },
),
listSubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
@@ -429,9 +472,6 @@ export const api = {
disableUser: (id: string, disabled: boolean) =>
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
resetUserPassword: (id: string) =>
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
setUserQuotas: (id: string, quotas: QuotaInput) =>
@@ -496,15 +536,12 @@ export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>;
switch (err.code) {
// Local-password auth (spec §B1).
// Session doors (spec §B): every passwordless door 403s this when local
// sessions are disabled on a Zero-Trust-only deployment.
case "local_auth_disabled":
return t("local_auth_disabled");
case "invalid_credentials":
return t("invalid_credentials");
case "weak_password":
return t("weak_password");
case "password_unchanged":
return t("password_unchanged");
case "staff_account":
return t("staff_account");
case "not_linked":
return t("not_linked");
case "invalid_code":
+3 -13
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { deriveAuth, isUnauthorized } from "./auth";
import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
// who is forced through the change-password card, and — critically — who is KEPT in
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
// and — critically — who is KEPT in
// the app despite a /me failure. The one distinction that must never blur is a true
// 401 (no session → login) versus any other failure (transient → stay functional),
// because mistaking the latter for the former would log out a healthy Zero-Trust
@@ -14,7 +14,6 @@ const admin: Identity = {
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: false,
is_owner: false,
};
@@ -41,7 +40,6 @@ describe("deriveAuth", () => {
expect(s.loading).toBe(true);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
});
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
@@ -61,21 +59,13 @@ describe("deriveAuth", () => {
const s = deriveAuth(admin, null, false);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(true);
expect(s.mustChangePassword).toBe(false);
});
it("surfaces must_change_password from the identity", () => {
const s = deriveAuth({ ...admin, must_change_password: true }, null, false);
expect(s.mustChangePassword).toBe(true);
expect(s.unauthenticated).toBe(false);
});
it("fails closed on a malformed identity missing is_admin / must_change_password", () => {
it("fails closed on a malformed identity missing is_admin", () => {
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
const partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
const s = deriveAuth(partial, null, false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
expect(s.unauthenticated).toBe(false);
});
});
+2 -5
View File
@@ -1,7 +1,7 @@
import type { ApiError, Identity } from "./types";
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
// React renderer (mirrors lib/nav.ts). The whole local-password gate turns on one
// React renderer (mirrors lib/nav.ts). The whole session gate turns on one
// distinction the rest of the app routes on: a /me that returns 401 means "there
// is genuinely no session — show the login page", whereas ANY OTHER /me failure
// (network, 5xx, timeout) must NOT log the user out. The latter preserves the
@@ -20,8 +20,6 @@ export interface AuthState {
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
* transient or 5xx failure leaves this false so the app keeps rendering. */
unauthenticated: boolean;
/** True when the loaded identity still owes a forced first-login change. */
mustChangePassword: boolean;
}
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
@@ -39,7 +37,7 @@ export function isUnauthorized(error: unknown): boolean {
/** deriveAuth folds one /me outcome (identity OR error, plus the in-flight flag)
* into the state the router reads. Every boolean is computed with `=== true` / an
* explicit 401 check so an absent or malformed field fails to the safe side:
* non-admin, still-authenticated, no forced change. */
* non-admin, still-authenticated. */
export function deriveAuth(
identity: Identity | null,
error: unknown,
@@ -50,6 +48,5 @@ export function deriveAuth(
loading,
isAdmin: identity?.is_admin === true,
unauthenticated: !loading && identity === null && isUnauthorized(error),
mustChangePassword: identity?.must_change_password === true,
};
}
+6
View File
@@ -6,6 +6,10 @@
export interface RuntimeConfig {
apiBase: string;
rootDomain: string;
/** Player-console hostname (console.<root>), absent when unconfigured. */
panelHostname?: string;
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
adminHostname?: string;
}
const FALLBACK: RuntimeConfig = {
@@ -26,6 +30,8 @@ export async function loadConfig(): Promise<RuntimeConfig> {
cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase,
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname,
};
} catch {
cached = FALLBACK;
+2 -4
View File
@@ -26,9 +26,8 @@ import { deriveAuth, type AuthState } from "./auth";
// simply don't see admin surfaces. (The backend 403s admin data calls
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
//
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
// after a login / change / logout so the gate re-evaluates without a reload.
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
@@ -47,7 +46,6 @@ const TierContext = createContext<TierState>({
isAdmin: false,
isOwner: false,
unauthenticated: false,
mustChangePassword: false,
refresh: async () => {},
});
+8 -25
View File
@@ -25,8 +25,8 @@ export interface ServerInfo {
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
players?: number;
maxPlayers?: number;
playersOnline?: number;
playersMax?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
@@ -74,7 +74,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.players carries the count alone.
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -101,10 +101,9 @@ export interface KickResult {
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
* projection. Sharing one interface would silently read `undefined` across the
* fetch().json() boundary for every renamed field. */
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
* required — whereas ServerInfo is the /me/servers projection with them optional.
* Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer {
name: string;
subdomain: string;
@@ -213,24 +212,9 @@ export interface Identity {
/** Server-computed Principal.IsOwner() — true only for the platform-level
* owner account (one above admin). Owners get user management; admins don't. */
is_owner: boolean;
/** Local-password path only: the account owes a forced first-login password
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean;
email_verified?: boolean;
}
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
* session cookie is set as a side effect (HttpOnly, so the panel never sees it);
* the body carries only what the panel routes on next — chiefly whether to force the
* change-password card before any other surface. */
export interface LoginResult {
user_id: string;
role: "user" | "admin" | "owner";
must_change_password: boolean;
}
export interface BindResult {
user_id: string;
linked: boolean;
@@ -288,7 +272,6 @@ export interface UserView {
disabled: boolean;
email_verified: boolean;
server_count: number;
must_change_password: boolean;
created_at: string;
updated_at: string;
}
@@ -304,12 +287,12 @@ export interface UserDetail extends UserView {
linked_accounts: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
* the new account signs in via email-OTP / passkey / bind code, never a password. */
export interface CreateUserRequest {
username: string;
email?: string;
role: "admin" | "user";
password: string;
must_change_password: boolean;
}
export interface PatchUserRequest {
+258 -7
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { ArrowRightLeft, CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -178,8 +178,9 @@ export function Account() {
}
}
// Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to
// Sign-out ends a local session (passkey / email-OTP / bind-code / op-login):
// clear it server-side, then refresh /me. For a local session that read now 401s
// → the tier model flips to
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
// navigation is needed. (On a Zero-Trust proxied session there is no local cookie
// to drop and /me still succeeds — sign-out is a no-op, which is the honest
@@ -304,7 +305,7 @@ export function Account() {
onClick={() => setEmailSent(false)}
className="h-auto p-0 font-normal"
>
修改邮箱
{t("change_email")}
</Button>
</div>
)}
@@ -387,10 +388,10 @@ export function Account() {
onClick={cancelRegistration}
disabled={registeringPasskey}
>
取消
{t("common:cancel")}
</Button>
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
{registeringPasskey ? t("registering_passkey") : "继续"}
{registeringPasskey ? t("registering_passkey") : t("continue_btn")}
</Button>
</DialogFooter>
</form>
@@ -400,7 +401,7 @@ export function Account() {
<CardContent className="text-sm space-y-4">
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
{passkeys.loading && !passkeys.data ? (
<Loading label="加载 Passkey 列表中..." />
<Loading label={t("loading_passkeys")} />
) : passkeys.error ? (
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? (
@@ -441,6 +442,11 @@ export function Account() {
</CardContent>
</Card>
<MigrationCard
userId={identity?.user_id}
hasPasskey={(passkeys.data?.credentials?.length ?? 0) > 0}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -554,6 +560,251 @@ function LinkForm({
);
}
/** MigrationCard is the web half of §B3 account migration (scenario A "inherit").
* The flow is born in-game (/felis migrate proves the player) and driven here:
* status → step-up confirm (passkey when one is enrolled — the server 409s the
* OTP door in that case — else email-OTP) → issue-code (the source names the
* target account and reads a one-time code) → redeem (the TARGET account spends
* the code; the source's servers move over and the source is retired). Both
* roles render on every account: the redeem form is always offered, and the
* account id is always shown so a target can hand it to the source. */
function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: boolean }) {
const { t } = useTranslation("account");
const mig = useAsync(() => api.migrateStatus(), []);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [otpSent, setOtpSent] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [targetId, setTargetId] = useState("");
const [issued, setIssued] = useState<{ code: string; expires_at: string } | null>(null);
const [redeemCode, setRedeemCode] = useState("");
const [redeemed, setRedeemed] = useState<{ servers_moved: number; servers: string[] } | null>(null);
async function run(fn: () => Promise<void>) {
if (busy) return;
setBusy(true);
setErr(null);
try {
await fn();
} catch (e) {
setErr(humanizeError(e));
} finally {
setBusy(false);
}
}
// Step-up passkey confirm. Unlike the register/login begins (which strip the
// envelope server-side), the migrate begin returns go-webauthn's raw
// {"publicKey": {...}} document, so we descend into .publicKey here.
function confirmWithPasskey() {
void run(async () => {
const options = await api.migrateConfirmPasskeyBegin();
const pk = options.publicKey;
const publicKey: PublicKeyCredentialRequestOptions = {
...pk,
challenge: base64urlToBytes(pk.challenge),
allowCredentials: pk.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
if (!credential) throw new Error("Failed to get credential");
const response = credential.response as AuthenticatorAssertionResponse;
await api.migrateConfirmPasskeyFinish({
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
});
await mig.reload();
});
}
const state = mig.data?.active ? mig.data.state : undefined;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ArrowRightLeft className="h-4 w-4 text-primary" /> {t("migration")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">{t("migration_desc")}</p>
{userId && (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs uppercase tracking-wide">{t("account_id")}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground select-all">
{userId}
</code>
</div>
)}
{mig.loading && !mig.data ? (
<Loading label={t("migration_checking")} />
) : mig.error ? (
<ErrorState error={mig.error} onRetry={mig.reload} />
) : (
<>
{!mig.data?.active && !redeemed && (
<p className="text-muted-foreground">
{t("migration_none_prefix")}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
/felis migrate
</code>
{t("migration_none_suffix")}
</p>
)}
{state === "initiated" && (
<div className="space-y-2">
<p className="font-medium text-foreground">{t("migration_confirm_title")}</p>
<p className="text-muted-foreground">{t("migration_confirm_desc")}</p>
{hasPasskey ? (
<Button size="sm" onClick={confirmWithPasskey} disabled={busy}>
<Fingerprint className="mr-2 h-4 w-4" />
{busy ? t("migration_confirming") : t("migration_confirm_passkey_btn")}
</Button>
) : !otpSent ? (
<Button
size="sm"
disabled={busy}
onClick={() =>
void run(async () => {
await api.migrateConfirmOTPStart();
setOtpSent(true);
})
}
>
<Mail className="mr-2 h-4 w-4" />
{busy ? t("sending_code") : t("migration_confirm_otp_btn")}
</Button>
) : (
<form
className="flex gap-2 max-w-md"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.migrateConfirmOTPVerify(otpCode.trim());
await mig.reload();
});
}}
>
<Input
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
placeholder={t("otp_code_placeholder")}
maxLength={6}
disabled={busy}
className="max-w-[12rem] font-mono text-center tracking-[0.2em]"
/>
<Button type="submit" size="sm" disabled={busy || otpCode.trim().length !== 6}>
{busy ? t("migration_confirming") : t("email_verify_btn")}
</Button>
</form>
)}
</div>
)}
{state === "confirmed" && !issued && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
setIssued(await api.migrateIssueCode(targetId.trim()));
await mig.reload();
});
}}
>
<p className="font-medium text-foreground">{t("migration_issue_title")}</p>
<p className="text-muted-foreground">{t("migration_issue_desc")}</p>
<div className="flex gap-2 max-w-md">
<Input
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder={t("migration_target_placeholder")}
disabled={busy}
className="font-mono"
/>
<Button type="submit" size="sm" disabled={busy || !targetId.trim()}>
{busy ? t("migration_issuing") : t("migration_issue_btn")}
</Button>
</div>
</form>
)}
{issued && (
<div className="space-y-2">
<p className="font-medium text-foreground">{t("migration_code_title")}</p>
<code className="block w-fit rounded bg-muted px-3 py-2 font-mono text-base tracking-[0.2em] text-foreground select-all">
{issued.code}
</code>
<p className="text-xs text-muted-foreground">
{t("migration_code_desc")} ({new Date(issued.expires_at).toLocaleString()})
</p>
</div>
)}
{state === "code_issued" && !issued && (
<p className="text-muted-foreground">
{t("migration_code_pending")}{" "}
{mig.data?.code_expires_at &&
`(${new Date(mig.data.code_expires_at).toLocaleString()})`}
</p>
)}
{redeemed ? (
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("migration_redeemed", { count: redeemed.servers_moved })}
</div>
) : (
!mig.data?.active && (
<form
className="space-y-2 border-t pt-4"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
setRedeemed(await api.migrateRedeem(redeemCode.trim()));
});
}}
>
<p className="font-medium text-foreground">{t("migration_redeem_title")}</p>
<p className="text-muted-foreground">{t("migration_redeem_desc")}</p>
<div className="flex gap-2 max-w-md">
<Input
value={redeemCode}
onChange={(e) => setRedeemCode(e.target.value)}
placeholder={t("migration_redeem_placeholder")}
disabled={busy}
className="font-mono"
/>
<Button type="submit" size="sm" disabled={busy || !redeemCode.trim()}>
{busy ? t("migration_redeeming") : t("migration_redeem_btn")}
</Button>
</div>
</form>
)
)}
{err && <p className="text-sm text-destructive">{err}</p>}
</>
)}
</CardContent>
</Card>
);
}
function StepBadge({ n }: { n: number }) {
return (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
-135
View File
@@ -1,135 +0,0 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
// Minimum new-password length. The server is the source of truth (8–72 BYTES, the
// bcrypt limit); this is only a pre-submit courtesy so the obvious case fails
// instantly rather than round-tripping to a `weak_password` error.
const MIN_PASSWORD = 8;
// ChangePassword is the forced first-login change AND the voluntary change surface
// (spec §B1). It lives OUTSIDE RequireAuth on purpose: RequireAuth redirects a
// must-change principal *to* this page, so nesting it under that gate would loop.
// It therefore re-checks auth itself — a 401 principal is sent to /login.
//
// On success the server keeps the caller's own session (revoking only the others),
// so no re-login is needed: we refresh /me — which now reports must_change_password
// false — and continue into the app.
export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) {
return (
<AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common:loading")}
</div>
</AuthLayout>
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
const mismatch = confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < MIN_PASSWORD;
const canSubmit =
!submitting &&
current.length > 0 &&
next.length >= MIN_PASSWORD &&
next === confirm;
async function submit(e: FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await api.changePassword(current, next);
await refresh();
navigate("/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
return (
<AuthLayout
title={t("change_password_title")}
subtitle={
mustChangePassword
? t("change_password_subtitle_forced")
: t("change_password_subtitle_voluntary")
}
>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">{t("current_password")}</Label>
<Input
id="current"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("new_password")}</Label>
<Input
id="new-password"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
aria-invalid={tooShort ? true : undefined}
/>
{tooShort && (
<p className="text-xs text-muted-foreground">
{t("password_min_length", { min: MIN_PASSWORD })}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
<Input
id="confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
aria-invalid={mismatch ? true : undefined}
/>
{mismatch && (
<p className="text-xs text-destructive">{t("password_mismatch")}</p>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? t("saving") : t("change_password_btn")}
</Button>
</form>
</CardContent>
</Card>
</AuthLayout>
);
}
+2 -2
View File
@@ -52,7 +52,7 @@ export function Dashboard() {
return {
total: list.length,
running: by("Running"),
players: list.reduce((n, s) => n + (s.players ?? 0), 0),
players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0),
};
}, [servers]);
@@ -115,7 +115,7 @@ function FleetView({
else if (s.phase === "Failed") failed++;
else unknown++;
maxPlayers += s.maxPlayers ?? 0;
maxPlayers += s.playersMax ?? 0;
});
return {
+286 -163
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2, KeyRound, Mail, Fingerprint } from "lucide-react";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
@@ -9,29 +9,36 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
// Login is the local-password sign-in (spec §B1). It is the ONLY local credential
// surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
// the API sets an HttpOnly session cookie (invisible here); we then refresh the tier
// context so the gate re-evaluates, and route to the forced change-password card
// when the account still owes its first-login change, else to the dashboard.
// 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);
// staff use the vouched op-login door (email code + in-game approval). No password
// exists anywhere in the product. On success the API sets an HttpOnly session
// cookie (invisible here); we then refresh the tier context so the gate
// re-evaluates and land on the dashboard.
//
// Reaching this page already-authenticated (e.g. typing /login while signed in)
// short-circuits to the right destination rather than showing the form.
// short-circuits to the dashboard rather than showing the form.
export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier();
const { loading, identity, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [activeTab, setActiveTab] = useState<"password" | "bind" | "email">("password");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [bindCode, setBindCode] = useState("");
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
const [email, setEmail] = useState("");
const [otpCode, setOtpCode] = useState("");
const [otpSent, setOtpSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const [bindCode, setBindCode] = useState("");
// Op-login (staff door): start → wait for the in-game vouch → finish with the
// mailed code. request_id doubles as the handle an online admin approves.
const [opEmail, setOpEmail] = useState("");
const [opRequestId, setOpRequestId] = useState<string | null>(null);
const [opApproved, setOpApproved] = useState(false);
const [opCode, setOpCode] = useState("");
const [isOpHost, setIsOpHost] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -44,6 +51,32 @@ export function Login() {
return () => clearTimeout(timer);
}, [countdown]);
// Tier-aware copy: on the op.console hostname the staff door is the default tab
// (the player doors refuse staff accounts anyway).
useEffect(() => {
void loadConfig().then((cfg) => {
if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) {
setIsOpHost(true);
setActiveTab("op");
}
});
}, []);
// Poll the op-login request until an in-game approval lands. Errors are
// swallowed on purpose: a transient failure just means we ask again.
useEffect(() => {
if (!opRequestId || opApproved) return;
const timer = setInterval(async () => {
try {
const s = await api.opLoginStatus(opRequestId);
if (s.approved) setOpApproved(true);
} catch {
// keep polling
}
}, 3000);
return () => clearInterval(timer);
}, [opRequestId, opApproved]);
// Don't flash the form while the boot /me is still in flight: a signed-in visitor
// would briefly see a login form before being redirected away.
if (loading) {
@@ -56,26 +89,8 @@ export function Login() {
</AuthLayout>
);
}
if (identity && mustChangePassword) return <Navigate to="/change-password" replace />;
if (identity) return <Navigate to="/" replace />;
async function handlePasswordSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.login(username.trim(), password);
// Re-read /me so the context reflects the new session before we leave this
// page; the route we land on is gated on that fresh state.
await refresh();
navigate(res.must_change_password ? "/change-password" : "/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
async function handleBindSubmit(e: FormEvent) {
e.preventDefault();
const code = bindCode.trim();
@@ -127,7 +142,7 @@ export function Login() {
setSubmitting(true);
setError(null);
const identifier = username.trim();
const identifier = email.trim();
try {
let assertion: any;
if (!identifier) {
@@ -165,9 +180,9 @@ export function Login() {
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
} else {
// Username-first (Email-first) passkey login
// Email-first passkey login
if (!identifier.includes("@")) {
throw new Error("使用 Passkey 登录请在上方输入框中输入您绑定的邮箱,或留空直接进行免密登录。");
throw new Error(t("passkey_email_hint"));
}
const options = await api.authPasskeyLoginBegin(identifier);
@@ -213,46 +228,135 @@ export function Login() {
}
}
async function handleOpStart(e: FormEvent) {
e.preventDefault();
if (!opEmail.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.opLoginStart(opEmail.trim());
setOpRequestId(res.request_id);
} catch (err) {
setError(humanizeError(err));
} finally {
setSubmitting(false);
}
}
async function handleOpFinish(e: FormEvent) {
e.preventDefault();
if (!opRequestId || !opCode.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
await api.opLoginFinish(opRequestId, opCode.trim());
await refresh();
navigate("/", { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
}
}
function switchTab(tab: "main" | "bind" | "op") {
setError(null);
setActiveTab(tab);
}
return (
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<AuthLayout
title={t("login_title")}
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
>
<Card>
<CardContent className="pt-6">
{activeTab === "password" && (
{activeTab === "main" && (
<div className="space-y-4">
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
aria-invalid={error ? true : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-invalid={error ? true : undefined}
/>
<Label htmlFor="email">{t("email_address")}</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder={t("email_placeholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email webauthn"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting || otpSent}
aria-invalid={error ? true : undefined}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSendOtp}
disabled={submitting || !email.trim() || countdown > 0}
className="shrink-0 font-normal"
>
{submitting && !otpSent ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
{t("sending_otp")}
</>
) : countdown > 0 ? (
`${countdown}${t("resend_in")}`
) : (
t("send_otp")
)}
</Button>
</div>
{otpSent && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1 leading-normal">
{t("otp_sent")}
</p>
)}
</div>
{otpSent && (
<div className="space-y-2">
<Label htmlFor="otpCode">{t("otp_code")}</Label>
<Input
id="otpCode"
placeholder={t("otp_placeholder")}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !username.trim() || !password}
>
{submitting ? t("signing_in") : t("sign_in")}
</Button>
{otpSent && (
<Button
type="submit"
className="w-full"
disabled={submitting || !otpCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
)}
</form>
<div className="relative my-2">
@@ -281,27 +385,21 @@ export function Login() {
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("email");
}}
onClick={() => switchTab("bind")}
disabled={submitting}
>
<Mail className="h-4 w-4 text-muted-foreground" />
{t("tab_email_btn")}
<KeyRound className="h-4 w-4 text-muted-foreground" />
{t("tab_bind_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("bind");
}}
onClick={() => switchTab("op")}
disabled={submitting}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
{t("tab_bind_btn")}
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
{t("tab_op_btn")}
</Button>
</div>
</div>
@@ -349,116 +447,141 @@ export function Login() {
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
onClick={() => switchTab("main")}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
<span>{t("back_to_login")}</span>
</button>
</div>
</form>
)}
{activeTab === "email" && (
<form onSubmit={handleEmailSubmit} className="space-y-4">
{activeTab === "op" && !opRequestId && (
<form onSubmit={handleOpStart} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t("email_address")}</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder={t("email_placeholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
disabled={submitting || otpSent}
aria-invalid={error ? true : undefined}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSendOtp}
disabled={submitting || !email.trim() || countdown > 0}
className="shrink-0 font-normal"
>
{submitting && !otpSent ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
{t("sending_otp")}
</>
) : countdown > 0 ? (
`${countdown}${t("resend_in")}`
) : (
t("send_otp")
)}
</Button>
</div>
{otpSent && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1 leading-normal">
{t("otp_sent")}
</p>
<Label htmlFor="opEmail">{t("email_address")}</Label>
<Input
id="opEmail"
type="email"
placeholder={t("email_placeholder")}
value={opEmail}
onChange={(e) => setOpEmail(e.target.value)}
autoComplete="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
<p className="text-[11px] text-muted-foreground/80 mt-1 leading-normal">
{t("op_hint")}
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={submitting || !opEmail.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("sending_otp")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("op_start_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => switchTab("main")}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_login")}</span>
</button>
</div>
</form>
)}
{activeTab === "op" && opRequestId && (
<form onSubmit={handleOpFinish} className="space-y-4">
<div className="rounded-md border bg-muted/40 p-3 space-y-2">
<p className="text-[11px] text-muted-foreground leading-normal">
{t("op_approve_hint")}
</p>
<p className="font-mono text-xs break-all select-all">
/felis web op approve {opRequestId}
</p>
</div>
{otpSent && (
<div className="space-y-2">
<Label htmlFor="otpCode">{t("otp_code")}</Label>
<Input
id="otpCode"
placeholder={t("otp_placeholder")}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
</div>
{opApproved ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
{t("op_approved")}
</p>
) : (
<p className="inline-flex items-center gap-2 text-[11px] text-muted-foreground leading-normal">
<Loader2 className="h-3 w-3 animate-spin" />
{t("op_waiting")}
</p>
)}
<div className="space-y-2">
<Label htmlFor="opCode">{t("otp_code")}</Label>
<Input
id="opCode"
placeholder={t("otp_placeholder")}
value={opCode}
onChange={(e) => setOpCode(e.target.value)}
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{otpSent && (
<Button
type="submit"
className="w-full"
disabled={submitting || !otpCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
)}
<Button
type="submit"
className="w-full"
disabled={submitting || !opCode.trim() || !opApproved}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
setOpRequestId(null);
setOpApproved(false);
setOpCode("");
switchTab("op");
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
<span>{t("op_restart")}</span>
</button>
</div>
</form>
+4 -4
View File
@@ -379,12 +379,12 @@ export function MySubmissionsPage() {
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("field_context_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div>
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("field_image_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
</div>
)}
@@ -510,7 +510,7 @@ export function MySubmissionsPage() {
}}
>
<X className="mr-1 h-3 w-3" />
清除
{t("clear_btn")}
</Button>
)}
</div>
@@ -518,7 +518,7 @@ export function MySubmissionsPage() {
<>
<Upload className="h-8 w-8 text-muted-foreground/80 mb-2" />
<p className="text-xs font-medium text-foreground">{t("file_drag_hint")}</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">支持 .tar.gz 格式 (最大 1GB)</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">{t("file_hint")}</p>
</>
)}
</div>
+5 -5
View File
@@ -441,7 +441,7 @@ export function ServerLuckPerms() {
</Badge>
))
) : (
<p className="text-xs text-muted-foreground/60 italic py-1">No parent groups assigned</p>
<p className="text-xs text-muted-foreground/60 italic py-1">{t("luckperms_no_parent_groups")}</p>
)}
</div>
@@ -550,7 +550,7 @@ export function ServerLuckPerms() {
{p.world}
</Badge>
) : (
<span className="text-muted-foreground italic">global</span>
<span className="text-muted-foreground italic">{t("luckperms_global")}</span>
)}
</td>
<td className="px-4 py-2.5 text-center">
@@ -569,7 +569,7 @@ export function ServerLuckPerms() {
) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
No explicit permission nodes assigned
{t("luckperms_no_perms")}
</td>
</tr>
)}
@@ -681,7 +681,7 @@ export function ServerLuckPerms() {
type="button"
onClick={clearHistory}
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
title="Clear history"
title={t("luckperms_clear_history")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -746,7 +746,7 @@ export function ServerLuckPerms() {
<details className="group/details">
<summary className="cursor-pointer select-none text-[10px] text-muted-foreground/70 hover:text-foreground font-mono transition-colors list-none flex items-center gap-1">
<span className="transition-transform group-open/details:rotate-90">▶</span>
RCON Console Output
{t("luckperms_rcon_output")}
</summary>
<pre className="mt-1.5 p-2 rounded bg-muted/60 border border-border/80 font-mono text-[10px] text-foreground/80 overflow-x-auto whitespace-pre-wrap break-all max-h-24">
{h.output}
+4 -4
View File
@@ -140,7 +140,7 @@ export function ImageAdmin() {
<DialogHeader>
<DialogTitle>{t("add_image_title")}</DialogTitle>
<DialogDescription>
将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。
{t("add_image_desc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAdd} className="space-y-4">
@@ -184,7 +184,7 @@ export function ImageAdmin() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索镜像名称或来源..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("images_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -242,8 +242,8 @@ export function ImageAdmin() {
) : filteredImages.length === 0 ? (
<div className="p-4 border-b-0">
<EmptyState
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_images_title")}
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_images_hint")}
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_images_title")}
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_images_hint")}
/>
</div>
) : (
+15 -14
View File
@@ -40,25 +40,26 @@ const STATUS_BADGE_STYLE: Record<BuildStatus, string> = {
cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
};
function formatDuration(createdAt: string, finishedAt?: string, isZh?: boolean): string {
function formatDuration(
createdAt: string,
finishedAt: string | undefined,
t: (key: string, opts?: Record<string, unknown>) => string
): string {
const start = new Date(createdAt).getTime();
if (!Number.isFinite(start)) return "";
const end = finishedAt ? new Date(finishedAt).getTime() : Date.now();
if (!Number.isFinite(end) || end < start) return "";
const diffSec = Math.round((end - start) / 1000);
if (diffSec < 60) {
return isZh ? `${diffSec}秒` : `${diffSec}s`;
return t("build_duration_seconds", { s: diffSec });
}
const m = Math.floor(diffSec / 60);
const s = diffSec % 60;
return isZh ? `${m}分${s}秒` : `${m}m ${s}s`;
return t("build_duration_minutes", { m: Math.floor(diffSec / 60), s: diffSec % 60 });
}
export function ImageBuildPage() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const now = Date.now();
const isZh = locale.startsWith("zh");
const config = useConfig();
const { identity } = useTier();
@@ -289,7 +290,7 @@ export function ImageBuildPage() {
<DialogHeader>
<DialogTitle>{t("trigger_build_title")}</DialogTitle>
<DialogDescription>
输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。
{t("trigger_build_desc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleTrigger} className="space-y-4">
@@ -329,7 +330,7 @@ export function ImageBuildPage() {
sub.status === "approved" && "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
sub.status === "rejected" && "bg-rose-500/10 text-rose-500 border-rose-500/20"
)}>
{sub.status === "pending_review" ? (isZh ? "待审核" : "Pending") : sub.status === "approved" ? (isZh ? "已同意" : "Approved") : (isZh ? "已驳回" : "Rejected")}
{sub.status === "pending_review" ? t("status_pending_review") : sub.status === "approved" ? t("status_approved") : t("status_rejected")}
</span>
</SelectItem>
))
@@ -346,7 +347,7 @@ export function ImageBuildPage() {
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5 animate-bounce" />
<div className="space-y-1">
<p className="font-bold text-amber-400">
{t("build_import_submission_warning_title", { status: selectedSub.status === "pending_review" ? (isZh ? "待审核" : "Pending Review") : (isZh ? "已驳回" : "Rejected") })}
{t("build_import_submission_warning_title", { status: selectedSub.status === "pending_review" ? t("status_pending_review") : t("status_rejected") })}
</p>
<p className="text-[10px] text-muted-foreground leading-normal">
{t("build_import_submission_warning_desc")}
@@ -434,7 +435,7 @@ export function ImageBuildPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索构建 ID、镜像引用或状态..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("builds_search_placeholder")} />
</div>
{/* List Content */}
@@ -443,8 +444,8 @@ export function ImageBuildPage() {
) : filteredBuilds.length === 0 ? (
<div className="p-4">
<EmptyState
title={search.trim() ? "无匹配构建任务" : t("no_builds_title")}
hint={search.trim() ? "尝试更换搜索词" : t("no_builds_hint")}
title={search.trim() ? t("search_no_results") : t("no_builds_title")}
hint={search.trim() ? t("search_no_results_hint") : t("no_builds_hint")}
/>
</div>
) : (
@@ -479,7 +480,7 @@ export function ImageBuildPage() {
<div className="flex flex-col min-w-0">
<span
className="font-mono font-medium text-foreground select-all block max-w-xl truncate"
title={`镜像引用: ${b.image_ref}${b.base_image ? `\n基础镜像: ${b.base_image}` : ""}${b.context_ref ? `\n构建上下文: ${b.context_ref}` : ""}`}
title={`${t("image_ref_label")}: ${b.image_ref}${b.base_image ? `\n${t("base_image_label")}: ${b.base_image}` : ""}${b.context_ref ? `\n${t("context_ref_label")}: ${b.context_ref}` : ""}`}
>
{b.image_ref}
</span>
@@ -519,7 +520,7 @@ export function ImageBuildPage() {
{/* Column 6: Duration */}
<td className="px-4 py-3 align-middle text-center font-mono text-muted-foreground whitespace-nowrap">
{formatDuration(b.created_at, b.finished_at, isZh) || "—"}
{formatDuration(b.created_at, b.finished_at, t) || "—"}
</td>
{/* Column 7: Action */}
+6 -6
View File
@@ -157,7 +157,7 @@ export function SubmissionsPage() {
<CardContent className="p-0">
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder="搜索模组包名称或提交人..." />
<SearchInput value={search} onChange={setSearch} placeholder={t("submissions_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
type="button"
@@ -230,8 +230,8 @@ export function SubmissionsPage() {
) : filteredSubmissions.length === 0 ? (
<div className="p-4 border-b-0">
<EmptyState
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_submissions_title")}
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_submissions_hint")}
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_submissions_title")}
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_submissions_hint")}
/>
</div>
) : (
@@ -337,12 +337,12 @@ export function SubmissionsPage() {
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("context_ref_label")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div>
{sub.image_ref && (
<div>
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
<p className="font-semibold text-foreground mb-1">{t("image_ref_label")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
</div>
)}
@@ -350,7 +350,7 @@ export function SubmissionsPage() {
{sub.build_id && (
<div>
<p className="font-semibold text-foreground">关联构建任务 (Build ID)</p>
<p className="font-semibold text-foreground">{t("table_build_id")}</p>
<code className="font-mono bg-background border rounded px-1.5 py-0.5">{sub.build_id}</code>
</div>
)}
+3 -60
View File
@@ -7,7 +7,6 @@ import {
UserRound,
Mail,
Calendar,
Key,
Clock,
Trash2,
Power,
@@ -151,7 +150,7 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
onSaved();
} catch (e: any) {
if (e && e.code === "already_exists") {
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
setErr(t("users_create_validation_username_taken"));
} else {
setErr(humanizeError(e));
}
@@ -640,7 +639,7 @@ function DangerZone({
navigate: (path: string) => void;
}) {
const { t } = useTranslation("admin");
const [dlg, setDlg] = useState<"disable" | "resetPw" | "delete" | null>(null);
const [dlg, setDlg] = useState<"disable" | "delete" | null>(null);
return (
<Card className="border-destructive/30">
@@ -658,18 +657,6 @@ function DangerZone({
onAction={() => setDlg("disable")}
/>
{/* Reset password */}
<DangerRow
icon={Key}
title={t("users_danger_reset_pw")}
desc={user.email
? t("users_danger_reset_pw_desc_email", { email: user.email })
: t("users_danger_reset_pw_desc")}
btnLabel={t("users_danger_reset_pw_btn")}
btnVariant="destructive"
onAction={() => setDlg("resetPw")}
/>
{/* Delete user */}
<DangerRow
icon={Trash2}
@@ -722,7 +709,7 @@ function DangerDialogs({
onChanged,
navigate,
}: {
dlg: "disable" | "resetPw" | "delete" | null;
dlg: "disable" | "delete" | null;
setDlg: (v: null) => void;
user: UserDetail;
onChanged: () => void;
@@ -731,12 +718,10 @@ function DangerDialogs({
const { t } = useTranslation("admin");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
function close() {
setDlg(null);
setErr(null);
setOk(null);
setLoading(false);
}
@@ -753,23 +738,6 @@ function DangerDialogs({
}
}
async function handleResetPassword() {
setLoading(true);
setErr(null);
try {
const r = await api.resetUserPassword(user.id);
if (r.email) {
setOk(t("users_pw_reset_ok", { email: r.email }));
} else {
setOk(t("users_pw_reset_ok_no_email"));
}
setLoading(false);
} catch (e) {
setErr(humanizeError(e));
setLoading(false);
}
}
async function handleDelete() {
setLoading(true);
setErr(null);
@@ -801,31 +769,6 @@ function DangerDialogs({
</DialogContent>
</Dialog>
{/* Reset password dialog */}
<Dialog open={dlg === "resetPw"} onOpenChange={(v) => { if (!v) close(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-primary" />
{t("users_danger_reset_pw_dlg_title")}
</DialogTitle>
<DialogDescription>
{user.email
? t("users_danger_reset_pw_dlg_desc_email", { email: user.email })
: t("users_danger_reset_pw_dlg_desc_no_email")}
</DialogDescription>
</DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>}
{ok && (
<p className="rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="inline h-4 w-4 mr-1" />
{ok}
</p>
)}
<ConfirmFooter onCancel={close} onConfirm={handleResetPassword} loading={loading} disabled={loading || ok !== null} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_reset_pw_confirm")} />
</DialogContent>
</Dialog>
{/* Delete user dialog */}
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
<DialogContent className="sm:max-w-sm">
+2 -2
View File
@@ -135,8 +135,8 @@ export function ServersPage() {
ready: s.phase === "Running",
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
playersOnline: s.players ?? 0,
playersMax: s.maxPlayers ?? 0,
playersOnline: s.playersOnline ?? 0,
playersMax: s.playersMax ?? 0,
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable,
owned: s.owned,