cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
1007 lines
45 KiB
TypeScript
1007 lines
45 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@capakraken/shared";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
|
import { FilterChips } from "~/components/ui/FilterChips.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
|
import { useTableSort } from "~/hooks/useTableSort.js";
|
|
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
|
|
|
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
|
|
|
const PERMISSION_LABELS: Record<string, string> = {
|
|
viewCosts: "View Costs",
|
|
exportData: "Export Data",
|
|
importData: "Import Data",
|
|
approveVacations: "Approve Vacations",
|
|
manageBlueprints: "Manage Blueprints",
|
|
viewAllResources: "View All Resources",
|
|
manageResources: "Manage Resources",
|
|
manageProjects: "Manage Projects",
|
|
manageAllocations: "Manage Allocations",
|
|
manageRoles: "Manage Roles",
|
|
manageUsers: "Manage Users",
|
|
};
|
|
|
|
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
|
[SystemRole.ADMIN]: "Admin",
|
|
[SystemRole.MANAGER]: "Manager",
|
|
[SystemRole.CONTROLLER]: "Controller",
|
|
[SystemRole.USER]: "User",
|
|
[SystemRole.VIEWER]: "Viewer",
|
|
};
|
|
|
|
const ROLE_BADGE_COLORS: Record<SystemRole, string> = {
|
|
[SystemRole.ADMIN]: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
|
|
[SystemRole.MANAGER]: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
|
[SystemRole.CONTROLLER]: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
|
[SystemRole.USER]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
|
[SystemRole.VIEWER]: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
|
|
};
|
|
|
|
// Lower = more privileged (sort asc = most privileged first)
|
|
const ROLE_ORDER: Record<string, number> = {
|
|
ADMIN: 0,
|
|
MANAGER: 1,
|
|
CONTROLLER: 2,
|
|
USER: 3,
|
|
VIEWER: 4,
|
|
};
|
|
|
|
type UserRow = {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
systemRole: string;
|
|
createdAt: Date;
|
|
lastLoginAt: Date | null;
|
|
lastActiveAt: Date | null;
|
|
permissionOverrides: PermissionOverrides | null;
|
|
};
|
|
|
|
type EditState = {
|
|
userId: string;
|
|
systemRole: SystemRole;
|
|
granted: Set<string>;
|
|
denied: Set<string>;
|
|
chapterIds: string;
|
|
};
|
|
|
|
type CreateState = {
|
|
name: string;
|
|
email: string;
|
|
password: string;
|
|
systemRole: SystemRole;
|
|
};
|
|
|
|
const EMPTY_CREATE: CreateState = {
|
|
name: "",
|
|
email: "",
|
|
password: "",
|
|
systemRole: SystemRole.USER,
|
|
};
|
|
|
|
export function UsersClient() {
|
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
const [editState, setEditState] = useState<EditState | null>(null);
|
|
const [createState, setCreateState] = useState<CreateState | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
|
const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null);
|
|
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null);
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
const { data: users, isLoading } = trpc.user.list.useQuery(undefined, {
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const { data: roleConfigs } = trpc.systemRoleConfig.list.useQuery(undefined, {
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Build dynamic role defaults map from DB config (fallback to hardcoded)
|
|
const roleDefaultsMap = useMemo(() => {
|
|
if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS;
|
|
const map: Record<string, string[]> = {};
|
|
for (const c of roleConfigs) {
|
|
map[c.role] = c.defaultPermissions as string[];
|
|
}
|
|
return map as Record<SystemRole, string[]>;
|
|
}, [roleConfigs]);
|
|
|
|
const { data: activeData } = trpc.user.activeCount.useQuery(undefined, {
|
|
staleTime: 30_000,
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
|
|
{ userId: selectedUserId ?? "" },
|
|
{ enabled: !!selectedUserId },
|
|
);
|
|
|
|
const updateRoleMutation = trpc.user.updateRole.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
await utils.user.getEffectivePermissions.invalidate();
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
|
|
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
await utils.user.getEffectivePermissions.invalidate();
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
const createUserMutation = trpc.user.create.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
setCreateState(null);
|
|
setActionError(null);
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
const autoLinkMutation = trpc.user.autoLinkAllByEmail.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
await utils.user.getEffectivePermissions.invalidate();
|
|
if (editState) {
|
|
setEditState({ ...editState, granted: new Set(), denied: new Set(), chapterIds: "" });
|
|
}
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
const setPasswordMutation = trpc.user.setPassword.useMutation({
|
|
onSuccess: () => {
|
|
setPasswordSuccess(true);
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setPasswordError(null);
|
|
setTimeout(() => {
|
|
setPasswordTarget(null);
|
|
setPasswordSuccess(false);
|
|
}, 1500);
|
|
},
|
|
onError: (err) => setPasswordError(err.message),
|
|
});
|
|
|
|
const updateNameMutation = trpc.user.updateName.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.user.list.invalidate();
|
|
setEditingName(null);
|
|
},
|
|
onError: (err) => setActionError(err.message),
|
|
});
|
|
|
|
function openSetPassword(user: UserRow) {
|
|
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setPasswordError(null);
|
|
setPasswordSuccess(false);
|
|
}
|
|
|
|
function closeSetPassword() {
|
|
setPasswordTarget(null);
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setPasswordError(null);
|
|
setPasswordSuccess(false);
|
|
}
|
|
|
|
async function handleSetPassword() {
|
|
if (!passwordTarget) return;
|
|
if (newPassword.length < 8) {
|
|
setPasswordError("Password must be at least 8 characters");
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError("Passwords do not match");
|
|
return;
|
|
}
|
|
setPasswordError(null);
|
|
await setPasswordMutation.mutateAsync({
|
|
userId: passwordTarget.userId,
|
|
password: newPassword,
|
|
});
|
|
}
|
|
|
|
function openEdit(user: UserRow) {
|
|
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
|
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
|
setSelectedUserId(user.id);
|
|
setEditState({
|
|
userId: user.id,
|
|
systemRole: role,
|
|
granted: new Set(overrides?.granted ?? []),
|
|
denied: new Set(overrides?.denied ?? []),
|
|
chapterIds: (overrides?.chapterIds ?? []).join(", "),
|
|
});
|
|
setActionError(null);
|
|
}
|
|
|
|
function closeEdit() {
|
|
setSelectedUserId(null);
|
|
setEditState(null);
|
|
setActionError(null);
|
|
}
|
|
|
|
function toggleGranted(key: string) {
|
|
if (!editState) return;
|
|
const next = new Set(editState.granted);
|
|
const nextDenied = new Set(editState.denied);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
nextDenied.delete(key);
|
|
}
|
|
setEditState({ ...editState, granted: next, denied: nextDenied });
|
|
}
|
|
|
|
function toggleDenied(key: string) {
|
|
if (!editState) return;
|
|
const next = new Set(editState.denied);
|
|
const nextGranted = new Set(editState.granted);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
nextGranted.delete(key);
|
|
}
|
|
setEditState({ ...editState, denied: next, granted: nextGranted });
|
|
}
|
|
|
|
async function handleSaveRole() {
|
|
if (!editState) return;
|
|
setActionError(null);
|
|
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
|
|
}
|
|
|
|
async function handleSavePermissions() {
|
|
if (!editState) return;
|
|
setActionError(null);
|
|
const granted = Array.from(editState.granted);
|
|
const denied = Array.from(editState.denied);
|
|
const chapterIds = editState.chapterIds
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
const overrides: PermissionOverrides = {
|
|
...(granted.length > 0 ? { granted: granted as unknown as PermissionKey[] } : {}),
|
|
...(denied.length > 0 ? { denied: denied as unknown as PermissionKey[] } : {}),
|
|
...(chapterIds.length > 0 ? { chapterIds } : {}),
|
|
};
|
|
const hasOverrides = granted.length > 0 || denied.length > 0 || chapterIds.length > 0;
|
|
await setPermissionsMutation.mutateAsync({
|
|
userId: editState.userId,
|
|
overrides: hasOverrides ? overrides : null,
|
|
});
|
|
}
|
|
|
|
async function handleReset() {
|
|
if (!editState) return;
|
|
setActionError(null);
|
|
await resetPermissionsMutation.mutateAsync({ userId: editState.userId });
|
|
}
|
|
|
|
const allUsers = (users ?? []) as unknown as UserRow[];
|
|
|
|
// Client-side filtering
|
|
const filteredUsers = allUsers.filter((u) => {
|
|
if (search) {
|
|
const q = search.toLowerCase();
|
|
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
|
|
}
|
|
if (roleFilter && u.systemRole !== roleFilter) return false;
|
|
return true;
|
|
});
|
|
|
|
const usersViewPrefs = useViewPrefs("users");
|
|
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredUsers, {
|
|
initialField: usersViewPrefs.savedSort?.field ?? null,
|
|
initialDir: usersViewPrefs.savedSort?.dir ?? null,
|
|
onSortChange: (field, dir) => {
|
|
usersViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
|
},
|
|
});
|
|
|
|
function handleSort(field: string) {
|
|
if (field === "systemRole") {
|
|
toggle("systemRole", (u) => ROLE_ORDER[u.systemRole] ?? 99);
|
|
} else {
|
|
toggle(field as keyof UserRow);
|
|
}
|
|
}
|
|
|
|
const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null;
|
|
|
|
async function handleCreateUser() {
|
|
if (!createState) return;
|
|
setActionError(null);
|
|
await createUserMutation.mutateAsync({
|
|
name: createState.name,
|
|
email: createState.email,
|
|
password: createState.password,
|
|
systemRole: createState.systemRole,
|
|
});
|
|
}
|
|
|
|
const isPending =
|
|
updateRoleMutation.isPending ||
|
|
setPermissionsMutation.isPending ||
|
|
resetPermissionsMutation.isPending ||
|
|
createUserMutation.isPending ||
|
|
setPasswordMutation.isPending;
|
|
|
|
function clearAll() {
|
|
setSearch("");
|
|
setRoleFilter("");
|
|
}
|
|
|
|
const chips = [
|
|
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
|
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
|
|
];
|
|
|
|
function isOnline(user: UserRow) {
|
|
if (!user.lastActiveAt) return false;
|
|
return Date.now() - new Date(user.lastActiveAt).getTime() < 5 * 60 * 1000;
|
|
}
|
|
|
|
function formatRelativeTime(date: Date | null) {
|
|
if (!date) return "Never";
|
|
const d = new Date(date);
|
|
const diff = Date.now() - d.getTime();
|
|
if (diff < 60_000) return "Just now";
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">User Management</h1>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Manage user roles and permission overrides
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{activeData && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-3 py-2 text-sm">
|
|
<span className="relative flex h-2.5 w-2.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
|
</span>
|
|
<span className="font-medium text-green-700 dark:text-green-400">
|
|
{activeData.count} online
|
|
</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
|
|
setActionError(r.linked > 0 ? null : `No unlinked accounts found (checked ${r.checked})`);
|
|
if (r.linked > 0) setActionError(null);
|
|
})}
|
|
disabled={autoLinkMutation.isPending}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
|
title="Auto-link user accounts to resources by matching email addresses"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
{autoLinkMutation.isPending ? "Linking..." : "Auto-link Resources"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setCreateState({ ...EMPTY_CREATE }); setActionError(null); }}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Create User
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3 mb-3">
|
|
<input
|
|
type="search"
|
|
placeholder="Search by name or email…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
|
|
/>
|
|
<select
|
|
value={roleFilter}
|
|
onChange={(e) => setRoleFilter(e.target.value as SystemRole | "")}
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 bg-white dark:bg-gray-900 dark:text-gray-100"
|
|
>
|
|
<option value="">All Roles</option>
|
|
{Object.values(SystemRole).map((role) => (
|
|
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{chips.length > 0 && (
|
|
<div className="mb-3">
|
|
<FilterChips chips={chips} onClearAll={clearAll} />
|
|
</div>
|
|
)}
|
|
|
|
{actionError && (
|
|
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
|
{actionError}
|
|
<button
|
|
type="button"
|
|
onClick={() => setActionError(null)}
|
|
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* User Table */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
|
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
|
|
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
|
|
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
|
|
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th>
|
|
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." />
|
|
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
|
|
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading && (
|
|
<tr>
|
|
<td colSpan={7} className="text-center py-8 text-gray-400">
|
|
Loading…
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!isLoading && sorted.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="text-center py-8 text-gray-400">
|
|
No users found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{sorted.map((user) => (
|
|
<tr
|
|
key={user.id}
|
|
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
|
>
|
|
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
|
{user.name ?? <span className="italic text-gray-400">—</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email}</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER]
|
|
}`}
|
|
>
|
|
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{isOnline(user) ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
|
Online
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
|
|
Offline
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
|
{formatRelativeTime(user.lastLoginAt)}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
|
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => openSetPassword(user)}
|
|
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
|
|
title="Set password"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
</svg>
|
|
Password
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => openEdit(user)}
|
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Set Password Modal */}
|
|
<AnimatedModal open={!!passwordTarget} onClose={closeSetPassword} maxWidth="max-w-md">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Set Password for {passwordTarget?.userName}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
{passwordError && (
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
|
{passwordError}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
New Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Min. 8 characters"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
autoComplete="new-password"
|
|
/>
|
|
{newPassword.length > 0 && newPassword.length < 8 && (
|
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
|
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Confirm Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder="Repeat password"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
autoComplete="new-password"
|
|
/>
|
|
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
Passwords do not match
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
onClick={closeSetPassword}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleSetPassword()}
|
|
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
|
</button>
|
|
</div>
|
|
</AnimatedModal>
|
|
|
|
<SuccessToast show={passwordSuccess} message="Password updated successfully" />
|
|
|
|
{/* Create User Modal */}
|
|
{createState && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Create User
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setCreateState(null); setActionError(null); }}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
{actionError && (
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Name <InfoTooltip content="The display name for this user account." />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={createState.name}
|
|
onChange={(e) => setCreateState({ ...createState, name: e.target.value })}
|
|
placeholder="Max Mustermann"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={createState.email}
|
|
onChange={(e) => setCreateState({ ...createState, email: e.target.value })}
|
|
placeholder="user@example.com"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={createState.password}
|
|
onChange={(e) => setCreateState({ ...createState, password: e.target.value })}
|
|
placeholder="Min. 8 characters"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
|
|
</label>
|
|
<select
|
|
value={createState.systemRole}
|
|
onChange={(e) => setCreateState({ ...createState, systemRole: e.target.value as SystemRole })}
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
>
|
|
{Object.values(SystemRole).map((role) => (
|
|
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setCreateState(null); setActionError(null); }}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCreateUser()}
|
|
disabled={isPending || !createState.name.trim() || !createState.email.trim() || createState.password.length < 8}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{createUserMutation.isPending ? "Creating..." : "Create User"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editState && selectedUser && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]">
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Edit User
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{selectedUser.name ?? selectedUser.email}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={closeEdit}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body */}
|
|
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
|
|
{/* User Name */}
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
Display Name
|
|
</h3>
|
|
{editingName?.userId === editState.userId ? (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={editingName.name}
|
|
onChange={(e) => setEditingName({ ...editingName, name: e.target.value })}
|
|
className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && editingName.name.trim()) {
|
|
updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() });
|
|
}
|
|
if (e.key === "Escape") setEditingName(null);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })}
|
|
disabled={!editingName.name.trim() || updateNameMutation.isPending}
|
|
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
|
|
>
|
|
{updateNameMutation.isPending ? "..." : "Save"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingName(null)}
|
|
className="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
|
{(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const user = (users as any)?.find((u: any) => u.id === editState.userId);
|
|
setEditingName({ userId: editState.userId, name: user?.name ?? "" });
|
|
}}
|
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* System Role */}
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
|
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={editState.systemRole}
|
|
onChange={(e) =>
|
|
setEditState({ ...editState, systemRole: e.target.value as SystemRole })
|
|
}
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
>
|
|
{Object.values(SystemRole).map((role) => (
|
|
<option key={role} value={role}>
|
|
{SYSTEM_ROLE_LABELS[role]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveRole}
|
|
disabled={isPending}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{updateRoleMutation.isPending ? "Saving…" : "Save Role"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Permissions */}
|
|
<section>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
|
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
|
|
</h3>
|
|
<div className="flex gap-1.5 mb-3 text-[11px]">
|
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
|
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
|
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
|
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">×</span></span> Denied
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{ALL_PERMISSION_KEYS.map((key) => {
|
|
const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []);
|
|
const isRoleDefault = roleDefaults.has(key as PermissionKey);
|
|
const isGranted = editState.granted.has(key);
|
|
const isDenied = editState.denied.has(key);
|
|
|
|
// Determine display state
|
|
let state: "default" | "granted" | "denied" | "off";
|
|
if (isDenied) state = "denied";
|
|
else if (isGranted) state = "granted";
|
|
else if (isRoleDefault) state = "default";
|
|
else state = "off";
|
|
|
|
function cycleState() {
|
|
if (!editState) return;
|
|
const nextGranted = new Set(editState.granted);
|
|
const nextDenied = new Set(editState.denied);
|
|
|
|
if (isRoleDefault) {
|
|
// Role default: off → denied → off
|
|
if (isDenied) {
|
|
nextDenied.delete(key);
|
|
} else {
|
|
nextDenied.add(key);
|
|
nextGranted.delete(key);
|
|
}
|
|
} else {
|
|
// Non-default: off → granted → off
|
|
if (isGranted) {
|
|
nextGranted.delete(key);
|
|
} else {
|
|
nextGranted.add(key);
|
|
nextDenied.delete(key);
|
|
}
|
|
}
|
|
setEditState({ ...editState, granted: nextGranted, denied: nextDenied });
|
|
}
|
|
|
|
const stateStyles = {
|
|
default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
|
|
granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
|
|
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
|
|
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
|
|
};
|
|
|
|
const checkStyles = {
|
|
default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40",
|
|
granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40",
|
|
denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40",
|
|
off: "text-gray-400 border-gray-300 dark:border-gray-600",
|
|
};
|
|
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={cycleState}
|
|
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
|
|
>
|
|
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
|
|
{state === "default" && (
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
|
)}
|
|
{state === "granted" && (
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
|
|
)}
|
|
{state === "denied" && (
|
|
<span className="text-xs font-bold leading-none">×</span>
|
|
)}
|
|
</span>
|
|
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
|
{PERMISSION_LABELS[key] ?? key}
|
|
</span>
|
|
{state === "default" && (
|
|
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
|
|
)}
|
|
{state === "granted" && (
|
|
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
|
|
)}
|
|
{state === "denied" && (
|
|
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Chapter Scope */}
|
|
<div className="mt-4">
|
|
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
|
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editState.chapterIds}
|
|
onChange={(e) => setEditState({ ...editState, chapterIds: e.target.value })}
|
|
placeholder="e.g. chapter-1, chapter-2"
|
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Modal Footer */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleReset}
|
|
disabled={isPending}
|
|
className="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-200 dark:border-red-700 hover:border-red-300 dark:hover:border-red-600 rounded-lg disabled:opacity-50"
|
|
>
|
|
{resetPermissionsMutation.isPending ? "Resetting…" : "Reset to Defaults"}
|
|
</button>
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={closeEdit}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
Close
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSavePermissions}
|
|
disabled={isPending}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{setPermissionsMutation.isPending ? "Saving…" : "Save Permissions"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|