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:
97 files changed
+1923
-1444
No files matched your search
+4
-6
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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}} 台服务器转移至本账户。"
|
||||
}
|
||||
@@ -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、镜像引用或状态..."
|
||||
}
|
||||
@@ -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": "操作"
|
||||
|
||||
@@ -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": "未关联游戏角色",
|
||||
|
||||
@@ -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": "该服务器已被他人抢先认领。",
|
||||
|
||||
@@ -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": "实时刷新",
|
||||
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user