← back to Norma
feat(auth): intern role + user fields + admin-editable role × feature permissions matrix
f6b2dba5904d25ae3de1223cc515b3279035601d · 2026-05-20 12:25:58 -0700 · Steve Abrams
- migration 024: extend tier_credentials role enum with 'intern'; add full_name,
email, is_active, last_login_at, created_by; create role_permissions table
(22 seeded feature_keys across 8 categories — gmail/drafts/petitions/contacts/
news/grants/outreach/social/settings).
- lib/auth.ts: UserRole union type + ROLE_LABEL + ALL_ROLES exports.
- lib/permissions.ts: hasPermission(role, feature) reader w/ 60s in-process cache.
admin implicitly allowed for every feature.
- /api/admin/permissions GET/PUT — admin-only matrix editor.
- /api/settings/tier-credentials POST/PUT now accept full_name/email/is_active;
admin row can't be disabled or demoted.
- /api/auth/login: blocks is_active=false accounts (after pw check, so we
don't leak which usernames exist); bumps last_login_at; returns full_name.
- CredentialsSection: new collapsible per-user permissions row showing every
feature the user can access. Add User modal grew Full Name / Email /
Active toggle / Intern role option.
- PermissionsMatrix.tsx: admin-editable feature × role checkbox grid in
Settings, mounted directly under Users & Roles.
Also widened the 'admin'|'staff'|'pulse' role literal in AuthProvider,
Sidebar, and pulse layout to include 'intern'.
Files touched
M .gitignoreA app/api/admin/permissions/route.tsM app/api/auth/login/route.tsM app/api/settings/tier-credentials/[id]/route.tsM app/api/settings/tier-credentials/route.tsM app/pulse/layout.tsxM components/AuthProvider.tsxM components/Sidebar.tsxM components/settings/CredentialsSection.tsxA components/settings/PermissionsMatrix.tsxM components/settings/SettingsTab.tsxA db/024_user_management.sqlM lib/auth.tsA lib/permissions.ts
Diff
commit f6b2dba5904d25ae3de1223cc515b3279035601d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 12:25:58 2026 -0700
feat(auth): intern role + user fields + admin-editable role × feature permissions matrix
- migration 024: extend tier_credentials role enum with 'intern'; add full_name,
email, is_active, last_login_at, created_by; create role_permissions table
(22 seeded feature_keys across 8 categories — gmail/drafts/petitions/contacts/
news/grants/outreach/social/settings).
- lib/auth.ts: UserRole union type + ROLE_LABEL + ALL_ROLES exports.
- lib/permissions.ts: hasPermission(role, feature) reader w/ 60s in-process cache.
admin implicitly allowed for every feature.
- /api/admin/permissions GET/PUT — admin-only matrix editor.
- /api/settings/tier-credentials POST/PUT now accept full_name/email/is_active;
admin row can't be disabled or demoted.
- /api/auth/login: blocks is_active=false accounts (after pw check, so we
don't leak which usernames exist); bumps last_login_at; returns full_name.
- CredentialsSection: new collapsible per-user permissions row showing every
feature the user can access. Add User modal grew Full Name / Email /
Active toggle / Intern role option.
- PermissionsMatrix.tsx: admin-editable feature × role checkbox grid in
Settings, mounted directly under Users & Roles.
Also widened the 'admin'|'staff'|'pulse' role literal in AuthProvider,
Sidebar, and pulse layout to include 'intern'.
---
.gitignore | 4 +
app/api/admin/permissions/route.ts | 82 +++++
app/api/auth/login/route.ts | 21 +-
app/api/settings/tier-credentials/[id]/route.ts | 63 +++-
app/api/settings/tier-credentials/route.ts | 58 +++-
app/pulse/layout.tsx | 2 +-
components/AuthProvider.tsx | 2 +-
components/Sidebar.tsx | 6 +-
components/settings/CredentialsSection.tsx | 393 ++++++++++++++++++------
components/settings/PermissionsMatrix.tsx | 240 +++++++++++++++
components/settings/SettingsTab.tsx | 4 +
db/024_user_management.sql | 86 ++++++
lib/auth.ts | 13 +-
lib/permissions.ts | 68 ++++
14 files changed, 922 insertions(+), 120 deletions(-)
diff --git a/.gitignore b/.gitignore
index 0f42d1d..607a490 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,7 @@ tmp/
dist/
build/
*.bak
+
+# Per-user secrets — never commit
+docs/sessions/NORMA-NEW-USERS-*.md
+docs/sessions/NORMA-WAKEUP-*.md
diff --git a/app/api/admin/permissions/route.ts b/app/api/admin/permissions/route.ts
new file mode 100644
index 0000000..2340e25
--- /dev/null
+++ b/app/api/admin/permissions/route.ts
@@ -0,0 +1,82 @@
+/**
+ * /api/admin/permissions
+ * GET — list all feature × role rows
+ * PUT — replace allowed_roles for one or many feature_keys
+ *
+ * Admin-only. Edits invalidate the in-process permission cache.
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
+import { ALL_ROLES, type UserRole } from '@/lib/auth';
+import { clearPermissionsCache } from '@/lib/permissions';
+
+const ASSIGNABLE_ROLES: UserRole[] = ALL_ROLES.filter((r) => r !== 'admin');
+
+export async function GET(request: NextRequest) {
+ const session = requireRole(request, 'admin');
+ if (session instanceof NextResponse) return session;
+
+ try {
+ const { rows } = await query(
+ `SELECT feature_key, label, category, allowed_roles, description, updated_at
+ FROM role_permissions
+ ORDER BY category, feature_key`,
+ );
+ return NextResponse.json({ permissions: rows, assignableRoles: ASSIGNABLE_ROLES });
+ } catch (err) {
+ console.error('[admin/permissions] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to load permissions' }, { status: 500 });
+ }
+}
+
+interface UpdateBody {
+ updates: Array<{ feature_key: string; allowed_roles: string[] }>;
+}
+
+export async function PUT(request: NextRequest) {
+ const session = requireRole(request, 'admin');
+ if (session instanceof NextResponse) return session;
+
+ try {
+ const body = (await request.json()) as UpdateBody;
+ const updates = Array.isArray(body?.updates) ? body.updates : [];
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No updates supplied' }, { status: 400 });
+ }
+ if (updates.length > 200) {
+ return NextResponse.json({ error: 'Too many updates in one call' }, { status: 400 });
+ }
+
+ // Look up admin's id for updated_by audit
+ const me = await query<{ id: string }>(
+ 'SELECT id FROM tier_credentials WHERE username = $1',
+ [session.username],
+ );
+ const updatedBy = me.rows[0]?.id || null;
+
+ let changed = 0;
+ for (const u of updates) {
+ if (typeof u.feature_key !== 'string' || !Array.isArray(u.allowed_roles)) continue;
+ // Filter out 'admin' (never gated) and any unknown roles
+ const clean = u.allowed_roles.filter(
+ (r): r is UserRole => ASSIGNABLE_ROLES.includes(r as UserRole),
+ );
+ const res = await query(
+ `UPDATE role_permissions
+ SET allowed_roles = $1, updated_by = $2, updated_at = NOW()
+ WHERE feature_key = $3`,
+ [clean, updatedBy, u.feature_key],
+ );
+ changed += res.rowCount || 0;
+ }
+
+ clearPermissionsCache();
+ return NextResponse.json({ success: true, changed });
+ } catch (err) {
+ console.error('[admin/permissions] PUT error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update permissions' }, { status: 500 });
+ }
+}
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index caf06a9..5c5bc3f 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -30,7 +30,8 @@ export async function POST(request: NextRequest) {
// Query tier_credentials by username only — verify password in application code
const result = await query(
- 'SELECT username, role, org_id, display_name, client_type, password_hash FROM tier_credentials WHERE username = $1',
+ `SELECT username, role, org_id, display_name, full_name, client_type, password_hash, is_active
+ FROM tier_credentials WHERE username = $1`,
[username],
);
@@ -52,6 +53,21 @@ export async function POST(request: NextRequest) {
);
}
+ // Block deactivated accounts AFTER password check so we don't leak whether
+ // a given username exists + is disabled vs simply invalid.
+ if (row.is_active === false) {
+ return NextResponse.json(
+ { error: 'Account disabled. Contact an admin.' },
+ { status: 403 },
+ );
+ }
+
+ // Bump last_login_at (best-effort; never block login on this)
+ query(
+ 'UPDATE tier_credentials SET last_login_at = NOW() WHERE username = $1',
+ [row.username],
+ ).catch(() => { /* non-fatal */ });
+
// Auto-upgrade SHA-256 hashes to bcrypt on successful login
if (needsRehash) {
const bcryptHash = await hashPassword(password);
@@ -76,7 +92,8 @@ export async function POST(request: NextRequest) {
success: true,
role: row.role,
orgId: row.org_id,
- displayName: row.display_name,
+ displayName: row.full_name || row.display_name,
+ fullName: row.full_name,
clientType: clientType || row.client_type || null,
isInteriorDesigner: clientType === 'trade',
});
diff --git a/app/api/settings/tier-credentials/[id]/route.ts b/app/api/settings/tier-credentials/[id]/route.ts
index 6498ea2..7c5602c 100644
--- a/app/api/settings/tier-credentials/[id]/route.ts
+++ b/app/api/settings/tier-credentials/[id]/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
-import { hashPassword } from '@/lib/auth';
+import { hashPassword, ALL_ROLES, type UserRole } from '@/lib/auth';
import { requireRole } from '@/lib/require-role';
interface RouteContext {
@@ -9,7 +9,7 @@ interface RouteContext {
/**
* PUT /api/settings/tier-credentials/[id]
- * Update display_name, role, org_id. Optionally reset password.
+ * Update display_name, full_name, email, role, org_id, is_active. Optionally reset password.
*/
export async function PUT(request: NextRequest, context: RouteContext) {
const result = requireRole(request, 'admin');
@@ -19,21 +19,45 @@ export async function PUT(request: NextRequest, context: RouteContext) {
try {
const body = await request.json();
- const { display_name, role, org_id, password } = body;
+ const { display_name, full_name, email, role, org_id, password, is_active } = body;
// Verify credential exists
- const existing = await query('SELECT id, username FROM tier_credentials WHERE id = $1', [id]);
+ const existing = await query(
+ 'SELECT id, username FROM tier_credentials WHERE id = $1',
+ [id],
+ );
if (existing.rows.length === 0) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 });
}
- if (role && !['admin', 'staff', 'pulse'].includes(role)) {
+ if (role !== undefined && !ALL_ROLES.includes(role as UserRole)) {
return NextResponse.json(
- { error: 'role must be admin, staff, or pulse' },
- { status: 400 }
+ { error: `role must be one of ${ALL_ROLES.join(', ')}` },
+ { status: 400 },
);
}
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return NextResponse.json({ error: 'Email is invalid' }, { status: 400 });
+ }
+
+ // Hard guard: 'admin' username cannot be disabled or demoted, otherwise the
+ // tenant can lock itself out of user management.
+ if (existing.rows[0].username === 'admin') {
+ if (is_active === false) {
+ return NextResponse.json(
+ { error: 'Cannot disable the admin account' },
+ { status: 403 },
+ );
+ }
+ if (role !== undefined && role !== 'admin') {
+ return NextResponse.json(
+ { error: 'Cannot demote the admin account' },
+ { status: 403 },
+ );
+ }
+ }
+
// Build dynamic update
const updates: string[] = [];
const values: unknown[] = [];
@@ -43,6 +67,14 @@ export async function PUT(request: NextRequest, context: RouteContext) {
updates.push(`display_name = $${paramIdx++}`);
values.push(display_name || null);
}
+ if (full_name !== undefined) {
+ updates.push(`full_name = $${paramIdx++}`);
+ values.push(full_name || null);
+ }
+ if (email !== undefined) {
+ updates.push(`email = $${paramIdx++}`);
+ values.push(email || null);
+ }
if (role !== undefined) {
updates.push(`role = $${paramIdx++}`);
values.push(role);
@@ -51,6 +83,10 @@ export async function PUT(request: NextRequest, context: RouteContext) {
updates.push(`org_id = $${paramIdx++}`);
values.push(org_id || null);
}
+ if (is_active !== undefined) {
+ updates.push(`is_active = $${paramIdx++}`);
+ values.push(!!is_active);
+ }
if (password) {
updates.push(`password_hash = $${paramIdx++}`);
values.push(await hashPassword(password));
@@ -63,8 +99,9 @@ export async function PUT(request: NextRequest, context: RouteContext) {
values.push(id);
const { rows } = await query(
`UPDATE tier_credentials SET ${updates.join(', ')} WHERE id = $${paramIdx}
- RETURNING id, username, role, org_id, display_name, created_at, updated_at`,
- values
+ RETURNING id, username, role, org_id, display_name, full_name, email, is_active,
+ last_login_at, created_at, updated_at`,
+ values,
);
return NextResponse.json({ credential: rows[0] });
@@ -85,15 +122,17 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
try {
- // Check if it's the admin row
- const existing = await query('SELECT username FROM tier_credentials WHERE id = $1', [id]);
+ const existing = await query(
+ 'SELECT username FROM tier_credentials WHERE id = $1',
+ [id],
+ );
if (existing.rows.length === 0) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 });
}
if (existing.rows[0].username === 'admin') {
return NextResponse.json(
{ error: 'Cannot delete the admin account' },
- { status: 403 }
+ { status: 403 },
);
}
diff --git a/app/api/settings/tier-credentials/route.ts b/app/api/settings/tier-credentials/route.ts
index 1d4f05a..7ebee30 100644
--- a/app/api/settings/tier-credentials/route.ts
+++ b/app/api/settings/tier-credentials/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
-import { hashPassword } from '@/lib/auth';
+import { hashPassword, ALL_ROLES, type UserRole } from '@/lib/auth';
import { requireRole } from '@/lib/require-role';
/**
@@ -13,7 +13,9 @@ export async function GET(request: NextRequest) {
try {
const { rows } = await query(
- `SELECT tc.id, tc.username, tc.role, tc.org_id, tc.display_name, tc.created_at, tc.updated_at,
+ `SELECT tc.id, tc.username, tc.role, tc.org_id, tc.display_name,
+ tc.full_name, tc.email, tc.is_active, tc.last_login_at,
+ tc.created_at, tc.updated_at,
na.org_name
FROM tier_credentials tc
LEFT JOIN nonprofit_accounts na ON tc.org_id = na.id
@@ -29,15 +31,24 @@ export async function GET(request: NextRequest) {
/**
* POST /api/settings/tier-credentials
* Create a new tier credential.
- * Body: { username, password, role, org_id?, display_name? }
+ * Body: { username, password, role, org_id?, display_name?, full_name?, email?, is_active? }
*/
export async function POST(request: NextRequest) {
- const result = requireRole(request, 'admin');
- if (result instanceof NextResponse) return result;
+ const session = requireRole(request, 'admin');
+ if (session instanceof NextResponse) return session;
try {
const body = await request.json();
- const { username, password, role, org_id, display_name } = body;
+ const {
+ username,
+ password,
+ role,
+ org_id,
+ display_name,
+ full_name,
+ email,
+ is_active,
+ } = body;
if (!username || !password || !role) {
return NextResponse.json(
@@ -46,9 +57,9 @@ export async function POST(request: NextRequest) {
);
}
- if (!['admin', 'staff', 'pulse'].includes(role)) {
+ if (!ALL_ROLES.includes(role as UserRole)) {
return NextResponse.json(
- { error: 'role must be admin, staff, or pulse' },
+ { error: `role must be one of ${ALL_ROLES.join(', ')}` },
{ status: 400 }
);
}
@@ -60,6 +71,10 @@ export async function POST(request: NextRequest) {
);
}
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return NextResponse.json({ error: 'Email is invalid' }, { status: 400 });
+ }
+
// Check uniqueness
const existing = await query(
'SELECT id FROM tier_credentials WHERE username = $1',
@@ -74,11 +89,30 @@ export async function POST(request: NextRequest) {
const hashed = await hashPassword(password);
+ // Look up creator id by username (for the created_by audit FK)
+ const creator = await query(
+ 'SELECT id FROM tier_credentials WHERE username = $1',
+ [session.username]
+ );
+ const createdBy = creator.rows[0]?.id || null;
+
const { rows } = await query(
- `INSERT INTO tier_credentials (username, password_hash, role, org_id, display_name)
- VALUES ($1, $2, $3, $4, $5)
- RETURNING id, username, role, org_id, display_name, created_at, updated_at`,
- [username, hashed, role, org_id || null, display_name || null]
+ `INSERT INTO tier_credentials
+ (username, password_hash, role, org_id, display_name, full_name, email, is_active, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, TRUE), $9)
+ RETURNING id, username, role, org_id, display_name, full_name, email, is_active,
+ last_login_at, created_at, updated_at`,
+ [
+ username,
+ hashed,
+ role,
+ org_id || null,
+ display_name || null,
+ full_name || null,
+ email || null,
+ is_active === undefined ? null : !!is_active,
+ createdBy,
+ ]
);
return NextResponse.json({ credential: rows[0] }, { status: 201 });
diff --git a/app/pulse/layout.tsx b/app/pulse/layout.tsx
index 43a9715..d3bfa4b 100644
--- a/app/pulse/layout.tsx
+++ b/app/pulse/layout.tsx
@@ -14,7 +14,7 @@ import {
interface SessionInfo {
authenticated: boolean;
user?: string;
- role?: 'admin' | 'staff' | 'pulse';
+ role?: 'admin' | 'staff' | 'intern' | 'pulse';
displayName?: string;
}
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
index 4646202..16ab2a4 100644
--- a/components/AuthProvider.tsx
+++ b/components/AuthProvider.tsx
@@ -14,7 +14,7 @@ import { Loader2 } from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface AuthContextValue {
user: string | null;
- role: 'admin' | 'staff' | 'pulse' | null;
+ role: 'admin' | 'staff' | 'intern' | 'pulse' | null;
orgId: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<{ error?: string }>;
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index 844d7a8..83d1c7b 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -185,7 +185,7 @@ interface SidebarProps {
onTabChange: (tab: TabId) => void;
isOpen?: boolean;
onToggle?: () => void;
- role?: 'admin' | 'staff' | 'pulse';
+ role?: 'admin' | 'staff' | 'intern' | 'pulse';
}
/* ─── Tabs excluded for staff role ────────────────────────────────────────── */
@@ -367,8 +367,8 @@ function writeLS(key: string, value: boolean) {
/* ─── Hidden tabs helpers ───────────────────────────────────────────────── */
-function getHiddenTabsKey(role?: 'admin' | 'staff' | 'pulse'): string {
- if (role === 'staff' || role === 'pulse') {
+function getHiddenTabsKey(role?: 'admin' | 'staff' | 'intern' | 'pulse'): string {
+ if (role === 'staff' || role === 'intern' || role === 'pulse') {
const orgId = typeof window !== 'undefined'
? localStorage.getItem('norma-user-orgid') || 'default'
: 'default';
diff --git a/components/settings/CredentialsSection.tsx b/components/settings/CredentialsSection.tsx
index 932810b..d544d51 100644
--- a/components/settings/CredentialsSection.tsx
+++ b/components/settings/CredentialsSection.tsx
@@ -1,22 +1,28 @@
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
import {
Users, Plus, Pencil, Trash2, X, Save, RefreshCw, Shield, Eye, EyeOff,
- ChevronDown, ChevronRight, AlertTriangle,
+ ChevronDown, ChevronRight, AlertTriangle, Check, Minus,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
/* ─── Types ──────────────────────────────────────────────────────────────── */
+type UserRole = 'admin' | 'staff' | 'intern' | 'pulse';
+
interface Credential {
- id: string;
- username: string;
- role: 'admin' | 'staff' | 'pulse';
- org_id: string | null;
- org_name: string | null;
- display_name: string | null;
- created_at: string;
- updated_at: string;
+ id: string;
+ username: string;
+ role: UserRole;
+ org_id: string | null;
+ org_name: string | null;
+ display_name: string | null;
+ full_name: string | null;
+ email: string | null;
+ is_active: boolean;
+ last_login_at: string | null;
+ created_at: string;
+ updated_at: string;
}
interface OrgOption {
@@ -24,20 +30,36 @@ interface OrgOption {
org_name: string;
}
+interface PermissionRow {
+ feature_key: string;
+ label: string;
+ category: string;
+ allowed_roles: string[];
+ description: string | null;
+}
+
+const ROLE_LABEL: Record<UserRole, string> = {
+ admin: 'Admin',
+ staff: 'User',
+ intern: 'Intern',
+ pulse: 'Pulse',
+};
+
/* ─── Role badge helper ──────────────────────────────────────────────────── */
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, { bg: string; color: string }> = {
admin: { bg: 'rgba(220, 38, 38, 0.10)', color: '#dc2626' },
staff: { bg: 'rgba(79, 70, 229, 0.10)', color: '#4f46e5' },
- pulse: { bg: 'rgba(10, 124, 89, 0.10)', color: '#0a7c59' },
+ intern: { bg: 'rgba(245, 158, 11, 0.10)', color: '#b45309' },
+ pulse: { bg: 'rgba(10, 124, 89, 0.10)', color: '#0a7c59' },
};
const s = styles[role] || styles.staff;
return (
<span
- className="text-xs font-semibold px-2 py-0.5 rounded-full capitalize"
+ className="text-xs font-semibold px-2 py-0.5 rounded-full"
style={{ backgroundColor: s.bg, color: s.color }}
>
- {role}
+ {ROLE_LABEL[role as UserRole] || role}
</span>
);
}
@@ -48,16 +70,21 @@ export default function CredentialsSection() {
const [expanded, setExpanded] = useState(true);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [orgs, setOrgs] = useState<OrgOption[]>([]);
+ const [permissions, setPermissions] = useState<PermissionRow[]>([]);
const [loading, setLoading] = useState(true);
+ const [expandedUserIds, setExpandedUserIds] = useState<Set<string>>(new Set());
// Modal state
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formUsername, setFormUsername] = useState('');
const [formPassword, setFormPassword] = useState('');
- const [formRole, setFormRole] = useState<'admin' | 'staff' | 'pulse'>('staff');
+ const [formRole, setFormRole] = useState<UserRole>('staff');
const [formOrgId, setFormOrgId] = useState('');
const [formDisplayName, setFormDisplayName] = useState('');
+ const [formFullName, setFormFullName] = useState('');
+ const [formEmail, setFormEmail] = useState('');
+ const [formIsActive, setFormIsActive] = useState(true);
const [formSaving, setFormSaving] = useState(false);
const [formError, setFormError] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -92,10 +119,44 @@ export default function CredentialsSection() {
} catch { /* noop */ }
}, []);
+ const fetchPermissions = useCallback(async () => {
+ try {
+ const res = await fetch('/api/admin/permissions');
+ if (!res.ok) return;
+ const data = await res.json();
+ setPermissions(data.permissions || []);
+ } catch { /* noop */ }
+ }, []);
+
useEffect(() => {
fetchCredentials();
fetchOrgs();
- }, [fetchCredentials, fetchOrgs]);
+ fetchPermissions();
+ }, [fetchCredentials, fetchOrgs, fetchPermissions]);
+
+ /* ─── Permissions index ────────────────────────────────────────────────── */
+ const permsByCategory = useMemo(() => {
+ const map = new Map<string, PermissionRow[]>();
+ for (const p of permissions) {
+ if (!map.has(p.category)) map.set(p.category, []);
+ map.get(p.category)!.push(p);
+ }
+ return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
+ }, [permissions]);
+
+ function userHasPermission(role: UserRole, feature: PermissionRow): boolean {
+ if (role === 'admin') return true;
+ return feature.allowed_roles.includes(role);
+ }
+
+ function toggleUserExpanded(id: string) {
+ setExpandedUserIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
/* ─── Modal helpers ────────────────────────────────────────────────────── */
function openCreateModal() {
@@ -105,6 +166,9 @@ export default function CredentialsSection() {
setFormRole('staff');
setFormOrgId('');
setFormDisplayName('');
+ setFormFullName('');
+ setFormEmail('');
+ setFormIsActive(true);
setFormError('');
setShowPassword(false);
setShowModal(true);
@@ -117,6 +181,9 @@ export default function CredentialsSection() {
setFormRole(cred.role);
setFormOrgId(cred.org_id || '');
setFormDisplayName(cred.display_name || '');
+ setFormFullName(cred.full_name || '');
+ setFormEmail(cred.email || '');
+ setFormIsActive(cred.is_active !== false);
setFormError('');
setShowPassword(false);
setShowModal(true);
@@ -144,6 +211,10 @@ export default function CredentialsSection() {
setFormError('Username cannot contain colons');
return;
}
+ if (formEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formEmail)) {
+ setFormError('Email looks invalid');
+ return;
+ }
setFormSaving(true);
try {
@@ -151,8 +222,11 @@ export default function CredentialsSection() {
// Update
const payload: Record<string, unknown> = {
display_name: formDisplayName,
- role: formRole,
- org_id: formOrgId || null,
+ full_name: formFullName,
+ email: formEmail,
+ role: formRole,
+ org_id: formOrgId || null,
+ is_active: formIsActive,
};
if (formPassword) {
payload.password = formPassword;
@@ -167,25 +241,28 @@ export default function CredentialsSection() {
const data = await res.json();
throw new Error(data.error || 'Failed to update');
}
- addToast('Credential updated', 'success');
+ addToast('User updated', 'success');
} else {
// Create
const res = await fetch('/api/settings/tier-credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- username: formUsername.trim(),
- password: formPassword,
- role: formRole,
- org_id: formOrgId || null,
+ username: formUsername.trim(),
+ password: formPassword,
+ role: formRole,
+ org_id: formOrgId || null,
display_name: formDisplayName || null,
+ full_name: formFullName || null,
+ email: formEmail || null,
+ is_active: formIsActive,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to create');
}
- addToast('Credential created', 'success');
+ addToast('User created', 'success');
}
closeModal();
fetchCredentials();
@@ -208,7 +285,7 @@ export default function CredentialsSection() {
const data = await res.json();
throw new Error(data.error || 'Failed to delete');
}
- addToast('Credential deleted', 'success');
+ addToast('User deleted', 'success');
setDeleteId(null);
fetchCredentials();
} catch (err) {
@@ -219,7 +296,8 @@ export default function CredentialsSection() {
}
/* ─── Format date ──────────────────────────────────────────────────────── */
- function fmtDate(iso: string) {
+ function fmtDate(iso: string | null) {
+ if (!iso) return '';
return new Date(iso).toLocaleDateString('en-US', {
timeZone: 'America/Los_Angeles',
month: 'short',
@@ -228,6 +306,17 @@ export default function CredentialsSection() {
});
}
+ function fmtLastLogin(iso: string | null) {
+ if (!iso) return 'never';
+ const d = new Date(iso);
+ const ageMs = Date.now() - d.getTime();
+ const ageDay = Math.floor(ageMs / 86_400_000);
+ if (ageDay === 0) return 'today';
+ if (ageDay === 1) return 'yesterday';
+ if (ageDay < 30) return `${ageDay}d ago`;
+ return fmtDate(iso);
+ }
+
/* ─── Render ───────────────────────────────────────────────────────────── */
return (
<div className="card p-5">
@@ -241,7 +330,7 @@ export default function CredentialsSection() {
<div className="flex items-center gap-2">
<Shield size={16} style={{ color: '#dc2626' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
- User Credentials
+ Users & Roles
</h3>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(220, 38, 38, 0.10)', color: '#dc2626' }}>
{credentials.length} {credentials.length === 1 ? 'user' : 'users'}
@@ -255,14 +344,15 @@ export default function CredentialsSection() {
{expanded && (
<div className="mt-4">
<p className="text-xs mb-3" style={{ color: 'var(--color-text-muted)' }}>
- Manage login credentials for Norma (admin), Nikki (staff), and Pulse (public) tiers.
+ Manage Norma login accounts. Click a row to see every permission that user has.
+ Admin is always allowed everywhere; the matrix only gates User, Intern, and Pulse.
</p>
{/* Add button */}
<div className="flex justify-end mb-3">
<button className="btn btn-primary btn-sm" onClick={openCreateModal}>
<Plus size={14} />
- <span>Add Credential</span>
+ <span>Add User</span>
</button>
</div>
@@ -278,7 +368,7 @@ export default function CredentialsSection() {
>
<Users size={24} className="mx-auto mb-2" style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
- No credentials configured yet.
+ No users configured yet.
</p>
</div>
) : (
@@ -286,61 +376,143 @@ export default function CredentialsSection() {
<table className="w-full text-sm" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-surface-el)' }}>
- <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Username</th>
- <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Display Name</th>
+ <th style={{ width: 30, borderBottom: '1px solid var(--color-border)' }} />
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>User</th>
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Email</th>
<th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Role</th>
<th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Organization</th>
- <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Created</th>
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Status</th>
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Last login</th>
<th className="text-right px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Actions</th>
</tr>
</thead>
<tbody>
- {credentials.map((cred) => (
- <tr
- key={cred.id}
- style={{ borderBottom: '1px solid var(--color-border)' }}
- >
- <td className="px-3 py-2.5" style={{ color: 'var(--color-text)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
- {cred.username}
- </td>
- <td className="px-3 py-2.5" style={{ color: 'var(--color-text-secondary)' }}>
- {cred.display_name || <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>--</span>}
- </td>
- <td className="px-3 py-2.5">
- <RoleBadge role={cred.role} />
- </td>
- <td className="px-3 py-2.5" style={{ color: 'var(--color-text-secondary)' }}>
- {cred.org_name || <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>}
- </td>
- <td className="px-3 py-2.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
- {fmtDate(cred.created_at)}
- </td>
- <td className="px-3 py-2.5 text-right">
- <div className="flex items-center justify-end gap-1">
- <button
- type="button"
- onClick={() => openEditModal(cred)}
- className="p-1.5 rounded-md hover:bg-black/5 transition-colors"
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
- title="Edit credential"
- >
- <Pencil size={14} />
- </button>
- {cred.username !== 'admin' && (
- <button
- type="button"
- onClick={() => setDeleteId(cred.id)}
- className="p-1.5 rounded-md hover:bg-red-50 transition-colors"
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-error)' }}
- title="Delete credential"
- >
- <Trash2 size={14} />
- </button>
- )}
- </div>
- </td>
- </tr>
- ))}
+ {credentials.map((cred) => {
+ const isOpen = expandedUserIds.has(cred.id);
+ return (
+ <Fragment key={cred.id}>
+ <tr
+ style={{ borderBottom: isOpen ? 'none' : '1px solid var(--color-border)', cursor: 'pointer' }}
+ onClick={() => toggleUserExpanded(cred.id)}
+ >
+ <td className="px-2 py-2.5" style={{ color: 'var(--color-text-muted)' }}>
+ {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+ </td>
+ <td className="px-3 py-2.5">
+ <div style={{ color: 'var(--color-text)', fontWeight: 600 }}>
+ {cred.full_name || cred.display_name || cred.username}
+ </div>
+ <div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
+ @{cred.username}
+ </div>
+ </td>
+ <td className="px-3 py-2.5" style={{ color: 'var(--color-text-secondary)', fontSize: '0.8rem' }}>
+ {cred.email || <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>—</span>}
+ </td>
+ <td className="px-3 py-2.5">
+ <RoleBadge role={cred.role} />
+ </td>
+ <td className="px-3 py-2.5" style={{ color: 'var(--color-text-secondary)' }}>
+ {cred.org_name || <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>}
+ </td>
+ <td className="px-3 py-2.5">
+ {cred.is_active === false ? (
+ <span className="text-xs font-semibold px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' }}>
+ Disabled
+ </span>
+ ) : (
+ <span className="text-xs font-semibold px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(16, 185, 129, 0.10)', color: '#10b981' }}>
+ Active
+ </span>
+ )}
+ </td>
+ <td className="px-3 py-2.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {fmtLastLogin(cred.last_login_at)}
+ </td>
+ <td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
+ <div className="flex items-center justify-end gap-1">
+ <button
+ type="button"
+ onClick={() => openEditModal(cred)}
+ className="p-1.5 rounded-md hover:bg-black/5 transition-colors"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
+ title="Edit user"
+ >
+ <Pencil size={14} />
+ </button>
+ {cred.username !== 'admin' && (
+ <button
+ type="button"
+ onClick={() => setDeleteId(cred.id)}
+ className="p-1.5 rounded-md hover:bg-red-50 transition-colors"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-error)' }}
+ title="Delete user"
+ >
+ <Trash2 size={14} />
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+
+ {/* Collapsible permissions row */}
+ {isOpen && (
+ <tr style={{ borderBottom: '1px solid var(--color-border)' }}>
+ <td />
+ <td colSpan={7} className="px-3 py-3" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ {permissions.length === 0 ? (
+ <div style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
+ Loading permissions…
+ </div>
+ ) : (
+ <div>
+ <div className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text-secondary)' }}>
+ {cred.role === 'admin'
+ ? 'Admin — implicitly allowed for every feature below.'
+ : `Permissions for role: ${ROLE_LABEL[cred.role]}`}
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+ {permsByCategory.map(([cat, rows]) => (
+ <div
+ key={cat}
+ className="rounded-md p-3"
+ style={{ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
+ >
+ <div className="text-xs font-semibold uppercase mb-2" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em' }}>
+ {cat}
+ </div>
+ <ul className="space-y-1">
+ {rows.map((p) => {
+ const allowed = userHasPermission(cred.role, p);
+ return (
+ <li key={p.feature_key} className="flex items-center gap-2" style={{ fontSize: '0.8rem', color: allowed ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
+ {allowed ? (
+ <Check size={13} style={{ color: '#10b981', flexShrink: 0 }} />
+ ) : (
+ <Minus size={13} style={{ color: '#6B7280', flexShrink: 0 }} />
+ )}
+ <span>{p.label}</span>
+ <span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.7rem', marginLeft: 'auto' }}>
+ {p.feature_key}
+ </span>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ ))}
+ </div>
+ <div className="mt-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Edit the role × feature matrix below to change what Users / Interns / Pulse can do.
+ </div>
+ </div>
+ )}
+ </td>
+ </tr>
+ )}
+ </Fragment>
+ );
+ })}
</tbody>
</table>
</div>
@@ -362,7 +534,7 @@ export default function CredentialsSection() {
{/* Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--color-border)' }}>
<h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
- {editingId ? 'Edit Credential' : 'Add Credential'}
+ {editingId ? 'Edit User' : 'Add User'}
</h4>
<button
type="button"
@@ -374,7 +546,7 @@ export default function CredentialsSection() {
</div>
{/* Body */}
- <div className="px-5 py-4 space-y-4">
+ <div className="px-5 py-4 space-y-4" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
{formError && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm" style={{ backgroundColor: 'rgba(220, 38, 38, 0.08)', color: '#dc2626', border: '1px solid rgba(220, 38, 38, 0.2)' }}>
<AlertTriangle size={14} />
@@ -382,6 +554,33 @@ export default function CredentialsSection() {
</div>
)}
+ {/* Full Name */}
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Full Name
+ </label>
+ <input
+ className="input w-full"
+ value={formFullName}
+ onChange={(e) => setFormFullName(e.target.value)}
+ placeholder="e.g. Natalia Abrams"
+ />
+ </div>
+
+ {/* Email */}
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Email
+ </label>
+ <input
+ className="input w-full"
+ type="email"
+ value={formEmail}
+ onChange={(e) => setFormEmail(e.target.value)}
+ placeholder="natalia@studentdebtcrisis.org"
+ />
+ </div>
+
{/* Username */}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
@@ -392,7 +591,7 @@ export default function CredentialsSection() {
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
disabled={!!editingId}
- placeholder="e.g. nikki_staff"
+ placeholder="e.g. natalia"
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', ...(editingId ? { opacity: 0.6, cursor: 'not-allowed' } : {}) }}
/>
{editingId && (
@@ -432,14 +631,32 @@ export default function CredentialsSection() {
<select
className="input w-full"
value={formRole}
- onChange={(e) => setFormRole(e.target.value as 'admin' | 'staff' | 'pulse')}
+ onChange={(e) => setFormRole(e.target.value as UserRole)}
>
- <option value="admin">Admin (Norma)</option>
- <option value="staff">Staff (Nikki)</option>
- <option value="pulse">Pulse (Public)</option>
+ <option value="admin">Admin — full access</option>
+ <option value="staff">User — staff with privileges from matrix</option>
+ <option value="intern">Intern — limited access, set by admin</option>
+ <option value="pulse">Pulse — public petition user</option>
</select>
</div>
+ {/* Active toggle */}
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Status
+ </label>
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={formIsActive}
+ onChange={(e) => setFormIsActive(e.target.checked)}
+ />
+ <span style={{ color: 'var(--color-text)', fontSize: '0.85rem' }}>
+ Account is active (can log in)
+ </span>
+ </label>
+ </div>
+
{/* Organization */}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
@@ -460,13 +677,13 @@ export default function CredentialsSection() {
{/* Display Name */}
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
- Display Name
+ Display Name <span style={{ color: 'var(--color-text-muted)' }}>(optional, shown in UI if Full Name is blank)</span>
</label>
<input
className="input w-full"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
- placeholder="e.g. Nikki (SDCC Staff)"
+ placeholder="e.g. Natalia (SDCC ED)"
/>
</div>
</div>
@@ -508,7 +725,7 @@ export default function CredentialsSection() {
<AlertTriangle size={18} style={{ color: '#dc2626' }} />
</div>
<div>
- <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Delete Credential</h4>
+ <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Delete User</h4>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
This will permanently remove the login for <strong>{credentials.find(c => c.id === deleteId)?.username}</strong>.
</p>
diff --git a/components/settings/PermissionsMatrix.tsx b/components/settings/PermissionsMatrix.tsx
new file mode 100644
index 0000000..adb2721
--- /dev/null
+++ b/components/settings/PermissionsMatrix.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
+import {
+ Sliders, RefreshCw, Save, ChevronDown, ChevronRight, Check,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+
+type UserRole = 'admin' | 'staff' | 'intern' | 'pulse';
+
+interface PermissionRow {
+ feature_key: string;
+ label: string;
+ category: string;
+ allowed_roles: string[];
+ description: string | null;
+}
+
+const ROLE_LABEL: Record<UserRole, string> = {
+ admin: 'Admin',
+ staff: 'User',
+ intern: 'Intern',
+ pulse: 'Pulse',
+};
+
+const ROLE_COLOR: Record<UserRole, string> = {
+ admin: '#dc2626',
+ staff: '#4f46e5',
+ intern: '#b45309',
+ pulse: '#0a7c59',
+};
+
+export default function PermissionsMatrix() {
+ const { addToast } = useToast();
+ const [expanded, setExpanded] = useState(true);
+ const [permissions, setPermissions] = useState<PermissionRow[]>([]);
+ const [assignableRoles, setAssignableRoles] = useState<UserRole[]>(['staff', 'intern', 'pulse']);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [dirty, setDirty] = useState<Record<string, string[]>>({}); // feature_key -> new allowed_roles
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch('/api/admin/permissions');
+ if (!res.ok) throw new Error('Failed to load');
+ const data = await res.json();
+ setPermissions(data.permissions || []);
+ setAssignableRoles((data.assignableRoles || ['staff', 'intern', 'pulse']) as UserRole[]);
+ setDirty({});
+ } catch {
+ addToast('Failed to load permissions', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, [addToast]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const byCategory = useMemo(() => {
+ const map = new Map<string, PermissionRow[]>();
+ for (const p of permissions) {
+ if (!map.has(p.category)) map.set(p.category, []);
+ map.get(p.category)!.push(p);
+ }
+ return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]));
+ }, [permissions]);
+
+ function currentAllowed(p: PermissionRow): string[] {
+ return dirty[p.feature_key] ?? p.allowed_roles;
+ }
+
+ function toggle(p: PermissionRow, role: UserRole) {
+ const allowed = new Set(currentAllowed(p));
+ if (allowed.has(role)) allowed.delete(role);
+ else allowed.add(role);
+ setDirty((d) => ({ ...d, [p.feature_key]: Array.from(allowed) }));
+ }
+
+ async function handleSave() {
+ const updates = Object.entries(dirty).map(([feature_key, allowed_roles]) => ({
+ feature_key,
+ allowed_roles,
+ }));
+ if (updates.length === 0) {
+ addToast('No changes to save', 'success');
+ return;
+ }
+ setSaving(true);
+ try {
+ const res = await fetch('/api/admin/permissions', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ updates }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error || 'Failed to save');
+ }
+ const data = await res.json();
+ addToast(`Saved ${data.changed} permission(s)`, 'success');
+ await load();
+ } catch (err) {
+ addToast((err as Error).message, 'error');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function handleRevert() {
+ setDirty({});
+ }
+
+ const dirtyCount = Object.keys(dirty).length;
+
+ return (
+ <div className="card p-5">
+ <button
+ type="button"
+ onClick={() => setExpanded(!expanded)}
+ className="w-full flex items-center justify-between"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
+ >
+ <div className="flex items-center gap-2">
+ <Sliders size={16} style={{ color: '#4f46e5' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ Role Permissions Matrix
+ </h3>
+ <span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(79, 70, 229, 0.10)', color: '#4f46e5' }}>
+ {permissions.length} features
+ </span>
+ {dirtyCount > 0 && (
+ <span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)', color: '#b45309' }}>
+ {dirtyCount} unsaved
+ </span>
+ )}
+ </div>
+ <div style={{ color: 'var(--color-text-muted)' }}>
+ {expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
+ </div>
+ </button>
+
+ {expanded && (
+ <div className="mt-4">
+ <p className="text-xs mb-3" style={{ color: 'var(--color-text-muted)' }}>
+ Tick which roles can use each feature. Admin is implicitly allowed everywhere.
+ Changes take effect within 60 seconds (server cache).
+ </p>
+
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <RefreshCw size={18} className="animate-spin" style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ ) : (
+ <>
+ <div className="overflow-x-auto rounded-lg" style={{ border: '1px solid var(--color-border)' }}>
+ <table className="w-full text-sm" style={{ borderCollapse: 'collapse' }}>
+ <thead>
+ <tr style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Feature</th>
+ <th className="text-left px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Key</th>
+ <th className="text-center px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: ROLE_COLOR.admin, letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>Admin</th>
+ {assignableRoles.map((r) => (
+ <th key={r} className="text-center px-3 py-2.5 font-semibold text-xs uppercase" style={{ color: ROLE_COLOR[r], letterSpacing: '0.05em', borderBottom: '1px solid var(--color-border)' }}>
+ {ROLE_LABEL[r]}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {byCategory.map(([cat, rows]) => (
+ <Fragment key={cat}>
+ <tr style={{ backgroundColor: 'var(--color-surface)' }}>
+ <td colSpan={3 + assignableRoles.length} className="px-3 py-2 text-xs font-semibold uppercase" style={{ color: 'var(--color-text-muted)', letterSpacing: '0.06em', borderBottom: '1px solid var(--color-border)' }}>
+ {cat}
+ </td>
+ </tr>
+ {rows.map((p) => {
+ const allowed = new Set(currentAllowed(p));
+ const isDirty = !!dirty[p.feature_key];
+ return (
+ <tr key={p.feature_key} style={{ borderBottom: '1px solid var(--color-border)', backgroundColor: isDirty ? 'rgba(245, 158, 11, 0.06)' : undefined }}>
+ <td className="px-3 py-2" style={{ color: 'var(--color-text)' }}>
+ <div>{p.label}</div>
+ {p.description && (
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {p.description}
+ </div>
+ )}
+ </td>
+ <td className="px-3 py-2" style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
+ {p.feature_key}
+ </td>
+ <td className="px-3 py-2 text-center" title="Admin is always allowed">
+ <Check size={14} style={{ color: ROLE_COLOR.admin, opacity: 0.6, display: 'inline' }} />
+ </td>
+ {assignableRoles.map((role) => (
+ <td key={role} className="px-3 py-2 text-center">
+ <input
+ type="checkbox"
+ checked={allowed.has(role)}
+ onChange={() => toggle(p, role)}
+ style={{ cursor: 'pointer', accentColor: ROLE_COLOR[role] }}
+ />
+ </td>
+ ))}
+ </tr>
+ );
+ })}
+ </Fragment>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ <div className="flex items-center justify-end gap-2 mt-3">
+ {dirtyCount > 0 && (
+ <button className="btn btn-ghost btn-sm" onClick={handleRevert} disabled={saving}>
+ Revert
+ </button>
+ )}
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={handleSave}
+ disabled={saving || dirtyCount === 0}
+ >
+ {saving ? (
+ <><RefreshCw size={13} className="animate-spin" /><span>Saving...</span></>
+ ) : (
+ <><Save size={13} /><span>Save {dirtyCount > 0 ? `(${dirtyCount})` : ''}</span></>
+ )}
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
diff --git a/components/settings/SettingsTab.tsx b/components/settings/SettingsTab.tsx
index 13941d9..59a48fa 100644
--- a/components/settings/SettingsTab.tsx
+++ b/components/settings/SettingsTab.tsx
@@ -14,6 +14,7 @@ import { useAuth } from '../AuthProvider';
import { useOrg } from '../OrgProvider';
import { SkeletonList } from '../Skeleton';
import CredentialsSection from './CredentialsSection';
+import PermissionsMatrix from './PermissionsMatrix';
import ApiCredentialsSection from './ApiCredentialsSection';
import PulseConfigSection from './PulseConfigSection';
import ModuleManager from './ModuleManager';
@@ -1886,6 +1887,9 @@ Example format: ["keyword1", "keyword2", "keyword3"]`,
{/* ── User Credentials Management ────────────────────────────────────── */}
<CredentialsSection />
+ {/* ── Role × Feature Permissions Matrix (admin-editable) ────────────── */}
+ <PermissionsMatrix />
+
{/* ── Pulse Public Portal Config ─────────────────────────────────────── */}
<PulseConfigSection />
</div>
diff --git a/db/024_user_management.sql b/db/024_user_management.sql
new file mode 100644
index 0000000..2f9a7d2
--- /dev/null
+++ b/db/024_user_management.sql
@@ -0,0 +1,86 @@
+-- ============================================================
+-- 024 — User Management: intern role + full_name/email/is_active/last_login_at
+-- + role_permissions matrix (admin-editable feature×role gate)
+-- ============================================================
+-- Idempotent. Safe to re-run.
+
+-- 1. Extend tier_credentials role enum to include 'intern'
+ALTER TABLE tier_credentials DROP CONSTRAINT IF EXISTS tier_credentials_role_check;
+ALTER TABLE tier_credentials
+ ADD CONSTRAINT tier_credentials_role_check
+ CHECK (role IN ('admin', 'staff', 'intern', 'pulse'));
+
+-- 2. New columns for proper user records
+ALTER TABLE tier_credentials ADD COLUMN IF NOT EXISTS full_name TEXT;
+ALTER TABLE tier_credentials ADD COLUMN IF NOT EXISTS email TEXT;
+ALTER TABLE tier_credentials ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE tier_credentials ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
+ALTER TABLE tier_credentials ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES tier_credentials(id);
+
+CREATE INDEX IF NOT EXISTS idx_tier_credentials_active ON tier_credentials(is_active);
+CREATE INDEX IF NOT EXISTS idx_tier_credentials_email ON tier_credentials(email) WHERE email IS NOT NULL;
+
+-- 3. Backfill display_name → full_name where empty (best-effort)
+UPDATE tier_credentials
+ SET full_name = display_name
+ WHERE full_name IS NULL AND display_name IS NOT NULL;
+
+-- 4. role_permissions — admin-editable matrix of feature×role.
+-- Each row = one feature_key (e.g. 'gmail.read', 'petitions.publish').
+-- allowed_roles is an array; admin is implicitly allowed for everything (enforced in lib/permissions.ts).
+CREATE TABLE IF NOT EXISTS role_permissions (
+ feature_key TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ category TEXT NOT NULL DEFAULT 'general',
+ allowed_roles TEXT[] NOT NULL DEFAULT '{}',
+ description TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_by UUID REFERENCES tier_credentials(id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_role_permissions_category ON role_permissions(category);
+
+DROP TRIGGER IF EXISTS tr_role_permissions_updated ON role_permissions;
+CREATE TRIGGER tr_role_permissions_updated
+ BEFORE UPDATE ON role_permissions
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- 5. Seed default permission rows. ON CONFLICT DO NOTHING so re-running the
+-- migration doesn't clobber the admin's later edits.
+INSERT INTO role_permissions (feature_key, label, category, allowed_roles, description) VALUES
+ -- Gmail / email center
+ ('gmail.read', 'View Gmail inbox', 'gmail', ARRAY['staff','intern'], 'Read incoming + sent Gmail threads in the CRM'),
+ ('gmail.compose', 'Compose + send email', 'gmail', ARRAY['staff'], 'Use the composer + Send Now button'),
+ ('gmail.bulk_send', 'Bulk send to lists', 'gmail', ARRAY[]::text[], 'Bulk-mail features (admin-only by default)'),
+ -- Drafts / pipeline
+ ('drafts.view', 'View drafts', 'drafts', ARRAY['staff','intern'], 'Read shared drafts library'),
+ ('drafts.edit', 'Edit + create drafts', 'drafts', ARRAY['staff','intern'], 'Author and edit drafts'),
+ ('drafts.publish', 'Publish / approve drafts', 'drafts', ARRAY['staff'], 'Push drafts to active pipeline'),
+ -- Petitions (Pulse)
+ ('petitions.view', 'View petitions', 'petitions', ARRAY['staff','intern','pulse'], 'Browse the petitions list + detail pages'),
+ ('petitions.create', 'Create petition drafts', 'petitions', ARRAY['staff','intern'], 'Start a new petition draft'),
+ ('petitions.publish', 'Publish petitions live', 'petitions', ARRAY['staff'], 'Promote drafts to active / public'),
+ ('petitions.sign', 'Sign petitions', 'petitions', ARRAY['staff','intern','pulse'], 'Add a signature'),
+ -- Contacts / CRM
+ ('contacts.view', 'View contacts', 'contacts', ARRAY['staff','intern'], 'Read CRM contacts'),
+ ('contacts.edit', 'Edit contacts', 'contacts', ARRAY['staff'], 'Add / update / delete CRM contacts'),
+ -- News / intelligence
+ ('news.view', 'View news feed', 'news', ARRAY['staff','intern'], 'Browse news intelligence panel'),
+ ('news.refresh', 'Trigger news refresh', 'news', ARRAY['staff'], 'Manual news refresh button'),
+ -- Grants / funding
+ ('grants.view', 'View grants explorer', 'grants', ARRAY['staff','intern'], 'Read funding opportunities'),
+ ('grants.edit', 'Edit grant applications', 'grants', ARRAY['staff'], 'Author + submit grant content'),
+ -- Outreach / social
+ ('outreach.view', 'View outreach pipeline', 'outreach', ARRAY['staff','intern'], 'Read outreach pipeline'),
+ ('outreach.send', 'Trigger outreach sends', 'outreach', ARRAY['staff'], 'Approve and send outreach campaigns'),
+ ('social.view', 'View social posts', 'social', ARRAY['staff','intern'], 'Read scheduled/posted social content'),
+ ('social.publish', 'Publish to social', 'social', ARRAY['staff'], 'Push posts to live social accounts'),
+ -- Settings
+ ('settings.view', 'View settings', 'settings', ARRAY['staff'], 'Read non-sensitive settings panels'),
+ ('settings.manage_users','Manage users + roles', 'settings', ARRAY[]::text[], 'Admin-only: create/edit/disable users + edit this matrix')
+ON CONFLICT (feature_key) DO NOTHING;
+
+-- 6. Comment
+COMMENT ON TABLE role_permissions IS '024_user_management: admin-editable feature × role gate. admin is implicitly allowed for every feature in lib/permissions.ts.';
+COMMENT ON COLUMN tier_credentials.is_active IS '024_user_management: false = login blocked, kept for audit history';
+COMMENT ON COLUMN tier_credentials.last_login_at IS '024_user_management: bumped on every successful login';
diff --git a/lib/auth.ts b/lib/auth.ts
index bea221c..9b61ae3 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -10,9 +10,20 @@ const MAX_AGE_SECONDS = 60 * 60 * 24; // 24 hours
/* ─── Types ──────────────────────────────────────────────────────────────── */
+export type UserRole = 'admin' | 'staff' | 'intern' | 'pulse';
+
+export const ALL_ROLES: UserRole[] = ['admin', 'staff', 'intern', 'pulse'];
+
+export const ROLE_LABEL: Record<UserRole, string> = {
+ admin: 'Admin',
+ staff: 'User',
+ intern: 'Intern',
+ pulse: 'Pulse',
+};
+
export interface AuthSession {
username: string;
- role: 'admin' | 'staff' | 'pulse';
+ role: UserRole;
orgId: string | null;
}
diff --git a/lib/permissions.ts b/lib/permissions.ts
new file mode 100644
index 0000000..29c18de
--- /dev/null
+++ b/lib/permissions.ts
@@ -0,0 +1,68 @@
+/**
+ * lib/permissions.ts — feature × role gate, admin-editable via UI.
+ *
+ * Reads the role_permissions table (migration 024) and answers
+ * `hasPermission(role, feature)`. Admin is implicitly allowed for every
+ * feature — never gated by the matrix.
+ *
+ * The result is cached in-process for 60 s. Mutations via /api/admin/permissions
+ * should call `clearPermissionsCache()` so the next read picks up the change.
+ */
+
+import { query } from '@/lib/db';
+import type { UserRole } from '@/lib/auth';
+
+export interface PermissionRow {
+ feature_key: string;
+ label: string;
+ category: string;
+ allowed_roles: string[];
+ description: string | null;
+ updated_at: string;
+}
+
+const CACHE_TTL_MS = 60_000;
+let cache: { at: number; rows: PermissionRow[] } | null = null;
+
+export function clearPermissionsCache() {
+ cache = null;
+}
+
+export async function loadPermissions(): Promise<PermissionRow[]> {
+ if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.rows;
+ const { rows } = await query<PermissionRow>(
+ `SELECT feature_key, label, category, allowed_roles, description, updated_at
+ FROM role_permissions
+ ORDER BY category, feature_key`,
+ );
+ cache = { at: Date.now(), rows };
+ return rows;
+}
+
+/**
+ * Is the given role allowed to use the given feature?
+ * Admin always returns true (cannot be gated out).
+ * Missing feature_key returns false (closed-by-default).
+ */
+export async function hasPermission(
+ role: UserRole | null | undefined,
+ feature: string,
+): Promise<boolean> {
+ if (!role) return false;
+ if (role === 'admin') return true;
+ const rows = await loadPermissions();
+ const row = rows.find((r) => r.feature_key === feature);
+ return !!row && row.allowed_roles.includes(role);
+}
+
+/** Synchronous variant that uses an already-loaded matrix (for batch checks). */
+export function hasPermissionFromRows(
+ rows: PermissionRow[],
+ role: UserRole | null | undefined,
+ feature: string,
+): boolean {
+ if (!role) return false;
+ if (role === 'admin') return true;
+ const row = rows.find((r) => r.feature_key === feature);
+ return !!row && row.allowed_roles.includes(role);
+}
← 189ac33 feat(admin): comprehensive API/Token/MCP registry + age-adap
·
back to Norma
·
feat(gmail): per-message Assign-to + per-user read receipts 38145df →