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
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user