533 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|