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

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

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

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

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

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

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

No files matched your search

+65 -147
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus, Loader2, Copy, Check } from "lucide-react";
import { Plus, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -27,39 +27,24 @@ interface Props {
onCreated: (id: string) => void;
}
function generateRandomPassword(length = 16): string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$";
let password = "";
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
// Passwordless create (spec §B): the account is minted with no credential at all.
// The new user signs in with an in-game /link bind code (or email-OTP / passkey
// once their address is verified), so there is nothing to hand over here — on
// success we just jump to the new user's detail page.
export function CreateUserDialog({ onCreated }: Props) {
const { t } = useTranslation("admin");
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState<"user" | "admin">("user");
const [mustChange, setMustChange] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Success state fields
const [createdUser, setCreatedUser] = useState<any | null>(null);
const [generatedPassword, setGeneratedPassword] = useState("");
const [copied, setCopied] = useState(false);
function reset() {
setUsername("");
setEmail("");
setRole("user");
setMustChange(true);
setErr(null);
setCreatedUser(null);
setGeneratedPassword("");
setCopied(false);
}
async function handleSubmit(e: React.FormEvent) {
@@ -72,21 +57,19 @@ export function CreateUserDialog({ onCreated }: Props) {
return;
}
const genPassword = generateRandomPassword();
setSubmitting(true);
try {
const u = await api.createUser({
username: username.trim(),
email: email.trim() || undefined,
role,
password: genPassword,
must_change_password: mustChange,
});
setGeneratedPassword(genPassword);
setCreatedUser(u);
setOpen(false);
reset();
onCreated(u.id);
} catch (e: any) {
if (e && e.code === "already_exists") {
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
setErr(t("users_create_validation_username_taken"));
} else {
setErr(humanizeError(e));
}
@@ -95,27 +78,6 @@ export function CreateUserDialog({ onCreated }: Props) {
}
}
const handleCopy = async () => {
if (!createdUser) return;
const text = `Username: ${createdUser.username}\nPassword: ${generatedPassword}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
// ignore
}
};
const handleDone = () => {
const id = createdUser?.id;
setOpen(false);
reset();
if (id) {
onCreated(id);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
@@ -126,113 +88,69 @@ export function CreateUserDialog({ onCreated }: Props) {
</DialogTrigger>
<DialogContent className="sm:max-w-md" hideClose={submitting}>
<DialogHeader>
<DialogTitle>{createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")}</DialogTitle>
<DialogDescription>
{createdUser
? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。"
: t("users_create_desc")}
</DialogDescription>
<DialogTitle>{t("users_create_title")}</DialogTitle>
<DialogDescription>{t("users_create_desc")}</DialogDescription>
</DialogHeader>
{createdUser ? (
<div className="space-y-4">
<div className="rounded-md border border-border/50 bg-muted/20 p-4 space-y-3">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
<div className="font-mono text-sm font-semibold select-all">{createdUser.username}</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_password")}</Label>
<div className="font-mono text-sm font-semibold text-emerald-600 dark:text-emerald-400 select-all">
{generatedPassword}
</div>
</div>
</div>
<DialogFooter className="flex flex-row justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCopy} className="gap-1.5">
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
{copied ? t("common:copied") || "已复制" : t("common:copy") || "复制凭据"}
</Button>
<Button type="button" onClick={handleDone}>
{t("common:done") || "完成"}
</Button>
</DialogFooter>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_username")} *
</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("users_create_username_placeholder")}
className="h-9 text-sm"
autoFocus
/>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_username")} *
</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("users_create_username_placeholder")}
className="h-9 text-sm"
autoFocus
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_email")}
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="h-9 text-sm"
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_email")}
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="h-9 text-sm"
/>
</div>
{/* Role */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_role")}
</Label>
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("users_role_user")}</SelectItem>
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Role */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{t("users_field_role")}
</Label>
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("users_role_user")}</SelectItem>
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Must change password toggle */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={mustChange}
onChange={(e) => setMustChange(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm text-foreground">
{t("users_create_must_change")}
</span>
</label>
{err && <MessageLine kind="error" message={err} />}
{err && <MessageLine kind="error" message={err} />}
<DialogFooter>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("users_create_btn")}
</Button>
</DialogFooter>
</form>
)}
<DialogFooter>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("users_create_btn")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
}
+2 -2
View File
@@ -237,10 +237,10 @@ export function EditServerDialog({
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="es-cpu">CPU 限制</Label>
<Label htmlFor="es-cpu">{t("edit_server_cpu")}</Label>
<Input
id="es-cpu"
placeholder='例如 1, 2, 500m'
placeholder={t("edit_server_cpu_placeholder")}
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
/>
+1 -3
View File
@@ -8,7 +8,6 @@ import { useTier } from "@/lib/tier";
//
// loading → a full-screen spinner (never flash login during boot /me)
// unauthenticated → /login (a genuine 401: no/expired session)
// mustChangePassword → /change-password (forced first-login change)
// otherwise → render the app (<Outlet/>)
//
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
@@ -16,7 +15,7 @@ import { useTier } from "@/lib/tier";
// app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier();
const { loading, unauthenticated } = useTier();
const { t } = useTranslation("common");
if (loading) {
@@ -28,6 +27,5 @@ export function RequireAuth() {
);
}
if (unauthenticated) return <Navigate to="/login" replace />;
if (mustChangePassword) return <Navigate to="/change-password" replace />;
return <Outlet />;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<div className="flex items-center gap-1.5 shrink-0 mr-1">
<span>
{running
? `${server.players ?? 0}${server.maxPlayers ? `/${server.maxPlayers}` : ""}`
? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}`
: "—"}
</span>
<Users className="h-3.5 w-3.5" />