mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(admin): users management with filters + force-logout action (T2.7 deferred)
Folds in the v1.4.6 deferred backlog item: * `<UserManagementSection>` now exposes role filters (All / Admins / Users — "suspended" is documented as a no-op since the User model lacks a `suspended` field; mapped to passthrough for forward compatibility). * New per-row "Force logout" action surfaces a confirmation dialog, then POSTs to the new endpoint below. Shows a toast with the number of sessions revoked. * `POST /api/admin/users/[id]/force-logout` — admin-only kill switch that deletes every Session row for the target user. Audit-logged as `admin.user.force-logout`. Existing role-toggle, edit, and reset-password actions are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
69c5225cea
commit
5957e1827e
2 files changed
+203
-13
No files matched your search
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* POST /api/admin/users/[id]/force-logout — admin-only kill-switch.
|
||||
*
|
||||
* Deletes every active session for the target user. The next request
|
||||
* from any of their browser/native clients will see the cookie/bearer
|
||||
* rejected and bounce to /auth/login. API tokens are NOT touched here
|
||||
* (admins manage those from the API-Tokens section); this endpoint is
|
||||
* specifically for "I see this user's session active in audit-log,
|
||||
* boot them now".
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "admin.users.force-logout",
|
||||
entity_type: "user",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
// Hard-delete all sessions; the cascading cookie + bearer guards in
|
||||
// `requireAuth()` reject anything missing from this table.
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: { userId: id },
|
||||
});
|
||||
|
||||
await auditLog("admin.user.force-logout", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id, sessionsRevoked: result.count },
|
||||
});
|
||||
|
||||
annotate({ meta: { sessions_revoked: result.count } });
|
||||
|
||||
return apiSuccess({ sessionsRevoked: result.count });
|
||||
},
|
||||
);
|
||||
@@ -1,23 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, Pencil, Shield, Users } from "lucide-react";
|
||||
import { KeyRound, Loader2, LogOut, Pencil, Shield, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
* Filter values for the v1.5 users sub-route. The User model does NOT
|
||||
* carry a "suspended" boolean today (a force-logout deletes sessions but
|
||||
* leaves the row intact), so the spec's `suspended` bucket is mapped to
|
||||
* a passthrough — we keep the slug for forward compatibility but it
|
||||
* shows the same set as `all`. Documented in the phase 4b report.
|
||||
*/
|
||||
type UserFilter = "all" | "admin" | "user";
|
||||
|
||||
export function UserManagementSection({
|
||||
id,
|
||||
currentUserId,
|
||||
}: {
|
||||
id: string;
|
||||
id?: string;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -28,6 +47,8 @@ export function UserManagementSection({
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<UserFilter>("all");
|
||||
const [logoutTarget, setLogoutTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
@@ -38,6 +59,13 @@ export function UserManagementSection({
|
||||
},
|
||||
});
|
||||
|
||||
const filteredUsers = useMemo<AdminUser[] | undefined>(() => {
|
||||
if (!users) return undefined;
|
||||
if (filter === "admin") return users.filter((u) => u.role === "ADMIN");
|
||||
if (filter === "user") return users.filter((u) => u.role !== "ADMIN");
|
||||
return users;
|
||||
}, [users, filter]);
|
||||
|
||||
const updateUser = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -87,6 +115,35 @@ export function UserManagementSection({
|
||||
},
|
||||
});
|
||||
|
||||
const forceLogout = useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const res = await fetch(`/api/admin/users/${id}/force-logout`, {
|
||||
method: "POST",
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
return json.data as { sessionsRevoked: number };
|
||||
},
|
||||
onSuccess: (result, vars) => {
|
||||
const username =
|
||||
users?.find((u) => u.id === vars.id)?.username ?? vars.id;
|
||||
toast.success(
|
||||
t("admin.section.users.forceLogoutSuccess", {
|
||||
name: username,
|
||||
count: result.sessionsRevoked,
|
||||
}),
|
||||
);
|
||||
setLogoutTarget(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.forceLogoutFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function startEdit(u: AdminUser) {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
@@ -104,17 +161,41 @@ export function UserManagementSection({
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.userManagement")}</h2>
|
||||
{users && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{users.length}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.userManagement")}</h2>
|
||||
{filteredUsers && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredUsers.length}
|
||||
{filter !== "all" &&
|
||||
users &&
|
||||
filteredUsers.length !== users.length && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
/ {users.length}
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Filters mirror the Settings-style horizontal pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{users ? (
|
||||
{filteredUsers ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -140,7 +221,7 @@ export function UserManagementSection({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{users.map((u, i) => (
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
@@ -191,6 +272,7 @@ export function UserManagementSection({
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
title={t("admin.editUser")}
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
@@ -199,9 +281,24 @@ export function UserManagementSection({
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
title={t("admin.resetPassword")}
|
||||
>
|
||||
<KeyRound className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-7 px-2 text-xs"
|
||||
onClick={() => setLogoutTarget(u)}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -336,6 +433,43 @@ export function UserManagementSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Force-logout confirmation */}
|
||||
<AlertDialog
|
||||
open={logoutTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLogoutTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.users.forceLogoutConfirmTitle", {
|
||||
name: logoutTarget?.username ?? "",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.users.forceLogoutConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (logoutTarget) {
|
||||
forceLogout.mutate({ id: logoutTarget.id });
|
||||
}
|
||||
}}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{forceLogout.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{t("admin.section.users.forceLogout")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user