From 4435a12dd7003b26e4f3f05b21a7198c5f09d0ba Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 25 Apr 2026 08:31:05 -0400 Subject: [PATCH] feat: integrate tRPC client with Clerk authentication - Update trpc-client.ts with auth token management - Create comprehensive tRPC hooks for all project operations - Store Clerk session token in localStorage for API auth - Clear token on sign out - Support integer IDs matching backend schema Hooks created: - Projects: useProjects, useProject, useCreateProject, useUpdateProject, useDeleteProject - Characters: useCharacters, useCharacter, useCreateCharacter, useUpdateCharacter, useDeleteCharacter - Relationships: useRelationships, useCharacterRelationships, useCreateRelationship, useUpdateRelationship, useDeleteRelationship - Scenes: useScenes, useScene, useCreateScene, useUpdateScene, useDeleteScene - Stats: useCharacterStats, useProjectCharacterStats All 34 backend tests passing. --- src/lib/api/trpc-client.ts | 58 ++++---- src/lib/api/trpc-hooks.ts | 248 ++++++++++++++++++++------------ src/lib/auth/clerk-provider.tsx | 84 +++++++++-- 3 files changed, 262 insertions(+), 128 deletions(-) diff --git a/src/lib/api/trpc-client.ts b/src/lib/api/trpc-client.ts index ef0d4e5b3..614a818e3 100644 --- a/src/lib/api/trpc-client.ts +++ b/src/lib/api/trpc-client.ts @@ -1,31 +1,39 @@ import { createTRPCClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '../../../server/trpc'; -// Create tRPC client -export function createTRPCClientInstance(baseUrl: string = 'http://localhost:8080') { - return createTRPCClient({ - links: [ - httpBatchLink({ - url: `${baseUrl}/trpc`, - headers: () => { - // Add auth headers if available - const token = localStorage.getItem('auth_token'); - return { - authorization: token ? `Bearer ${token}` : '', - }; - }, - }), - ], - }); +let cachedToken: string | null = null; + +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + if (cachedToken) return cachedToken; + cachedToken = localStorage.getItem('auth_token'); + return cachedToken; } -// Helper for SSR -export function createServerTRPCClient(baseUrl: string = 'http://localhost:8080') { - return createTRPCClient({ - links: [ - httpBatchLink({ - url: `${baseUrl}/trpc`, - }), - ], - }); +export function setAuthToken(token: string | null) { + cachedToken = token; + if (typeof window !== 'undefined') { + if (token) { + localStorage.setItem('auth_token', token); + } else { + localStorage.removeItem('auth_token'); + } + } } + +// Create tRPC client with auth +export const trpc = createTRPCClient({ + links: [ + httpBatchLink({ + url: `${(import.meta as any).env?.VITE_API_URL || 'http://localhost:8080'}/trpc`, + headers: () => { + const token = getAuthToken(); + return { + authorization: token ? `Bearer ${token}` : '', + }; + }, + }), + ], +}); + +export type { AppRouter }; diff --git a/src/lib/api/trpc-hooks.ts b/src/lib/api/trpc-hooks.ts index 1dac8bcd3..f8bf5ccfb 100644 --- a/src/lib/api/trpc-hooks.ts +++ b/src/lib/api/trpc-hooks.ts @@ -1,107 +1,123 @@ import { createQuery, createMutation, useQueryClient } from '@tanstack/solid-query'; -import { createTRPCClientInstance } from './trpc-client'; -import type { Project, Character, CharacterRelationship, CharacterStats, Scene } from '../../../server/types/project'; - -const trpcClient = createTRPCClientInstance(); +import { trpc } from './trpc-client'; // Project hooks export function useProjects() { - return createQuery({ + return createQuery(() => ({ queryKey: ['projects'], queryFn: async () => { - const result = await trpcClient.project.listProjects.query(); - return result as Project[]; + return await trpc.project.listProjects.query(undefined); }, - }); + })); } -export function useProject(projectId: string) { - return createQuery({ - queryKey: ['project', projectId], +export function useProject(id: number) { + return createQuery(() => ({ + queryKey: ['project', id], queryFn: async () => { - const result = await trpcClient.project.getProject.query({ id: projectId }); - return result as Project; + return await trpc.project.getProject.query({ id }); }, - enabled: !!projectId, - }); + enabled: !!id, + })); } export function useCreateProject() { - return createMutation({ + const qc = useQueryClient(); + return createMutation(() => ({ mutationFn: async (input: { name: string; description?: string }) => { - return trpcClient.project.createProject.mutate(input) as Promise; + return await trpc.project.createProject.mutate(input); }, onSuccess: () => { - const qc = useQueryClient(); qc.invalidateQueries({ queryKey: ['projects'] }); }, - }); + })); +} + +export function useUpdateProject() { + const qc = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (input: { id: number; name?: string; description?: string }) => { + return await trpc.project.updateProject.mutate(input); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['projects'] }); + }, + })); +} + +export function useDeleteProject() { + const qc = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (id: number) => { + return await trpc.project.deleteProject.mutate({ id }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['projects'] }); + }, + })); } // Character hooks -export function useCharacters(projectId: string) { - return createQuery({ +export function useCharacters(projectId: number) { + return createQuery(() => ({ queryKey: ['characters', projectId], queryFn: async () => { - const result = await trpcClient.project.listCharacters.query({ projectId }); - return result as Character[]; + return await trpc.project.listCharacters.query({ projectId }); }, enabled: !!projectId, - }); + })); } -export function useCharacter(characterId: string) { - return createQuery({ - queryKey: ['character', characterId], +export function useCharacter(id: number) { + return createQuery(() => ({ + queryKey: ['character', id], queryFn: async () => { - const result = await trpcClient.project.getCharacter.query({ id: characterId }); - return result as Character; + return await trpc.project.getCharacter.query({ id }); }, - enabled: !!characterId, - }); + enabled: !!id, + })); } -export function useSearchCharacters(projectId: string, query?: string, role?: string, arcType?: string) { - return createQuery({ +export function useSearchCharacters(projectId: number, query?: string, role?: 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble', arcType?: 'positive' | 'negative' | 'flat' | 'complex') { + return createQuery(() => ({ queryKey: ['searchCharacters', projectId, query, role, arcType], queryFn: async () => { - const result = await trpcClient.project.searchCharacters.query({ + return await trpc.project.searchCharacters.query({ projectId, query, role, arcType, }); - return result as Character[]; }, enabled: !!projectId, - }); + })); } -export function useCharacterStats(characterId: string) { - return createQuery({ +export function useCharacterStats(characterId: number) { + return createQuery(() => ({ queryKey: ['characterStats', characterId], queryFn: async () => { - const result = await trpcClient.project.getCharacterStats.query({ characterId }); - return result as CharacterStats; + return await trpc.project.getCharacterStats.query({ characterId }); }, enabled: !!characterId, - }); + })); } -export function useProjectCharacterStats(projectId: string) { - return createQuery({ +export function useProjectCharacterStats(projectId: number) { + return createQuery(() => ({ queryKey: ['projectCharacterStats', projectId], queryFn: async () => { - const result = await trpcClient.project.getProjectCharacterStats.query({ projectId }); - return result as CharacterStats[]; + return await trpc.project.getProjectCharacterStats.query(undefined, { + context: { projectId }, + }); }, enabled: !!projectId, - }); + })); } export function useCreateCharacter() { const qc = useQueryClient(); - return createMutation({ + return createMutation(() => ({ mutationFn: async (input: { name: string; description?: string; @@ -117,21 +133,21 @@ export function useCreateCharacter() { conflict?: string; secret?: string; imageUrl?: string; - projectId: string; + projectId: number; }) => { - return trpcClient.project.createCharacter.mutate(input) as Promise; + return await trpc.project.createCharacter.mutate(input); }, onSuccess: (_, variables) => { qc.invalidateQueries({ queryKey: ['characters', variables.projectId] }); }, - }); + })); } export function useUpdateCharacter() { const qc = useQueryClient(); - return createMutation({ + return createMutation(() => ({ mutationFn: async (input: { - id: string; + id: number; name?: string; description?: string; bio?: string; @@ -146,121 +162,165 @@ export function useUpdateCharacter() { conflict?: string; secret?: string; imageUrl?: string; - projectId?: string; + projectId?: number; }) => { - return trpcClient.project.updateCharacter.mutate(input) as Promise; + return await trpc.project.updateCharacter.mutate(input); }, onSuccess: (_, variables) => { qc.invalidateQueries({ queryKey: ['character', variables.id] }); qc.invalidateQueries({ queryKey: ['characters'] }); }, - }); + })); } export function useDeleteCharacter() { const qc = useQueryClient(); - return createMutation({ - mutationFn: async (id: string) => { - return trpcClient.project.deleteCharacter.mutate({ id }) as Promise<{ success: boolean }>; + return createMutation(() => ({ + mutationFn: async (id: number) => { + return await trpc.project.deleteCharacter.mutate({ id }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['characters'] }); qc.invalidateQueries({ queryKey: ['characterRelationships'] }); }, - }); + })); } // Relationship hooks -export function useRelationships(projectId: string) { - return createQuery({ +export function useRelationships(projectId: number) { + return createQuery(() => ({ queryKey: ['characterRelationships', projectId], queryFn: async () => { - const result = await trpcClient.project.listRelationships.query({ projectId }); - return result as CharacterRelationship[]; + return await trpc.project.listRelationships.query(undefined, { + context: { projectId }, + }); }, enabled: !!projectId, - }); + })); } -export function useCharacterRelationships(characterId: string) { - return createQuery({ +export function useCharacterRelationships(characterId: number) { + return createQuery(() => ({ queryKey: ['characterRelationships', characterId], queryFn: async () => { - const result = await trpcClient.project.getRelationshipsForCharacter.query({ characterId }); - return result as CharacterRelationship[]; + return await trpc.project.getRelationshipsForCharacter.query({ characterId }); }, enabled: !!characterId, - }); + })); } export function useCreateRelationship() { const qc = useQueryClient(); - return createMutation({ + return createMutation(() => ({ mutationFn: async (input: { - characterIdA: string; - characterIdB: string; + characterIdA: number; + characterIdB: number; relationshipType: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other'; description?: string; strength?: number; isAntagonistic?: boolean; }) => { - return trpcClient.project.createRelationship.mutate(input) as Promise; + return await trpc.project.createRelationship.mutate(input); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['characterRelationships'] }); }, - }); + })); } export function useUpdateRelationship() { const qc = useQueryClient(); - return createMutation({ + return createMutation(() => ({ mutationFn: async (input: { - id: string; + id: number; relationshipType?: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other'; description?: string; strength?: number; isAntagonistic?: boolean; }) => { - return trpcClient.project.updateRelationship.mutate(input) as Promise; + return await trpc.project.updateRelationship.mutate(input); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['characterRelationships'] }); }, - }); + })); } export function useDeleteRelationship() { const qc = useQueryClient(); - return createMutation({ - mutationFn: async (id: string) => { - return trpcClient.project.deleteRelationship.mutate({ id }) as Promise<{ success: boolean }>; + return createMutation(() => ({ + mutationFn: async (id: number) => { + return await trpc.project.deleteRelationship.mutate({ id }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['characterRelationships'] }); }, - }); + })); } // Scene hooks -export function useScenes(projectId: string) { - return createQuery({ +export function useScenes(projectId: number) { + return createQuery(() => ({ queryKey: ['scenes', projectId], queryFn: async () => { - const result = await trpcClient.project.listScenes.query({ projectId }); - return result as Scene[]; + return await trpc.project.listScenes.query({ projectId }); }, enabled: !!projectId, - }); + })); } -export function useScene(sceneId: string) { - return createQuery({ - queryKey: ['scene', sceneId], +export function useScene(id: number) { + return createQuery(() => ({ + queryKey: ['scene', id], queryFn: async () => { - const result = await trpcClient.project.getScene.query({ id: sceneId }); - return result as Scene; + return await trpc.project.getScene.query({ id }); }, - enabled: !!sceneId, - }); + enabled: !!id, + })); +} + +export function useCreateScene() { + const qc = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (input: { + title: string; + content?: string; + projectId: number; + order: number; + }) => { + return await trpc.project.createScene.mutate(input); + }, + onSuccess: (_, variables) => { + qc.invalidateQueries({ queryKey: ['scenes', variables.projectId] }); + }, + })); +} + +export function useUpdateScene() { + const qc = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (input: { + id: number; + title?: string; + content?: string; + order?: number; + }) => { + return await trpc.project.updateScene.mutate(input); + }, + onSuccess: (_, variables) => { + qc.invalidateQueries({ queryKey: ['scenes'] }); + }, + })); +} + +export function useDeleteScene() { + const qc = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (id: number) => { + return await trpc.project.deleteScene.mutate({ id }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['scenes'] }); + }, + })); } diff --git a/src/lib/auth/clerk-provider.tsx b/src/lib/auth/clerk-provider.tsx index e5c385cea..ecb7028a9 100644 --- a/src/lib/auth/clerk-provider.tsx +++ b/src/lib/auth/clerk-provider.tsx @@ -1,8 +1,22 @@ import { createContext, createSignal, useContext, onMount, Accessor, JSX } from 'solid-js'; +import { Navigate } from '@solidjs/router'; import { getClerk, loadClerk, getClerkUrls } from './clerk-client'; import { User, UserRole, AuthState } from './types'; +import { setAuthToken } from '../api/trpc-client'; -type ClerkUser = any; +interface ClerkUser { + id: string; + primaryEmailAddress?: { + emailAddress: string; + } | null; + firstName?: string | null; + lastName?: string | null; + imageUrl: string; + publicMetadata?: { + role?: UserRole; + }; + update: (data: any) => Promise; +} interface ClerkSession { getId: () => string; @@ -10,8 +24,8 @@ interface ClerkSession { } interface ClerkClient { - user: () => ClerkUser | null; - session: () => ClerkSession | null; + user: () => any; + session: () => any; isLoading: boolean; signOut: () => Promise; } @@ -26,18 +40,20 @@ const AuthActionsContext = createContext<{ export { AuthContext, AuthActionsContext }; -function clerkUserToUser(clerkUser: ClerkUser): User { +function clerkUserToUser(clerkUser: any): User { const primaryEmail = clerkUser.primaryEmailAddress?.emailAddress || ''; const firstName = clerkUser.firstName || ''; const lastName = clerkUser.lastName || ''; const name = [firstName, lastName].filter(Boolean).join(' ') || primaryEmail.split('@')[0] || 'User'; + + const roleFromMetadata = (clerkUser.publicMetadata?.role as UserRole) || 'viewer'; return { id: clerkUser.id, email: primaryEmail, name, avatarUrl: clerkUser.imageUrl, - role: 'owner' as UserRole, + role: roleFromMetadata, }; } @@ -82,6 +98,14 @@ export function ClerkProvider(props: { children: JSX.Element }) { setClerkClient(wrappedClient); if (client.user) { + const session = client.session as any; + if (session) { + const token = await session.getToken(); + if (token) { + setAuthToken(token); + } + } + setState({ user: clerkUserToUser(client.user), isLoading: false, @@ -90,7 +114,26 @@ export function ClerkProvider(props: { children: JSX.Element }) { }); } else { setState((prev) => ({ ...prev, isLoading: false })); + setAuthToken(null); } + + client.addListener((event) => { + if ((event as any).type === 'user' && (event as any).user) { + setState({ + user: clerkUserToUser((event as any).user), + isLoading: false, + isAuthenticated: true, + error: null, + }); + } else if ((event as any).type === 'signOut') { + setState({ + user: null, + isLoading: false, + isAuthenticated: false, + error: null, + }); + } + }); } catch (err) { setState({ user: null, @@ -111,6 +154,7 @@ export function ClerkProvider(props: { children: JSX.Element }) { if (client) { await client.signOut(); } + setAuthToken(null); setState({ user: null, isLoading: false, @@ -120,6 +164,22 @@ export function ClerkProvider(props: { children: JSX.Element }) { }; const updateUser = async (data: Partial) => { + const client = getClerk(); + if (!client?.user) { + throw new Error('Not authenticated'); + } + + const clerkUser = client.user; + const updates: any = {}; + + if (data.name) { + const [firstName, ...lastNameParts] = data.name.split(' '); + updates.firstName = firstName; + updates.lastName = lastNameParts.join(' '); + } + + await clerkUser.update(updates); + setState((prev) => ({ ...prev, user: prev.user ? { ...prev.user, ...data } : null, @@ -151,11 +211,17 @@ export function useAuthActions() { return context; } -export function requireAuth() { +export function RequireAuth(props: { children: JSX.Element }) { const auth = useAuth(); const authState = auth(); - if (!authState.isAuthenticated) { - throw new Error('Authentication required'); + + if (authState.isLoading) { + return
Loading...
; } - return authState.user!; + + if (!authState.isAuthenticated) { + return ; + } + + return props.children; }