Introduces a capability layer on top of existing roles that controls feature visibility and access. Capabilities follow an area.feature.action taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins can customize via grant/revoke overrides stored in org settings JSONB. Key changes: - Add vice_president role to DB schema - Backend: capability constants, resolution logic, CapabilityGuard (global), @RequireCapability decorator on all 16 tenant controllers - Frontend: permission hooks (useCanEdit, useHasCapability), CapabilityGate component, sidebar filtering by capability, all 17 pages migrated from useIsReadOnly to capability-based checks - New admin UI: /settings/permissions matrix page for per-tenant role customization with grant/revoke delta model - GET /organizations/my-capabilities endpoint for capability refresh - Validation of permissionOverrides in settings updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
3.7 KiB
TypeScript
134 lines
3.7 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
interface Organization {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
status?: string;
|
|
planLevel?: string;
|
|
settings?: Record<string, any>;
|
|
capabilities?: string[];
|
|
}
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
isSuperadmin?: boolean;
|
|
isPlatformOwner?: boolean;
|
|
hasSeenIntro?: boolean;
|
|
}
|
|
|
|
interface ImpersonationOriginal {
|
|
token: string;
|
|
user: User;
|
|
organizations: Organization[];
|
|
currentOrg: Organization | null;
|
|
}
|
|
|
|
interface AuthState {
|
|
token: string | null;
|
|
user: User | null;
|
|
organizations: Organization[];
|
|
currentOrg: Organization | null;
|
|
impersonationOriginal: ImpersonationOriginal | null;
|
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
|
setToken: (token: string) => void;
|
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
|
setUserIntroSeen: () => void;
|
|
setOrgSettings: (settings: Record<string, any>) => void;
|
|
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
|
stopImpersonation: () => void;
|
|
logout: () => void;
|
|
}
|
|
|
|
/** Hook to check if the current user has read-only (viewer) access */
|
|
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
token: null,
|
|
user: null,
|
|
organizations: [],
|
|
currentOrg: null,
|
|
impersonationOriginal: null,
|
|
setAuth: (token, user, organizations) =>
|
|
set({
|
|
token,
|
|
user,
|
|
organizations,
|
|
// Don't auto-select org — force user through SelectOrgPage
|
|
currentOrg: null,
|
|
}),
|
|
setToken: (token) => set({ token }),
|
|
setCurrentOrg: (org, token) =>
|
|
set((state) => ({
|
|
currentOrg: org,
|
|
token: token || state.token,
|
|
})),
|
|
setUserIntroSeen: () =>
|
|
set((state) => ({
|
|
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
|
|
})),
|
|
setOrgSettings: (settings) =>
|
|
set((state) => ({
|
|
currentOrg: state.currentOrg
|
|
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
|
|
: null,
|
|
})),
|
|
startImpersonation: (token, user, organizations) => {
|
|
const state = get();
|
|
set({
|
|
impersonationOriginal: {
|
|
token: state.token!,
|
|
user: state.user!,
|
|
organizations: state.organizations,
|
|
currentOrg: state.currentOrg,
|
|
},
|
|
token,
|
|
user,
|
|
organizations,
|
|
currentOrg: null,
|
|
});
|
|
},
|
|
stopImpersonation: () => {
|
|
const { impersonationOriginal } = get();
|
|
if (impersonationOriginal) {
|
|
set({
|
|
token: impersonationOriginal.token,
|
|
user: impersonationOriginal.user,
|
|
organizations: impersonationOriginal.organizations,
|
|
currentOrg: impersonationOriginal.currentOrg,
|
|
impersonationOriginal: null,
|
|
});
|
|
}
|
|
},
|
|
logout: () => {
|
|
// Fire-and-forget server-side logout to revoke refresh token cookie
|
|
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
|
|
set({
|
|
token: null,
|
|
user: null,
|
|
organizations: [],
|
|
currentOrg: null,
|
|
impersonationOriginal: null,
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: 'ledgeriq-auth',
|
|
version: 6,
|
|
migrate: () => ({
|
|
token: null,
|
|
user: null,
|
|
organizations: [],
|
|
currentOrg: null,
|
|
impersonationOriginal: null,
|
|
}),
|
|
},
|
|
),
|
|
);
|