Release v0.1.5
This commit is contained in:
189
webui/src/features/admin/UsersPanel.tsx
Normal file
189
webui/src/features/admin/UsersPanel.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createUser, fetchGroups, fetchRoles, fetchUsers, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard } from "@govoplan/core-webui";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
groupIds: [] as string[],
|
||||
roleIds: [] as string[]
|
||||
};
|
||||
|
||||
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
canManageGroups: boolean;
|
||||
canAssignRoles: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; password: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextUsers, nextGroups, nextRoles] = await Promise.all([fetchUsers(settings), fetchGroups(settings), fetchRoles(settings)]);
|
||||
setUsers(nextUsers);
|
||||
setGroups(nextGroups);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(user: UserAdminItem) {
|
||||
setDraft({
|
||||
email: user.email,
|
||||
displayName: user.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: user.password_reset_required,
|
||||
isActive: user.is_active,
|
||||
groupIds: user.groups.map((group) => group.id),
|
||||
roleIds: user.roles.map((role) => role.id)
|
||||
});
|
||||
setEditing(user);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createUser(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
group_ids: canManageGroups ? draft.groupIds : [],
|
||||
role_ids: canAssignRoles ? draft.roleIds : []
|
||||
});
|
||||
setSuccess(`Tenant membership created for ${response.user.email}.`);
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateUser>[2] = {};
|
||||
if (canUpdate) payload.display_name = draft.displayName || null;
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
if (canManageGroups) payload.group_ids = draft.groupIds;
|
||||
if (canAssignRoles) payload.role_ids = draft.roleIds;
|
||||
await updateUser(settings, editing.id, payload);
|
||||
setSuccess(`Access updated for ${editing.email}.`);
|
||||
if (editing.id === auth.user.id) await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateUser(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(`${deactivating.email} deactivated in this tenant.`);
|
||||
if (deactivating.id === auth.user.id) await onAuthRefresh();
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ id: "user", header: "User", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "groups", header: "Groups", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||
{ id: "roles", header: "Direct roles", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "scope_count", header: "Permissions", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "All" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }
|
||||
], [canAssignRoles, canManageGroups, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant users" description="Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant user" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenant users found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Add tenant user" : "Edit tenant user"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "Saving…" : "Save user"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
|
||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.</p>}
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">Groups</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="No groups exist yet." /></div>
|
||||
<div><span className="form-label">Direct roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
|
||||
</div>
|
||||
<p className="muted small-note">Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant user details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>User</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>Email</dt><dd>{viewing.email}</dd></div>
|
||||
<div><dt>Membership</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Global account</dt><dd>{viewing.account_is_active ? "Active" : "Inactive"}</dd></div>
|
||||
<div><dt>Groups</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>Direct roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>Effective permissions</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "All tenant permissions" : viewing.effective_scopes.join(", ") || "None"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
|
||||
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">Transmit it through a separate secure channel.</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate tenant membership" message={`Deactivate ${deactivating?.email} in the active tenant? Historical ownership remains intact.`} confirmLabel="Deactivate user" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user