- 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>
128 lines
3.4 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
),
|
|
);
|