[object Object]

← 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

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 &amp; 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 →