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 type { AppRouter } from '../../../server/trpc';
// Create tRPC client
export function createTRPCClientInstance(baseUrl: string = 'http://localhost:8080') {
return createTRPCClient<AppRouter>({
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;
}
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<AppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}/trpc`,
url: `${(import.meta as any).env?.VITE_API_URL || 'http://localhost:8080'}/trpc`,
headers: () => {
// Add auth headers if available
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
return {
authorization: token ? `Bearer ${token}` : '',
};
},
}),
],
});
}
});
// Helper for SSR
export function createServerTRPCClient(baseUrl: string = 'http://localhost:8080') {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}/trpc`,
}),
],
});
}
export type { AppRouter };

View File

@@ -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<Project>;
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<Character>;
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<Character>;
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<CharacterRelationship>;
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<CharacterRelationship>;
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'] });
},
}));
}

View File

@@ -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<any>;
}
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<void>;
}
@@ -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<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) => ({
...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 <div>Loading...</div>;
}
return authState.user!;
if (!authState.isAuthenticated) {
return <Navigate href="/sign-in" />;
}
return props.children;
}