- login page (Supabase email/password sign-in + sign-up) - onboarding: create first tenant, redirects into /dashboard once a membership exists - dashboard shell: sidebar nav, tenant switcher (x-tenant-id header, never in body/query -- matches ceo-api's TenantGuard), sign out - Organizations, Tasks, and Team (members/RBAC) pages wired to ceo-api via a thin apiFetch wrapper that attaches the Supabase bearer token - executive "paper & ink" design tokens (Tailwind) replacing defaults - Dockerfile: declare NEXT_PUBLIC_* as build ARGs so Coolify's existing build-time env vars actually get inlined (was previously silently dropped, which would have crashed prerendering)
85 lines
3 KiB
TypeScript
85 lines
3 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import type { Session } from '@supabase/supabase-js';
|
|
import { supabase } from '../lib/supabase';
|
|
import { ACTIVE_TENANT_STORAGE_KEY, apiFetch, type MeResponse, type TenantMembership } from '../lib/api';
|
|
|
|
interface SessionContextValue {
|
|
authSession: Session | null;
|
|
authLoading: boolean;
|
|
me: MeResponse | null;
|
|
meLoading: boolean;
|
|
activeTenant: TenantMembership | null;
|
|
setActiveTenantId: (tenantId: string) => void;
|
|
signOut: () => Promise<void>;
|
|
}
|
|
|
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
|
|
|
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|
const router = useRouter();
|
|
const queryClient = useQueryClient();
|
|
const [authSession, setAuthSession] = useState<Session | null>(null);
|
|
const [authLoading, setAuthLoading] = useState(true);
|
|
const [activeTenantId, setActiveTenantIdState] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
supabase.auth.getSession().then(({ data }) => {
|
|
setAuthSession(data.session);
|
|
setAuthLoading(false);
|
|
});
|
|
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
|
|
setAuthSession(session);
|
|
});
|
|
setActiveTenantIdState(window.localStorage.getItem(ACTIVE_TENANT_STORAGE_KEY));
|
|
return () => subscription.subscription.unsubscribe();
|
|
}, []);
|
|
|
|
const { data: me = null, isLoading: meLoading } = useQuery({
|
|
queryKey: ['me', authSession?.user.id],
|
|
queryFn: () => apiFetch<MeResponse>('/v1/me'),
|
|
enabled: Boolean(authSession),
|
|
});
|
|
|
|
const activeTenant = useMemo(() => {
|
|
if (!me || me.tenants.length === 0) {
|
|
return null;
|
|
}
|
|
return me.tenants.find((tenant) => tenant.tenantId === activeTenantId) ?? me.tenants[0];
|
|
}, [me, activeTenantId]);
|
|
|
|
const setActiveTenantId = useCallback(
|
|
(tenantId: string) => {
|
|
window.localStorage.setItem(ACTIVE_TENANT_STORAGE_KEY, tenantId);
|
|
setActiveTenantIdState(tenantId);
|
|
// datele afisate apartin tenantului anterior — invalidam tot ce e tenant-scoped
|
|
queryClient.invalidateQueries();
|
|
},
|
|
[queryClient],
|
|
);
|
|
|
|
const signOut = useCallback(async () => {
|
|
await supabase.auth.signOut();
|
|
window.localStorage.removeItem(ACTIVE_TENANT_STORAGE_KEY);
|
|
queryClient.clear();
|
|
router.push('/login');
|
|
}, [queryClient, router]);
|
|
|
|
const value = useMemo(
|
|
() => ({ authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut }),
|
|
[authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut],
|
|
);
|
|
|
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
|
}
|
|
|
|
export function useSession(): SessionContextValue {
|
|
const context = useContext(SessionContext);
|
|
if (!context) {
|
|
throw new Error('useSession must be used inside SessionProvider');
|
|
}
|
|
return context;
|
|
}
|