feat: update authentication for the web app

This commit is contained in:
2026-07-11 12:58:56 +07:00
parent 9845a1702f
commit 8cd10aefdc
20 changed files with 1063 additions and 57 deletions
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Card, Input } from "@/shared/components";
import Modal, { ConfirmModal } from "@/shared/components/Modal";
import useUserStore from "@/store/userStore";
const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true };
function formatDate(value) {
if (!value) return "—";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export default function UsersPage() {
const router = useRouter();
const user = useUserStore((state) => state.user);
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [editor, setEditor] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState(null);
const loadUsers = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await fetch("/api/users", { cache: "no-store" });
if (response.status === 403) {
router.replace("/dashboard");
return;
}
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Failed to load users");
setUsers(data.users || []);
} catch (requestError) {
setError(requestError.message || "Failed to load users");
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
if (!user) fetchCurrentUser();
}, [fetchCurrentUser, user]);
useEffect(() => {
if (!user) return undefined;
if (user.role !== "admin") {
router.replace("/dashboard");
return undefined;
}
const frameId = window.requestAnimationFrame(() => { void loadUsers(); });
return () => window.cancelAnimationFrame(frameId);
}, [loadUsers, router, user]);
const openCreate = () => {
setError("");
setForm(EMPTY_FORM);
setEditor({ mode: "create" });
};
const openEdit = (target) => {
setError("");
setForm({ username: target.username, password: "", role: target.role, isActive: target.isActive });
setEditor({ mode: "edit", user: target });
};
const saveUser = async (event) => {
event.preventDefault();
setSaving(true);
setError("");
try {
const isCreate = editor.mode === "create";
const payload = { ...form };
if (!payload.password) delete payload.password;
const response = await fetch(isCreate ? "/api/users" : `/api/users/${editor.user.id}`, {
method: isCreate ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Failed to save user");
setEditor(null);
await loadUsers();
} catch (requestError) {
setError(requestError.message || "Failed to save user");
} finally {
setSaving(false);
}
};
const deleteUser = async () => {
if (!deleteTarget) return;
setSaving(true);
setError("");
try {
const response = await fetch(`/api/users/${deleteTarget.id}`, { method: "DELETE" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Failed to delete user");
setDeleteTarget(null);
await loadUsers();
} catch (requestError) {
setError(requestError.message || "Failed to delete user");
} finally {
setSaving(false);
}
};
if (!user || user.role !== "admin") {
return <div className="py-12 text-center text-text-muted">Loading user management</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Administration</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-text-main">Users</h1>
<p className="mt-1 text-sm text-text-muted">Manage dashboard accounts and access roles.</p>
</div>
<Button variant="primary" onClick={openCreate}>
<span className="material-symbols-outlined text-[18px]">person_add</span>
Add user
</Button>
</div>
{error ? <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">{error}</p> : null}
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border-subtle bg-surface-2/50 text-xs uppercase tracking-wide text-text-muted">
<tr>
<th className="px-5 py-3 font-medium">Username</th>
<th className="px-5 py-3 font-medium">Role</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium">Created</th>
<th className="px-5 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{loading ? (
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">Loading users</td></tr>
) : users.length === 0 ? (
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">No users found.</td></tr>
) : users.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-surface-2/40">
<td className="px-5 py-4 font-medium text-text-main">{entry.username}{entry.id === user.id ? <span className="ml-2 text-xs font-normal text-text-muted">(you)</span> : null}</td>
<td className="px-5 py-4"><span className={`rounded-full px-2 py-1 text-xs font-medium ${entry.role === "admin" ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"}`}>{entry.role}</span></td>
<td className="px-5 py-4"><span className={entry.isActive ? "text-emerald-600 dark:text-emerald-400" : "text-text-muted"}>{entry.isActive ? "Active" : "Disabled"}</span></td>
<td className="px-5 py-4 text-text-muted">{formatDate(entry.createdAt)}</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(entry)}>Edit</Button>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700" onClick={() => setDeleteTarget(entry)} disabled={entry.id === user.id}>Delete</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Modal
isOpen={!!editor}
onClose={() => !saving && setEditor(null)}
title={editor?.mode === "create" ? "Add user" : `Edit ${editor?.user?.username || "user"}`}
footer={<><Button variant="ghost" onClick={() => setEditor(null)} disabled={saving}>Cancel</Button><Button variant="primary" type="submit" form="user-editor" loading={saving}>{editor?.mode === "create" ? "Create user" : "Save changes"}</Button></>}
>
<form id="user-editor" className="space-y-4" onSubmit={saveUser}>
<div className="space-y-2"><label className="text-sm font-medium">Username</label><Input value={form.username} onChange={(event) => setForm((current) => ({ ...current, username: event.target.value }))} minLength="3" required autoFocus /></div>
<div className="space-y-2"><label className="text-sm font-medium">{editor?.mode === "create" ? "Password" : "New password (optional)"}</label><Input type="password" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} minLength="6" required={editor?.mode === "create"} autoComplete="new-password" /></div>
<div className="space-y-2"><label className="text-sm font-medium">Role</label><select value={form.role} onChange={(event) => setForm((current) => ({ ...current, role: event.target.value }))} className="w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-sm text-text-main"><option value="user">User</option><option value="admin">Administrator</option></select></div>
{editor?.mode === "edit" ? <label className="flex items-center gap-2 text-sm text-text-main"><input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} /> Account is active</label> : null}
</form>
</Modal>
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => !saving && setDeleteTarget(null)}
onConfirm={deleteUser}
title="Delete user"
message={`Delete ${deleteTarget?.username || "this user"}? This cannot be undone.`}
confirmText="Delete user"
loading={saving}
/>
</div>
);
}
+18 -18
View File
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { isOidcConfigured } from "@/lib/auth/oidc";
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
import { isLocalRequest } from "@/dashboardGuard";
import { verifyUserCredentials } from "@/lib/db";
const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default.";
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
@@ -28,7 +28,7 @@ export async function POST(request) {
);
}
const { password } = await request.json();
const { username, password } = await request.json();
const settings = await getSettings();
// Block login via tunnel/tailscale if dashboard access is disabled
@@ -36,33 +36,33 @@ export async function POST(request) {
return NextResponse.json({ error: "Dashboard access via tunnel is disabled" }, { status: 403 });
}
// Default password is '123456' if not set
const storedHash = settings.password;
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}
let isValid = false;
if (storedHash) {
isValid = await bcrypt.compare(password, storedHash);
} else {
// Use env var or default
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
isValid = password === initialPassword;
}
const user = await verifyUserCredentials(username, password);
if (isValid) {
if (user) {
recordSuccess(ip);
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
await setDashboardAuthCookie(cookieStore, request, {
userId: user.id,
username: user.username,
role: user.role,
});
// Default password still in use on a remote client → force a password
// change before the dashboard is exposed remotely (keeps local UX intact).
const mustChangePassword =
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
user.username.toLowerCase() === "admin" &&
!settings.password &&
!process.env.INITIAL_PASSWORD &&
!isLocalRequest(request);
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
return NextResponse.json(
{ success: true, mustChangePassword, user: { id: user.id, username: user.username, role: user.role } },
{ headers: NO_STORE_HEADERS }
);
}
const { remainingBeforeLock } = recordFail(ip);
@@ -74,7 +74,7 @@ export async function POST(request) {
);
}
return NextResponse.json(
{ error: `Invalid password. ${remainingBeforeLock} attempt(s) left before lockout.`, remainingBeforeLock },
{ error: `Invalid username or password. ${remainingBeforeLock} attempt(s) left before lockout.`, remainingBeforeLock },
{ status: 401 }
);
} catch (error) {
+3 -4
View File
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server";
import { updateSettings } from "@/lib/localDb";
import { resetAdminPassword } from "@/lib/db";
// Reset dashboard password to default by clearing the stored hash.
// Local-only (enforced by dashboardGuard). Never returns the default literal.
// Reset the bootstrap administrator password. Local-only (enforced by dashboardGuard).
export async function POST() {
try {
await updateSettings({ password: null });
await resetAdminPassword(process.env.INITIAL_PASSWORD || "123456");
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
+11 -2
View File
@@ -13,7 +13,10 @@ export async function GET() {
const authMode = settings.authMode || "password";
const oidcName = String(session?.oidcName || "").trim();
const oidcEmail = String(session?.oidcEmail || "").trim();
const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
const userId = String(session?.userId || "").trim();
const username = String(session?.username || "").trim();
const role = session?.role === "admin" ? "admin" : "user";
const displayName = username || oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
const loginMethod = session?.oidc ? "OIDC" : "Password";
return NextResponse.json({
@@ -21,9 +24,12 @@ export async function GET() {
authMode,
oidcConfigured: isOidcConfigured(settings),
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
hasPassword: !!settings.password,
hasPassword: true,
displayName,
loginMethod,
userId: userId || null,
username: username || null,
role: session ? role : null,
oidcName: oidcName || null,
oidcEmail: oidcEmail || null,
oidcLogin: !!session?.oidc,
@@ -37,6 +43,9 @@ export async function GET() {
hasPassword: false,
displayName: "Password user",
loginMethod: "Password",
userId: null,
username: null,
role: null,
oidcName: null,
oidcEmail: null,
oidcLogin: false,
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { exportDb, getSettings, importDb } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import { verifyDashboardPassword } from "@/lib/auth/dashboardSession";
import { verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser";
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const PASSWORD_HEADER = "x-9r-password";
@@ -13,7 +13,7 @@ function isCliRequest(request) {
export async function GET(request) {
try {
if (!isCliRequest(request) && !(await verifyDashboardPassword(request.headers.get(PASSWORD_HEADER)))) {
if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
const payload = await exportDb();
@@ -27,7 +27,7 @@ export async function GET(request) {
export async function POST(request) {
try {
const { password, ...payload } = await request.json();
if (!isCliRequest(request) && !(await verifyDashboardPassword(password))) {
if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(password))) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
await importDb(payload);
+14 -21
View File
@@ -3,7 +3,8 @@ import { getSettings, updateSettings } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import { resetComboRotation } from "open-sse/services/combo.js";
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
import bcrypt from "bcryptjs";
import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
import { updateUser, verifyUserPassword } from "@/lib/db";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@@ -45,28 +46,20 @@ export async function PATCH(request) {
// If updating password, hash it
if (body.newPassword) {
const settings = await getSettings();
const currentHash = settings.password;
// Verify current password if it exists
if (currentHash) {
if (!body.currentPassword) {
return NextResponse.json({ error: "Current password required" }, { status: 400 });
}
const isValid = await bcrypt.compare(body.currentPassword, currentHash);
if (!isValid) {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
} else {
// First time setting password, no current password needed
// Allow empty currentPassword or default "123456"
if (body.currentPassword && body.currentPassword !== "123456") {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
let user;
try {
user = await requireCurrentDashboardUser();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const salt = await bcrypt.genSalt(10);
body.password = await bcrypt.hash(body.newPassword, salt);
if (!body.currentPassword) {
return NextResponse.json({ error: "Current password required" }, { status: 400 });
}
if (!(await verifyUserPassword(user.id, body.currentPassword))) {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
await updateUser(user.id, { password: body.newPassword });
delete body.newPassword;
delete body.currentPassword;
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { countActiveAdmins, deleteUser, getUserById, updateUser } from "@/lib/db";
import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
const EDITABLE_FIELDS = new Set(["username", "password", "role", "isActive"]);
function errorResponse(error) {
const message = error?.message || "Request failed";
const status = message === "Unauthorized" ? 401 : message === "Forbidden" ? 403 : 400;
return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS });
}
async function getTarget(params) {
const { userId } = await params;
const target = await getUserById(userId);
if (!target) throw new Error("User not found");
return target;
}
function wouldRemoveLastActiveAdmin(target, updates, activeAdminCount) {
if (target.role !== "admin" || !target.isActive) return false;
const nextRole = Object.hasOwn(updates, "role") ? updates.role : target.role;
const nextActive = Object.hasOwn(updates, "isActive") ? updates.isActive === true : target.isActive;
return (nextRole !== "admin" || !nextActive) && activeAdminCount <= 1;
}
export async function PATCH(request, { params }) {
try {
const actor = await requireCurrentDashboardUser();
const target = await getTarget(params);
const body = await request.json();
const updates = Object.fromEntries(Object.entries(body).filter(([key]) => EDITABLE_FIELDS.has(key)));
if (actor.role !== "admin") {
const forbiddenChange = Object.hasOwn(updates, "username") || Object.hasOwn(updates, "role") || Object.hasOwn(updates, "isActive");
if (actor.id !== target.id || forbiddenChange) throw new Error("Forbidden");
}
if (
actor.id === target.id &&
(Object.hasOwn(updates, "role") || Object.hasOwn(updates, "isActive"))
) {
throw new Error("You cannot change your own role or account status");
}
if (wouldRemoveLastActiveAdmin(target, updates, await countActiveAdmins())) {
throw new Error("At least one active administrator is required");
}
const user = await updateUser(target.id, updates);
return NextResponse.json({ user }, { headers: NO_STORE_HEADERS });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(request, { params }) {
try {
const actor = await requireCurrentDashboardUser();
if (actor.role !== "admin") throw new Error("Forbidden");
const target = await getTarget(params);
if (target.id === actor.id) throw new Error("You cannot delete your own account");
if (target.role === "admin" && target.isActive && await countActiveAdmins() <= 1) {
throw new Error("At least one active administrator is required");
}
await deleteUser(target.id);
return NextResponse.json({ success: true }, { headers: NO_STORE_HEADERS });
} catch (error) {
return errorResponse(error);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { createUser, getUsers } from "@/lib/db";
import { requireAdminUser } from "@/lib/auth/currentUser";
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
function errorResponse(error) {
const message = error?.message || "Request failed";
const status = message === "Unauthorized" ? 401 : message === "Forbidden" ? 403 : 400;
return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS });
}
export async function GET() {
try {
await requireAdminUser();
return NextResponse.json({ users: await getUsers() }, { headers: NO_STORE_HEADERS });
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request) {
try {
await requireAdminUser();
const { username, password, role } = await request.json();
const user = await createUser({ username, password, role });
return NextResponse.json({ user }, { status: 201, headers: NO_STORE_HEADERS });
} catch (error) {
return errorResponse(error);
}
}
+18 -4
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, Input } from "@/shared/components";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [resetHint, setResetHint] = useState("");
@@ -67,7 +68,7 @@ export default function LoginPage() {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
body: JSON.stringify({ username, password }),
});
if (res.ok) {
@@ -143,7 +144,7 @@ export default function LoginPage() {
<p className="text-text-muted">
{authMode === "oidc" && oidcConfigured
? "Sign in with your OIDC provider to access the dashboard"
: "Enter your password to access the dashboard"}
: "Enter your username and password to access the dashboard"}
</p>
</div>
@@ -193,6 +194,19 @@ export default function LoginPage() {
</p>
)}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Username</label>
<Input
type="text"
placeholder="Enter username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus={!oidcAvailable}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Password</label>
<Input
@@ -201,7 +215,7 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus={!oidcAvailable}
autoComplete="current-password"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
{retryAfter > 0 && (
@@ -227,7 +241,7 @@ export default function LoginPage() {
</Button>
<p className="text-xs text-center text-text-muted mt-2">
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
Default administrator login: <code className="bg-sidebar px-1 rounded">admin</code> / <code className="bg-sidebar px-1 rounded">123456</code>
</p>
{hasPassword === false && (
<p className="text-xs text-center text-amber-600 dark:text-amber-400">