Files
HOA_Financial_Platform/frontend/src/stores/authStore.ts
olsch01 c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00

128 lines
3.4 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
status?: string;
settings?: Record<string, any>;
}
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;
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,
}),
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: () =>
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
}),
{
name: 'ledgeriq-auth',
version: 5,
migrate: () => ({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
},
),
);