- Fix eternal Loading spinner: track Zustand hydration state, wait for both hydration and pathname before auth redirect decisions - OLIVER Brand: Montserrat font, gold/orange/black palette, 4px radius, dark mode default, OLIVER wordmark logo, remove blue/purple gradients - Cloud Run HTTP service for document processing (cloud_run_service.py, Dockerfile.cloud-run) with OIDC auth client - Knowledge endpoints: delegate to Cloud Run when CLOUD_RUN_PROCESSOR_URL is set, fallback to Celery - Gitignore: junk files, context handover docs, root node_modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
107 lines
2.8 KiB
TypeScript
107 lines
2.8 KiB
TypeScript
// ============================================
|
|
// Authentication Store (Zustand with Persistence)
|
|
// ============================================
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { User } from '@/types';
|
|
|
|
interface AuthState {
|
|
// State
|
|
user: User | null;
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
isAuthenticated: boolean;
|
|
_hasHydrated: boolean;
|
|
|
|
// Actions
|
|
login: (user: User, accessToken: string, refreshToken: string) => void;
|
|
logout: () => void;
|
|
updateAccessToken: (accessToken: string) => void;
|
|
updateUser: (user: User) => void;
|
|
setHasHydrated: (v: boolean) => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set) => ({
|
|
// Initial state
|
|
user: null,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
_hasHydrated: false,
|
|
|
|
// Login action
|
|
login: (user, accessToken, refreshToken) => {
|
|
set({
|
|
user,
|
|
accessToken,
|
|
refreshToken,
|
|
isAuthenticated: true,
|
|
});
|
|
|
|
// Also store in localStorage for API client
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('access_token', accessToken);
|
|
localStorage.setItem('refresh_token', refreshToken);
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
}
|
|
},
|
|
|
|
// Logout action
|
|
logout: () => {
|
|
set({
|
|
user: null,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
});
|
|
|
|
// Clear localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
localStorage.removeItem('user');
|
|
}
|
|
},
|
|
|
|
// Update access token (used during token refresh)
|
|
updateAccessToken: (accessToken) => {
|
|
set({ accessToken });
|
|
|
|
// Update localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('access_token', accessToken);
|
|
}
|
|
},
|
|
|
|
// Update user (used when fetching updated profile)
|
|
updateUser: (user) => {
|
|
set({ user });
|
|
|
|
// Update localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
}
|
|
},
|
|
|
|
setHasHydrated: (v) => {
|
|
set({ _hasHydrated: v });
|
|
},
|
|
}),
|
|
{
|
|
name: 'auth-storage', // localStorage key
|
|
partialize: (state) => ({
|
|
// Only persist these fields (exclude _hasHydrated)
|
|
user: state.user,
|
|
accessToken: state.accessToken,
|
|
refreshToken: state.refreshToken,
|
|
isAuthenticated: state.isAuthenticated,
|
|
}),
|
|
onRehydrateStorage: () => (state) => {
|
|
state?.setHasHydrated(true);
|
|
},
|
|
}
|
|
)
|
|
);
|