user management

This commit is contained in:
2026-03-15 14:47:58 -05:00
parent 857d34397e
commit 3197e68749
14 changed files with 1175 additions and 95 deletions

View File

@@ -90,6 +90,9 @@ export function AdminDiagnosticsPage() {
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>

View File

@@ -151,12 +151,17 @@ export function CompanySettingsPage() {
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
<h3 className="mt-2 text-lg font-bold text-text">Diagnostics and audit trail</h3>
<p className="mt-2 text-sm text-muted">Review runtime footprint and recent change activity from the admin diagnostics surface.</p>
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div>
</section>
) : null}

View File

@@ -0,0 +1,363 @@
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
const emptyUserForm: AdminUserInput = {
email: "",
firstName: "",
lastName: "",
isActive: true,
roleIds: [],
password: "",
};
const emptyRoleForm: AdminRoleInput = {
name: "",
description: "",
permissionKeys: [],
};
export function UserManagementPage() {
const { token } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
useEffect(() => {
if (!token) {
return;
}
let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
.then(([nextUsers, nextRoles, nextPermissions]) => {
if (!active) {
return;
}
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus("User management loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load admin access controls.");
});
return () => {
active = false;
};
}, [token]);
useEffect(() => {
if (selectedUserId === "new") {
setUserForm(emptyUserForm);
return;
}
const selectedUser = users.find((user) => user.id === selectedUserId);
if (!selectedUser) {
setUserForm(emptyUserForm);
return;
}
setUserForm({
email: selectedUser.email,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
isActive: selectedUser.isActive,
roleIds: selectedUser.roleIds,
password: "",
});
}, [selectedUserId, users]);
useEffect(() => {
if (selectedRoleId === "new") {
setRoleForm(emptyRoleForm);
return;
}
const selectedRole = roles.find((role) => role.id === selectedRoleId);
if (!selectedRole) {
setRoleForm(emptyRoleForm);
return;
}
setRoleForm({
name: selectedRole.name,
description: selectedRole.description,
permissionKeys: selectedRole.permissionKeys,
});
}, [roles, selectedRoleId]);
if (!token) {
return null;
}
const authToken = token;
async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
api.getAdminUsers(authToken),
api.getAdminRoles(authToken),
api.getAdminPermissions(authToken),
]);
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus(nextStatus);
}
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, userForm);
await refreshData(`Created user ${createdUser.email}.`);
setSelectedUserId(createdUser.id);
return;
}
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, userForm);
await refreshData(`Updated user ${updatedUser.email}.`);
setSelectedUserId(updatedUser.id);
}
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedRoleId === "new") {
const createdRole = await api.createAdminRole(authToken, roleForm);
await refreshData(`Created role ${createdRole.name}.`);
setSelectedRoleId(createdRole.id);
return;
}
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
await refreshData(`Updated role ${updatedRole.name}.`);
setSelectedRoleId(updatedRole.id);
}
function toggleUserRole(roleId: string) {
setUserForm((current) => ({
...current,
roleIds: current.roleIds.includes(roleId)
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
: [...current.roleIds, roleId],
}));
}
function toggleRolePermission(permissionKey: string) {
setRoleForm((current) => ({
...current,
permissionKeys: current.permissionKeys.includes(permissionKey)
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
: [...current.permissionKeys, permissionKey],
}));
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p>
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Diagnostics
</Link>
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
</div>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New user</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName} ({user.email})
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={userForm.password ?? ""}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
<input
value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
<input
value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.isActive}
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
/>
User can sign in
</label>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Assigned roles</p>
<div className="mt-3 grid gap-3">
{roles.map((role) => (
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.roleIds.includes(role.id)}
onChange={() => toggleUserRole(role.id)}
/>
<span>
<span className="block font-semibold">{role.name}</span>
<span className="block text-xs text-muted">{role.description || "No description"}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"}
</button>
</div>
</form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
</div>
<select
value={selectedRoleId}
onChange={(event) => setSelectedRoleId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
<input
value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
rows={3}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Role permissions</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{permissions.map((permission) => (
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)}
onChange={() => toggleRolePermission(permission.key)}
/>
<span>
<span className="block font-semibold">{permission.key}</span>
<span className="block text-xs text-muted">{permission.description}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{roles.map((role) => (
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">{role.name}</p>
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
</div>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"}
</button>
</div>
</form>
</section>
</div>
);
}