mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
feat: update authentication for the web app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user