- Onboarding wizard: add Reserve Account step between Operating and Assessments, redirect to Budget Planning on completion - Dashboard: health score pending state shows clickable links to set up missing items - Projects/Vendors: rich empty-state wizard screens with real-world examples and CTAs - Investment Planning: auto-refresh AI recommendations when empty or stale (>30 days) - Hide Invoices and Payments menus (see PARKING-LOT.md for re-enablement) - Send welcome email via Resend when new members are added to a tenant - Enforce 5-member limit for Starter/Standard/Professional plans (Enterprise unlimited) - Cash flow forecast: only mark months as "Actual" when journal entries exist, fixing the issue where months without data showed as actuals - Bump version to 2026.3.19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
133 lines
3.7 KiB
TypeScript
133 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>;
|
|
}
|
|
|
|
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: 5,
|
|
migrate: () => ({
|
|
token: null,
|
|
user: null,
|
|
organizations: [],
|
|
currentOrg: null,
|
|
impersonationOriginal: null,
|
|
}),
|
|
},
|
|
),
|
|
);
|