Files
BizOne-portal/frontend/src/components/roles-permissions-board.tsx

533 lines
18 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
type PermissionKey = 'view' | 'edit' | 'delete' | 'manage';
type RoleId = string;
type PermissionValue = boolean | null;
type PermissionRow = {
id: string;
label: string;
icon: string;
description: string;
values: Record<PermissionKey, PermissionValue>;
};
type RoleDefinition = {
id: RoleId;
name: string;
badge: string;
tone: 'primary' | 'secondary' | 'tertiary';
summary: string;
usersAssigned: number;
icon: string;
permissionRows: PermissionRow[];
};
const permissionColumns: Array<{ key: PermissionKey; label: string }> = [
{ key: 'view', label: 'View' },
{ key: 'edit', label: 'Create/Edit' },
{ key: 'delete', label: 'Delete' },
{ key: 'manage', label: 'Manage All' },
];
function cloneRoleSet(roles: RoleDefinition[]) {
return roles.map((role) => ({
...role,
permissionRows: role.permissionRows.map((row) => ({
...row,
values: { ...row.values },
})),
}));
}
function roleBadge(role: RoleDefinition, isEditing: boolean) {
if (isEditing) {
return { label: 'Editing Now', tone: 'editing' as const };
}
return { label: role.badge, tone: role.tone };
}
type AuditHighlight = {
id: string;
icon: string;
title: string;
description: string;
time: string;
};
type Props = {
initialRoles: RoleDefinition[];
initialAuditHighlights: AuditHighlight[];
};
export function RolesPermissionsBoard({ initialRoles, initialAuditHighlights }: Props) {
const [roles, setRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
const [savedRoles, setSavedRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
const [selectedRoleId, setSelectedRoleId] = useState<RoleId>(initialRoles[0]?.id || '');
const [editingRoleId, setEditingRoleId] = useState<RoleId | null>(null);
const [saveMessage, setSaveMessage] = useState('');
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newRoleName, setNewRoleName] = useState('');
const [newRoleSummary, setNewRoleSummary] = useState('');
const [isSaving, setIsSaving] = useState(false);
const selectedRole = useMemo(
() => roles.find((role) => role.id === selectedRoleId) ?? roles[0],
[roles, selectedRoleId],
);
const isEditingSelectedRole = selectedRole ? editingRoleId === selectedRole.id : false;
useEffect(() => {
const nextRoles = cloneRoleSet(initialRoles);
setRoles(nextRoles);
setSavedRoles(nextRoles);
setSelectedRoleId((current) =>
nextRoles.some((role) => role.id === current) ? current : nextRoles[0]?.id || '',
);
setEditingRoleId((current) =>
nextRoles.some((role) => role.id === current) ? current : null,
);
}, [initialRoles]);
function updatePermission(rowId: string, permission: PermissionKey, nextValue: boolean) {
if (!isEditingSelectedRole) {
return;
}
setRoles((currentRoles) =>
currentRoles.map((role) =>
role.id !== selectedRoleId
? role
: {
...role,
permissionRows: role.permissionRows.map((row) =>
row.id === rowId ? { ...row, values: { ...row.values, [permission]: nextValue } } : row,
),
},
),
);
setSaveMessage('');
}
function beginEdit(roleId: RoleId) {
setSelectedRoleId(roleId);
setEditingRoleId(roleId);
setSaveMessage('');
}
function resetChanges() {
setRoles(cloneRoleSet(savedRoles));
setEditingRoleId(null);
setSaveMessage('Changes discarded.');
}
async function saveChanges() {
if (!selectedRole) {
return;
}
setIsSaving(true);
try {
const response = await fetch(`/api/roles/${selectedRole.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
permissions: selectedRole.permissionRows,
summary: selectedRole.summary,
badge: selectedRole.badge,
tone: selectedRole.tone,
icon: selectedRole.icon,
}),
});
const payload = await response.json();
if (!response.ok) {
setSaveMessage(payload.message || 'Failed to save role');
return;
}
setRoles((current) =>
current.map((role) =>
role.id === payload.id
? {
...role,
permissionRows: payload.permissions,
usersAssigned: payload.usersAssigned,
summary: payload.summary,
badge: payload.badge,
tone: payload.tone,
icon: payload.icon,
}
: role,
),
);
setSavedRoles((current) =>
current.map((role) =>
role.id === payload.id
? {
...role,
permissionRows: payload.permissions,
usersAssigned: payload.usersAssigned,
summary: payload.summary,
badge: payload.badge,
tone: payload.tone,
icon: payload.icon,
}
: role,
),
);
setEditingRoleId(null);
setSaveMessage(`Permission matrix for ${payload.name} saved.`);
} finally {
setIsSaving(false);
}
}
async function createRole() {
const trimmedName = newRoleName.trim();
const trimmedSummary = newRoleSummary.trim();
if (!trimmedName) {
setSaveMessage('Role name is required.');
return;
}
const id =
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || `role-${Date.now()}`;
const nextRole: RoleDefinition = {
id,
name: trimmedName,
badge: 'Custom',
tone: 'secondary',
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
usersAssigned: 0,
icon: 'verified_user',
permissionRows: [
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: false, delete: false, manage: false } },
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } },
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } },
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } },
],
};
setIsSaving(true);
try {
const response = await fetch('/api/roles', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
badge: 'Custom',
tone: 'secondary',
icon: 'verified_user',
permissions: nextRole.permissionRows,
}),
});
const payload = await response.json();
if (!response.ok) {
setSaveMessage(payload.message || 'Failed to create role');
return;
}
const createdRole = {
id: payload.id,
name: payload.name,
badge: payload.badge,
tone: payload.tone,
summary: payload.summary,
usersAssigned: payload.usersAssigned,
icon: payload.icon,
permissionRows: payload.permissions,
} satisfies RoleDefinition;
const nextRoles = [createdRole, ...cloneRoleSet(roles)];
setRoles(nextRoles);
setSavedRoles(cloneRoleSet(nextRoles));
setSelectedRoleId(payload.id);
setEditingRoleId(payload.id);
setIsCreateOpen(false);
setNewRoleName('');
setNewRoleSummary('');
setSaveMessage(`Role ${payload.name} created and opened for editing.`);
} finally {
setIsSaving(false);
}
}
if (!selectedRole) {
return (
<section className="permission-matrix-card">
<div className="permission-matrix-head">
<div>
<p className="card-kicker">Roles</p>
<h3>No roles available</h3>
<p className="permission-matrix-copy">
Create the first role to start defining access permissions.
</p>
</div>
<button
type="button"
className="role-action-button"
onClick={() => setIsCreateOpen(true)}
>
<span className="material-symbols-outlined">add_moderator</span>
Create Role
</button>
</div>
</section>
);
}
return (
<>
<section className="page-section">
<div className="header-actions">
<button
type="button"
className="roles-create-button"
onClick={() => {
setIsCreateOpen((current) => !current);
setSaveMessage('');
}}
>
<span className="material-symbols-outlined">{isCreateOpen ? 'close' : 'add_moderator'}</span>
{isCreateOpen ? 'Hide Role Form' : 'Create New Role'}
</button>
</div>
</section>
{isCreateOpen ? (
<section className="roles-create-panel">
<div className="roles-create-panel-head">
<div>
<p className="card-kicker">New Role</p>
<h3>Create a custom access profile</h3>
<p className="roles-create-copy">Define a clear role name and a short summary so your team can understand the scope immediately.</p>
</div>
</div>
<div className="roles-create-form">
<label className="roles-create-field">
<span>Role name</span>
<input type="text" value={newRoleName} onChange={(event) => setNewRoleName(event.target.value)} placeholder="Reporting Only" />
<small>Use a short operational name like Viewer, QA Reviewer, or Billing Admin.</small>
</label>
<label className="roles-create-field">
<span>Summary</span>
<textarea value={newRoleSummary} onChange={(event) => setNewRoleSummary(event.target.value)} placeholder="Read-only access to analytics, campaign stats, and webhook summaries." rows={3} />
<small>Describe what this role can access and the boundaries it should keep.</small>
</label>
</div>
<div className="button-row roles-create-actions">
<button type="button" className="secondary-button role-action-button" onClick={() => {
setIsCreateOpen(false);
setNewRoleName('');
setNewRoleSummary('');
}}>
Cancel
</button>
<button type="button" className="role-action-button" onClick={createRole} disabled={isSaving}>
Save Role
</button>
</div>
</section>
) : null}
<section className="roles-card-grid">
{roles.map((role) => {
const badge = roleBadge(role, editingRoleId === role.id);
const isSelected = role.id === selectedRoleId;
return (
<article
key={role.id}
className={isSelected ? 'role-card is-selected' : 'role-card'}
onClick={() => {
setSelectedRoleId(role.id);
setSaveMessage('');
}}
aria-selected={isSelected}
>
<div className="role-card-head">
<div className={`role-icon tone-${role.tone}`}>
<span className="material-symbols-outlined">{role.icon}</span>
</div>
<span className={`role-badge tone-${badge.tone}`}>{badge.label}</span>
</div>
<h2>{role.name}</h2>
<p>{role.summary}</p>
<div className="role-card-meta">
<span className="material-symbols-outlined">group</span>
{role.usersAssigned} users assigned
</div>
<div className="role-card-actions">
<button
type="button"
className={editingRoleId === role.id ? 'role-inline-button is-active' : 'role-inline-button'}
onClick={(event) => {
event.stopPropagation();
beginEdit(role.id);
}}
>
<span className="material-symbols-outlined">edit</span>
{editingRoleId === role.id ? 'Editing' : 'Edit'}
</button>
</div>
</article>
);
})}
</section>
<section className="permission-matrix-card">
<div className="permission-matrix-head">
<div>
<p className="card-kicker">Permission Matrix</p>
<h3>
{isEditingSelectedRole ? 'Editing' : 'Viewing'} <span>{selectedRole.name}</span>
</h3>
<p className="permission-matrix-copy">
{isEditingSelectedRole
? 'Toggle permissions below, then save the matrix to persist your changes.'
: 'This matrix is read-only until you click Edit on the selected role card.'}
</p>
</div>
<div className="button-row">
{isEditingSelectedRole ? (
<>
<button type="button" className="secondary-button role-action-button" onClick={resetChanges} disabled={isSaving}>
Discard
</button>
<button type="button" className="role-action-button" onClick={saveChanges} disabled={isSaving}>
Save Changes
</button>
</>
) : (
<button type="button" className="role-action-button" onClick={() => beginEdit(selectedRole.id)}>
<span className="material-symbols-outlined">edit</span>
Edit Role
</button>
)}
</div>
</div>
{saveMessage ? <p className="roles-feedback">{saveMessage}</p> : null}
<div className="permission-table-wrap">
<table className="permission-table">
<thead>
<tr>
<th>Module / Permission</th>
{permissionColumns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{selectedRole.permissionRows.map((row) => (
<tr key={row.id}>
<td>
<div className="permission-label">
<span className="material-symbols-outlined">{row.icon}</span>
<div>
<strong>{row.label}</strong>
<small>{row.description}</small>
</div>
</div>
</td>
{permissionColumns.map((column) => {
const value = row.values[column.key];
if (value === null) {
return (
<td key={column.key} className="permission-unavailable">
</td>
);
}
if (!isEditingSelectedRole) {
return (
<td key={column.key}>
<span className={value ? 'permission-indicator is-on' : 'permission-indicator is-off'}>
{value ? 'On' : 'Off'}
</span>
</td>
);
}
return (
<td key={column.key}>
<label className="toggle-switch">
<input type="checkbox" checked={value} onChange={(event) => updatePermission(row.id, column.key, event.target.checked)} />
<span className="toggle-track">
<span className="toggle-thumb" />
</span>
</label>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="roles-bottom-grid">
<article className="surface-card">
<div className="card-head">
<div>
<p className="card-kicker">Audit Trail</p>
<h3 className="roles-section-title">Recent Changes</h3>
</div>
<a href="/dashboard/settings/audit-trail" className="roles-inline-link">
View All
</a>
</div>
<div className="roles-audit-list">
{initialAuditHighlights.map((entry) => (
<div key={entry.id} className="roles-audit-item">
<div className="roles-audit-icon">
<span className="material-symbols-outlined">{entry.icon}</span>
</div>
<div className="roles-audit-copy">
<strong>{entry.title}</strong>
<p>{entry.description}</p>
</div>
<span className="roles-audit-time">{entry.time}</span>
</div>
))}
{initialAuditHighlights.length === 0 ? (
<div className="roles-audit-item">
<div className="roles-audit-icon">
<span className="material-symbols-outlined">history</span>
</div>
<div className="roles-audit-copy">
<strong>No role changes yet</strong>
<p>Role create and edit actions will appear here once they are recorded.</p>
</div>
</div>
) : null}
</div>
</article>
<aside className="roles-help-card">
<div className="roles-help-copy">
<p className="card-kicker">Security Guidance</p>
<h3>Need help?</h3>
<p>Learn how to set granular access boundaries for enterprise teams with higher security requirements.</p>
<a href="/dashboard/settings/security" className="roles-help-button">
Documentation
</a>
</div>
<span className="material-symbols-outlined roles-help-mark">lock_person</span>
</aside>
</section>
</>
);
}