feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 01:25:15 +01:00
co-authored by Claude Sonnet 4.6
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions
+61 -34
View File
@@ -33,9 +33,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const apiBase = import.meta.env.VITE_API_BASE as string;
const persistSession = useCallback((session: Session | null) => {
if (session) {
storageService.set(StorageKeys.SUPABASE_SESSION, session);
@@ -69,57 +71,82 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole };
}, []);
const triggerUserInit = useCallback((token: string) => {
fetch(`${apiBase}/user/init`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then(data => logger.debug('auth-context', '✅ User init', data))
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
}, [apiBase]);
useEffect(() => {
// Canonical Supabase auth pattern: rely solely on onAuthStateChange.
// INITIAL_SESSION fires immediately with the current session state,
// eliminating the race condition between loadInitialSession + onAuthStateChange.
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
switch (event) {
case 'INITIAL_SESSION':
case 'SIGNED_IN':
case 'TOKEN_REFRESHED': {
persistSession(session ?? null);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
if (event === 'SIGNED_IN') {
persistSession(session ?? null);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
triggerUserInit(session.access_token);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
// Always clear loading after the first auth event resolves
setLoading(false);
break;
}
case 'SIGNED_OUT': {
persistSession(null);
} else {
setUser(null);
setUserRole(null);
setAccessToken(null);
setLoading(false);
break;
}
default:
break;
setLoading(false);
return;
}
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
setUser(null);
setUserRole(null);
setAccessToken(null);
}
setLoading(false);
return;
}
if (event === 'SIGNED_OUT') {
persistSession(null);
setUser(null);
setUserRole(null);
setAccessToken(null);
setLoading(false);
}
}
);
return () => subscription.unsubscribe();
}, [buildUserFromSupabase, persistSession]);
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
const signIn = async (email: string, password: string) => {
try {
+28 -32
View File
@@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore';
@@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
});
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const { user, accessToken } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore();
@@ -215,12 +214,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
setIsLoading(true);
setError(null);
// Set database names
const userDb = profile.user_db_name || (user?.email ?
DatabaseNameService.getStoredUserDatabase() || null : null);
if (!userDb) {
throw new Error('No user database name available');
// Inject auth into navigation store so Supabase queries work
if (user?.id && accessToken) {
navigationStore.setAuthInfo(accessToken, user.id);
}
// Initialize user node in profile context
@@ -236,7 +232,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
main: 'profile',
base: 'profile',
extended: 'overview'
}, userDb, profile.school_db_name),
}, null, null),
switchTimeout
]);
@@ -271,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Continue without user node - this is not critical for basic functionality
}
// Set final state
setUserDbName(userDb);
setWorkerDbName(profile.school_db_name);
// Set final state — userDbName signals auth availability for UI guards
setUserDbName(user?.id || null);
setWorkerDbName(null);
setIsInitialized(true);
setIsLoading(false);
initializationRef.current.isComplete = true;
@@ -294,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Calendar Navigation Functions
const navigateToDay = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'day'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -334,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToWeek = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'week'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -374,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToMonth = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'month'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -414,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToYear = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'year'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -455,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Worker Navigation Functions
const navigateToTimetable = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'timetable'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -492,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToJournal = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'journal'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -529,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToPlanner = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'planner'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -566,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToClass = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'classes'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {
@@ -604,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToLesson = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'lessons'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {