Move access permissions to access module
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, 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";
|
||||
@@ -42,6 +42,9 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
|
||||
const [explaining, setExplaining] = useState<UserAdminItem | null>(null);
|
||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
@@ -161,6 +164,21 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function openAccessExplanation(user: UserAdminItem) {
|
||||
setExplaining(user);
|
||||
setAccessExplanation(null);
|
||||
setAccessExplanationLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setAccessExplanation(await fetchUserAccessExplanation(settings, user.id));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
setExplaining(null);
|
||||
} finally {
|
||||
setAccessExplanationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", 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: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||
@@ -168,12 +186,13 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", 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) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", 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: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }],
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate]);
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,6 +234,48 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||
{accessExplanation && <>
|
||||
<dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
||||
</dl>
|
||||
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
||||
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid">
|
||||
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
||||
<strong>{source.role_name}</strong>
|
||||
<div className="muted small-note">
|
||||
<span>{sourceTypeLabel(source)}</span>
|
||||
{source.group_name && <> - <span>i18n:govoplan-access.group.171a0606</span>: {source.group_name}</>}
|
||||
{(source.function_name || source.function_id) && <> - <span>i18n:govoplan-access.function_fact.4f7435e4</span>: {source.function_name || source.function_id}</>}
|
||||
{(source.organization_unit_name || source.organization_unit_id) && <> - <span>i18n:govoplan-access.organization_unit.9832b383</span>: {source.organization_unit_name || source.organization_unit_id}</>}
|
||||
</div>
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
||||
</div>)}
|
||||
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
||||
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
||||
<dt>{fact.function_name || fact.function_id}</dt>
|
||||
<dd>
|
||||
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
||||
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
||||
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||
</dd>
|
||||
</div>)}
|
||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||
<div className="admin-scope-list">
|
||||
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
||||
</div>
|
||||
</>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
||||
</Dialog>
|
||||
@@ -241,3 +302,83 @@ function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sourceKey(source: AccessRoleSourceItem): string {
|
||||
return [
|
||||
source.source_type,
|
||||
source.role_id,
|
||||
source.group_id || "",
|
||||
source.function_assignment_id || "",
|
||||
source.function_id || ""
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function AccessExplanationLinks({ organizationHref, idmHref }: { organizationHref: string | null; idmHref: string | null }) {
|
||||
if (!organizationHref && !idmHref) return null;
|
||||
return (
|
||||
<div className="muted small-note">
|
||||
{organizationHref && <a href={organizationHref} title="i18n:govoplan-access.open_organizations">i18n:govoplan-access.open_organizations</a>}
|
||||
{organizationHref && idmHref && <span> | </span>}
|
||||
{idmHref && <a href={idmHref} title="i18n:govoplan-access.open_idm_assignment">i18n:govoplan-access.open_idm_assignment</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function organizationHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return organizationHref(source.function_id, source.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return organizationHref(fact.function_id, fact.organization_unit_id);
|
||||
}
|
||||
|
||||
function organizationHref(functionId?: string | null, unitId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (functionId) {
|
||||
params.set("section", "functions");
|
||||
params.set("function_id", functionId);
|
||||
if (unitId) params.set("unit_id", unitId);
|
||||
} else if (unitId) {
|
||||
params.set("section", "units");
|
||||
params.set("unit_id", unitId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/organizations?${query}` : null;
|
||||
}
|
||||
|
||||
function idmHrefForSource(source: AccessRoleSourceItem): string | null {
|
||||
return idmHref(source.function_assignment_id, source.function_id);
|
||||
}
|
||||
|
||||
function idmHrefForFact(fact: FunctionFactExplanationItem): string | null {
|
||||
return idmHref(fact.assignment_id, fact.function_id);
|
||||
}
|
||||
|
||||
function idmHref(assignmentId?: string | null, functionId?: string | null): string | null {
|
||||
const params = new URLSearchParams();
|
||||
if (assignmentId) params.set("assignment_id", assignmentId);
|
||||
if (functionId) params.set("function_id", functionId);
|
||||
const query = params.toString();
|
||||
return query ? `/idm?${query}` : null;
|
||||
}
|
||||
|
||||
function sourceTypeLabel(source: AccessRoleSourceItem): string {
|
||||
switch (source.source_type) {
|
||||
case "direct_role":
|
||||
return "i18n:govoplan-access.direct_role_source.bf3e6a27";
|
||||
case "group_role":
|
||||
return "i18n:govoplan-access.group_role_source.0aa38904";
|
||||
case "legacy_function_role":
|
||||
return "i18n:govoplan-access.legacy_function_role_source.36f81c6b";
|
||||
case "idm_function_role":
|
||||
return "i18n:govoplan-access.idm_function_role_source.14a231fe";
|
||||
case "system_role":
|
||||
return "i18n:govoplan-access.system_role_source.c6e83223";
|
||||
default:
|
||||
return source.source_type;
|
||||
}
|
||||
}
|
||||
|
||||
function factRoles(fact: FunctionFactExplanationItem): string {
|
||||
return fact.role_names.length ? fact.role_names.join(", ") : "i18n:govoplan-access.no_mapped_roles.986bfb44";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user