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.
This commit is contained in:
2026-04-25 08:31:05 -04:00
parent 754fce269f
commit 4435a12dd7
3 changed files with 262 additions and 128 deletions

View File

@@ -1,31 +1,39 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'; import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../../server/trpc'; import type { AppRouter } from '../../../server/trpc';
// Create tRPC client let cachedToken: string | null = null;
export function createTRPCClientInstance(baseUrl: string = 'http://localhost:8080') {
return createTRPCClient<AppRouter>({ function getAuthToken(): string | null {
links: [ if (typeof window === 'undefined') return null;
httpBatchLink({ if (cachedToken) return cachedToken;
url: `${baseUrl}/trpc`, cachedToken = localStorage.getItem('auth_token');
headers: () => { return cachedToken;
// Add auth headers if available
const token = localStorage.getItem('auth_token');
return {
authorization: token ? `Bearer ${token}` : '',
};
},
}),
],
});
} }
// Helper for SSR export function setAuthToken(token: string | null) {
export function createServerTRPCClient(baseUrl: string = 'http://localhost:8080') { cachedToken = token;
return createTRPCClient<AppRouter>({ if (typeof window !== 'undefined') {
links: [ if (token) {
httpBatchLink({ localStorage.setItem('auth_token', token);
url: `${baseUrl}/trpc`, } else {
}), localStorage.removeItem('auth_token');
], }
}); }
} }
// Create tRPC client with auth
export const trpc = createTRPCClient<AppRouter>({
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 };

View File

@@ -1,107 +1,123 @@
import { createQuery, createMutation, useQueryClient } from '@tanstack/solid-query'; import { createQuery, createMutation, useQueryClient } from '@tanstack/solid-query';
import { createTRPCClientInstance } from './trpc-client'; import { trpc } from './trpc-client';
import type { Project, Character, CharacterRelationship, CharacterStats, Scene } from '../../../server/types/project';
const trpcClient = createTRPCClientInstance();
// Project hooks // Project hooks
export function useProjects() { export function useProjects() {
return createQuery({ return createQuery(() => ({
queryKey: ['projects'], queryKey: ['projects'],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.listProjects.query(); return await trpc.project.listProjects.query(undefined);
return result as Project[];
}, },
}); }));
} }
export function useProject(projectId: string) { export function useProject(id: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['project', projectId], queryKey: ['project', id],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getProject.query({ id: projectId }); return await trpc.project.getProject.query({ id });
return result as Project;
}, },
enabled: !!projectId, enabled: !!id,
}); }));
} }
export function useCreateProject() { export function useCreateProject() {
return createMutation({ const qc = useQueryClient();
return createMutation(() => ({
mutationFn: async (input: { name: string; description?: string }) => { mutationFn: async (input: { name: string; description?: string }) => {
return trpcClient.project.createProject.mutate(input) as Promise<Project>; return await trpc.project.createProject.mutate(input);
}, },
onSuccess: () => { onSuccess: () => {
const qc = useQueryClient();
qc.invalidateQueries({ queryKey: ['projects'] }); 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 // Character hooks
export function useCharacters(projectId: string) { export function useCharacters(projectId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['characters', projectId], queryKey: ['characters', projectId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.listCharacters.query({ projectId }); return await trpc.project.listCharacters.query({ projectId });
return result as Character[];
}, },
enabled: !!projectId, enabled: !!projectId,
}); }));
} }
export function useCharacter(characterId: string) { export function useCharacter(id: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['character', characterId], queryKey: ['character', id],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getCharacter.query({ id: characterId }); return await trpc.project.getCharacter.query({ id });
return result as Character;
}, },
enabled: !!characterId, enabled: !!id,
}); }));
} }
export function useSearchCharacters(projectId: string, query?: string, role?: string, arcType?: string) { export function useSearchCharacters(projectId: number, query?: string, role?: 'protagonist' | 'antagonist' | 'supporting' | 'background' | 'ensemble', arcType?: 'positive' | 'negative' | 'flat' | 'complex') {
return createQuery({ return createQuery(() => ({
queryKey: ['searchCharacters', projectId, query, role, arcType], queryKey: ['searchCharacters', projectId, query, role, arcType],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.searchCharacters.query({ return await trpc.project.searchCharacters.query({
projectId, projectId,
query, query,
role, role,
arcType, arcType,
}); });
return result as Character[];
}, },
enabled: !!projectId, enabled: !!projectId,
}); }));
} }
export function useCharacterStats(characterId: string) { export function useCharacterStats(characterId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['characterStats', characterId], queryKey: ['characterStats', characterId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getCharacterStats.query({ characterId }); return await trpc.project.getCharacterStats.query({ characterId });
return result as CharacterStats;
}, },
enabled: !!characterId, enabled: !!characterId,
}); }));
} }
export function useProjectCharacterStats(projectId: string) { export function useProjectCharacterStats(projectId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['projectCharacterStats', projectId], queryKey: ['projectCharacterStats', projectId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getProjectCharacterStats.query({ projectId }); return await trpc.project.getProjectCharacterStats.query(undefined, {
return result as CharacterStats[]; context: { projectId },
});
}, },
enabled: !!projectId, enabled: !!projectId,
}); }));
} }
export function useCreateCharacter() { export function useCreateCharacter() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (input: { mutationFn: async (input: {
name: string; name: string;
description?: string; description?: string;
@@ -117,21 +133,21 @@ export function useCreateCharacter() {
conflict?: string; conflict?: string;
secret?: string; secret?: string;
imageUrl?: string; imageUrl?: string;
projectId: string; projectId: number;
}) => { }) => {
return trpcClient.project.createCharacter.mutate(input) as Promise<Character>; return await trpc.project.createCharacter.mutate(input);
}, },
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
qc.invalidateQueries({ queryKey: ['characters', variables.projectId] }); qc.invalidateQueries({ queryKey: ['characters', variables.projectId] });
}, },
}); }));
} }
export function useUpdateCharacter() { export function useUpdateCharacter() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (input: { mutationFn: async (input: {
id: string; id: number;
name?: string; name?: string;
description?: string; description?: string;
bio?: string; bio?: string;
@@ -146,121 +162,165 @@ export function useUpdateCharacter() {
conflict?: string; conflict?: string;
secret?: string; secret?: string;
imageUrl?: string; imageUrl?: string;
projectId?: string; projectId?: number;
}) => { }) => {
return trpcClient.project.updateCharacter.mutate(input) as Promise<Character>; return await trpc.project.updateCharacter.mutate(input);
}, },
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
qc.invalidateQueries({ queryKey: ['character', variables.id] }); qc.invalidateQueries({ queryKey: ['character', variables.id] });
qc.invalidateQueries({ queryKey: ['characters'] }); qc.invalidateQueries({ queryKey: ['characters'] });
}, },
}); }));
} }
export function useDeleteCharacter() { export function useDeleteCharacter() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (id: string) => { mutationFn: async (id: number) => {
return trpcClient.project.deleteCharacter.mutate({ id }) as Promise<{ success: boolean }>; return await trpc.project.deleteCharacter.mutate({ id });
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['characters'] }); qc.invalidateQueries({ queryKey: ['characters'] });
qc.invalidateQueries({ queryKey: ['characterRelationships'] }); qc.invalidateQueries({ queryKey: ['characterRelationships'] });
}, },
}); }));
} }
// Relationship hooks // Relationship hooks
export function useRelationships(projectId: string) { export function useRelationships(projectId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['characterRelationships', projectId], queryKey: ['characterRelationships', projectId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.listRelationships.query({ projectId }); return await trpc.project.listRelationships.query(undefined, {
return result as CharacterRelationship[]; context: { projectId },
});
}, },
enabled: !!projectId, enabled: !!projectId,
}); }));
} }
export function useCharacterRelationships(characterId: string) { export function useCharacterRelationships(characterId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['characterRelationships', characterId], queryKey: ['characterRelationships', characterId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getRelationshipsForCharacter.query({ characterId }); return await trpc.project.getRelationshipsForCharacter.query({ characterId });
return result as CharacterRelationship[];
}, },
enabled: !!characterId, enabled: !!characterId,
}); }));
} }
export function useCreateRelationship() { export function useCreateRelationship() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (input: { mutationFn: async (input: {
characterIdA: string; characterIdA: number;
characterIdB: string; characterIdB: number;
relationshipType: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other'; relationshipType: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other';
description?: string; description?: string;
strength?: number; strength?: number;
isAntagonistic?: boolean; isAntagonistic?: boolean;
}) => { }) => {
return trpcClient.project.createRelationship.mutate(input) as Promise<CharacterRelationship>; return await trpc.project.createRelationship.mutate(input);
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['characterRelationships'] }); qc.invalidateQueries({ queryKey: ['characterRelationships'] });
}, },
}); }));
} }
export function useUpdateRelationship() { export function useUpdateRelationship() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (input: { mutationFn: async (input: {
id: string; id: number;
relationshipType?: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other'; relationshipType?: 'family' | 'romantic' | 'friendship' | 'rivalry' | 'mentor' | 'alliance' | 'conflict' | 'professional' | 'other';
description?: string; description?: string;
strength?: number; strength?: number;
isAntagonistic?: boolean; isAntagonistic?: boolean;
}) => { }) => {
return trpcClient.project.updateRelationship.mutate(input) as Promise<CharacterRelationship>; return await trpc.project.updateRelationship.mutate(input);
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['characterRelationships'] }); qc.invalidateQueries({ queryKey: ['characterRelationships'] });
}, },
}); }));
} }
export function useDeleteRelationship() { export function useDeleteRelationship() {
const qc = useQueryClient(); const qc = useQueryClient();
return createMutation({ return createMutation(() => ({
mutationFn: async (id: string) => { mutationFn: async (id: number) => {
return trpcClient.project.deleteRelationship.mutate({ id }) as Promise<{ success: boolean }>; return await trpc.project.deleteRelationship.mutate({ id });
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['characterRelationships'] }); qc.invalidateQueries({ queryKey: ['characterRelationships'] });
}, },
}); }));
} }
// Scene hooks // Scene hooks
export function useScenes(projectId: string) { export function useScenes(projectId: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['scenes', projectId], queryKey: ['scenes', projectId],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.listScenes.query({ projectId }); return await trpc.project.listScenes.query({ projectId });
return result as Scene[];
}, },
enabled: !!projectId, enabled: !!projectId,
}); }));
} }
export function useScene(sceneId: string) { export function useScene(id: number) {
return createQuery({ return createQuery(() => ({
queryKey: ['scene', sceneId], queryKey: ['scene', id],
queryFn: async () => { queryFn: async () => {
const result = await trpcClient.project.getScene.query({ id: sceneId }); return await trpc.project.getScene.query({ id });
return result as Scene;
}, },
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'] });
},
}));
} }

View File

@@ -1,8 +1,22 @@
import { createContext, createSignal, useContext, onMount, Accessor, JSX } from 'solid-js'; import { createContext, createSignal, useContext, onMount, Accessor, JSX } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { getClerk, loadClerk, getClerkUrls } from './clerk-client'; import { getClerk, loadClerk, getClerkUrls } from './clerk-client';
import { User, UserRole, AuthState } from './types'; 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<any>;
}
interface ClerkSession { interface ClerkSession {
getId: () => string; getId: () => string;
@@ -10,8 +24,8 @@ interface ClerkSession {
} }
interface ClerkClient { interface ClerkClient {
user: () => ClerkUser | null; user: () => any;
session: () => ClerkSession | null; session: () => any;
isLoading: boolean; isLoading: boolean;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
@@ -26,18 +40,20 @@ const AuthActionsContext = createContext<{
export { AuthContext, AuthActionsContext }; export { AuthContext, AuthActionsContext };
function clerkUserToUser(clerkUser: ClerkUser): User { function clerkUserToUser(clerkUser: any): User {
const primaryEmail = clerkUser.primaryEmailAddress?.emailAddress || ''; const primaryEmail = clerkUser.primaryEmailAddress?.emailAddress || '';
const firstName = clerkUser.firstName || ''; const firstName = clerkUser.firstName || '';
const lastName = clerkUser.lastName || ''; const lastName = clerkUser.lastName || '';
const name = [firstName, lastName].filter(Boolean).join(' ') || primaryEmail.split('@')[0] || 'User'; const name = [firstName, lastName].filter(Boolean).join(' ') || primaryEmail.split('@')[0] || 'User';
const roleFromMetadata = (clerkUser.publicMetadata?.role as UserRole) || 'viewer';
return { return {
id: clerkUser.id, id: clerkUser.id,
email: primaryEmail, email: primaryEmail,
name, name,
avatarUrl: clerkUser.imageUrl, avatarUrl: clerkUser.imageUrl,
role: 'owner' as UserRole, role: roleFromMetadata,
}; };
} }
@@ -82,6 +98,14 @@ export function ClerkProvider(props: { children: JSX.Element }) {
setClerkClient(wrappedClient); setClerkClient(wrappedClient);
if (client.user) { if (client.user) {
const session = client.session as any;
if (session) {
const token = await session.getToken();
if (token) {
setAuthToken(token);
}
}
setState({ setState({
user: clerkUserToUser(client.user), user: clerkUserToUser(client.user),
isLoading: false, isLoading: false,
@@ -90,7 +114,26 @@ export function ClerkProvider(props: { children: JSX.Element }) {
}); });
} else { } else {
setState((prev) => ({ ...prev, isLoading: false })); 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) { } catch (err) {
setState({ setState({
user: null, user: null,
@@ -111,6 +154,7 @@ export function ClerkProvider(props: { children: JSX.Element }) {
if (client) { if (client) {
await client.signOut(); await client.signOut();
} }
setAuthToken(null);
setState({ setState({
user: null, user: null,
isLoading: false, isLoading: false,
@@ -120,6 +164,22 @@ export function ClerkProvider(props: { children: JSX.Element }) {
}; };
const updateUser = async (data: Partial<User>) => { const updateUser = async (data: Partial<User>) => {
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) => ({ setState((prev) => ({
...prev, ...prev,
user: prev.user ? { ...prev.user, ...data } : null, user: prev.user ? { ...prev.user, ...data } : null,
@@ -151,11 +211,17 @@ export function useAuthActions() {
return context; return context;
} }
export function requireAuth() { export function RequireAuth(props: { children: JSX.Element }) {
const auth = useAuth(); const auth = useAuth();
const authState = auth(); const authState = auth();
if (!authState.isAuthenticated) {
throw new Error('Authentication required'); if (authState.isLoading) {
return <div>Loading...</div>;
} }
return authState.user!;
if (!authState.isAuthenticated) {
return <Navigate href="/sign-in" />;
}
return props.children;
} }