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:
@@ -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 };
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user