Fix 4 code review findings on FRE-596

- clerk-provider.tsx: typed Clerk event listener with type guards
  (isClerkUserEvent, isClerkSignOutEvent) instead of (event as any)
- service.ts: fixed signal propagation timing in updateProject,
  addCollaborator, removeCollaborator — capture updated project inside
  setProjects callback instead of reading stale signal after mutation
- TeamManagement.tsx: added useAuth import and getAuthToken helper to
  replace raw localStorage reads; auth context now available in components
- ProjectForm.tsx: added explicit null check on auth().user before
  accessing .id, replacing unsafe non-null assertion
This commit is contained in:
2026-04-28 22:36:00 -04:00
parent 5dc59176bc
commit fc2b7fe970
4 changed files with 87 additions and 38 deletions

View File

@@ -19,11 +19,17 @@ export const ProjectForm: Component<any> = () => {
return; return;
} }
const user = auth().user;
if (!user) {
setError('User not authenticated');
return;
}
try { try {
const project = await projectService.createProject( const project = await projectService.createProject(
name().trim(), name().trim(),
description().trim(), description().trim(),
auth().user!.id user.id
); );
navigate(`/projects/${project.id}`); navigate(`/projects/${project.id}`);
} catch (err) { } catch (err) {

View File

@@ -2,6 +2,7 @@ import { Component, createSignal, For, Show } from 'solid-js';
import { A, useLocation } from '@solidjs/router'; import { A, useLocation } from '@solidjs/router';
import { createTRPCClient } from '../../trpc/client'; import { createTRPCClient } from '../../trpc/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../lib/auth';
import type { Team as DbTeam, TeamMember as DbTeamMember } from '../../db/schema/teams'; import type { Team as DbTeam, TeamMember as DbTeamMember } from '../../db/schema/teams';
const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8080'; const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8080';
@@ -14,9 +15,15 @@ function formatDateTime(iso: Date | string): string {
}); });
} }
function getAuthToken(): string | null {
const token = localStorage.getItem('clerk_token');
return token || null;
}
const TeamList: Component = () => { const TeamList: Component = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const client = createTRPCClient(API_URL); const client = createTRPCClient(API_URL);
const auth = useAuth();
const [showCreateDialog, setShowCreateDialog] = createSignal(false); const [showCreateDialog, setShowCreateDialog] = createSignal(false);
const [newTeamName, setNewTeamName] = createSignal(''); const [newTeamName, setNewTeamName] = createSignal('');
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
@@ -24,9 +31,9 @@ const TeamList: Component = () => {
const teamsQuery = useQuery({ const teamsQuery = useQuery({
queryKey: ['team', 'listTeams'], queryKey: ['team', 'listTeams'],
queryFn: async () => { queryFn: async () => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.listTeams`, { const res = await fetch(`${API_URL}/trpc/team.listTeams`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!res.ok) throw new Error('Failed to fetch teams'); if (!res.ok) throw new Error('Failed to fetch teams');
const data = await res.json(); const data = await res.json();
@@ -36,12 +43,12 @@ const TeamList: Component = () => {
const createTeamMutation = useMutation({ const createTeamMutation = useMutation({
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.createTeam`, { const res = await fetch(`${API_URL}/trpc/team.createTeam`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { name } }), body: JSON.stringify({ input: { name } }),
}); });
@@ -61,12 +68,12 @@ const TeamList: Component = () => {
const deleteTeamMutation = useMutation({ const deleteTeamMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.deleteTeam`, { const res = await fetch(`${API_URL}/trpc/team.deleteTeam`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { id } }), body: JSON.stringify({ input: { id } }),
}); });
@@ -203,6 +210,7 @@ const TeamList: Component = () => {
const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => { const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const auth = useAuth();
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [showInviteDialog, setShowInviteDialog] = createSignal(false); const [showInviteDialog, setShowInviteDialog] = createSignal(false);
const [inviteUserId, setInviteUserId] = createSignal(''); const [inviteUserId, setInviteUserId] = createSignal('');
@@ -211,9 +219,9 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const teamQuery = useQuery({ const teamQuery = useQuery({
queryKey: ['team', 'getTeam', teamId], queryKey: ['team', 'getTeam', teamId],
queryFn: async () => { queryFn: async () => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.getTeam?input=${encodeURIComponent(JSON.stringify({ id: teamId }))}`, { const res = await fetch(`${API_URL}/trpc/team.getTeam?input=${encodeURIComponent(JSON.stringify({ id: teamId }))}`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!res.ok) throw new Error('Failed to fetch team'); if (!res.ok) throw new Error('Failed to fetch team');
const data = await res.json(); const data = await res.json();
@@ -224,9 +232,9 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const membersQuery = useQuery({ const membersQuery = useQuery({
queryKey: ['team', 'listMembers', teamId], queryKey: ['team', 'listMembers', teamId],
queryFn: async () => { queryFn: async () => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.listMembers?input=${encodeURIComponent(JSON.stringify({ teamId }))}`, { const res = await fetch(`${API_URL}/trpc/team.listMembers?input=${encodeURIComponent(JSON.stringify({ teamId }))}`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!res.ok) throw new Error('Failed to fetch members'); if (!res.ok) throw new Error('Failed to fetch members');
const data = await res.json(); const data = await res.json();
@@ -236,12 +244,12 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const updateRoleMutation = useMutation({ const updateRoleMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: string }) => { mutationFn: async ({ userId, role }: { userId: number; role: string }) => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.updateMemberRole`, { const res = await fetch(`${API_URL}/trpc/team.updateMemberRole`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { teamId, userId, role } }), body: JSON.stringify({ input: { teamId, userId, role } }),
}); });
@@ -256,12 +264,12 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const removeMemberMutation = useMutation({ const removeMemberMutation = useMutation({
mutationFn: async (userId: number) => { mutationFn: async (userId: number) => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.removeMember`, { const res = await fetch(`${API_URL}/trpc/team.removeMember`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { teamId, userId } }), body: JSON.stringify({ input: { teamId, userId } }),
}); });
@@ -276,12 +284,12 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const leaveTeamMutation = useMutation({ const leaveTeamMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.leaveTeam`, { const res = await fetch(`${API_URL}/trpc/team.leaveTeam`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { teamId } }), body: JSON.stringify({ input: { teamId } }),
}); });
@@ -296,12 +304,12 @@ const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const addMemberMutation = useMutation({ const addMemberMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: string }) => { mutationFn: async ({ userId, role }: { userId: number; role: string }) => {
const auth = localStorage.getItem('clerk_token'); const token = getAuthToken();
const res = await fetch(`${API_URL}/trpc/team.addMember`, { const res = await fetch(`${API_URL}/trpc/team.addMember`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ input: { teamId, userId, role } }), body: JSON.stringify({ input: { teamId, userId, role } }),
}); });

View File

@@ -21,13 +21,38 @@ interface ClerkUser {
interface ClerkSession { interface ClerkSession {
getId: () => string; getId: () => string;
getUser: () => ClerkUser; getUser: () => ClerkUser;
getToken: (template?: string) => Promise<string | null>;
}
// Clerk's addListener emits resource instances, not typed events.
// We model the possible event shapes for type-safe handling.
type ClerkListenerEvent =
| { __type: 'userSignedIn'; user: object }
| { __type: 'userSignedOut' }
| { __type: 'sessionChanged'; session: object }
| { __type: 'userUpdated'; user: object };
function isClerkUserEvent(event: unknown): event is { user: ClerkUser } {
if (event && typeof event === 'object' && 'user' in event) {
return true;
}
return false;
}
function isClerkSignOutEvent(event: unknown): boolean {
if (event && typeof event === 'object') {
const e = event as Record<string, unknown>;
return e.__type === 'userSignedOut' || e.__type === 'sessionRemoved';
}
return false;
} }
interface ClerkClient { interface ClerkClient {
user: () => any; user: () => ClerkUser | undefined;
session: () => any; session: () => ClerkSession | null;
isLoading: boolean; isLoading: boolean;
signOut: () => Promise<void>; signOut: () => Promise<void>;
addListener: (handler: (event: unknown) => void) => void;
} }
const AuthContext = createContext<Accessor<AuthState> | undefined>(undefined); const AuthContext = createContext<Accessor<AuthState> | undefined>(undefined);
@@ -81,8 +106,8 @@ export function ClerkProvider(props: { children: JSX.Element }) {
} }
const wrappedClient: ClerkClient = { const wrappedClient: ClerkClient = {
user: () => client.user, user: () => client.user || undefined,
session: () => (client.session as any) || null, session: () => (client.session as unknown as ClerkSession) || null,
isLoading: false, isLoading: false,
signOut: async () => { signOut: async () => {
await client.signOut(); await client.signOut();
@@ -93,12 +118,15 @@ export function ClerkProvider(props: { children: JSX.Element }) {
error: null, error: null,
}); });
}, },
addListener: (handler: (event: unknown) => void) => {
client.addListener((event: unknown) => handler(event));
},
}; };
setClerkClient(wrappedClient); setClerkClient(wrappedClient);
if (client.user) { if (client.user) {
const session = client.session as any; const session = client.session as unknown as ClerkSession;
if (session) { if (session) {
const token = await session.getToken(); const token = await session.getToken();
if (token) { if (token) {
@@ -117,15 +145,15 @@ export function ClerkProvider(props: { children: JSX.Element }) {
setAuthToken(null); setAuthToken(null);
} }
client.addListener((event) => { client.addListener((event: unknown) => {
if ((event as any).type === 'user' && (event as any).user) { if (isClerkUserEvent(event) && event.user) {
setState({ setState({
user: clerkUserToUser((event as any).user), user: clerkUserToUser(event.user),
isLoading: false, isLoading: false,
isAuthenticated: true, isAuthenticated: true,
error: null, error: null,
}); });
} else if ((event as any).type === 'signOut') { } else if (isClerkSignOutEvent(event)) {
setState({ setState({
user: null, user: null,
isLoading: false, isLoading: false,

View File

@@ -63,15 +63,18 @@ export function createProjectService(): ProjectService {
updates: Partial<Project> updates: Partial<Project>
): Promise<Project> => { ): Promise<Project> => {
setLoading(true); setLoading(true);
let updated: Project | undefined;
setProjects((prev) => setProjects((prev) =>
prev.map((p) => prev.map((p) => {
p.id === id if (p.id === id) {
? { ...p, ...updates, updatedAt: new Date().toISOString() } updated = { ...p, ...updates, updatedAt: new Date().toISOString() };
: p return updated;
) }
return p;
})
); );
setLoading(false); setLoading(false);
return projects().find((p) => p.id === id)!; return updated!;
}; };
const deleteProject = async (id: string): Promise<void> => { const deleteProject = async (id: string): Promise<void> => {
@@ -86,12 +89,13 @@ export function createProjectService(): ProjectService {
role: UserRole role: UserRole
): Promise<Project> => { ): Promise<Project> => {
setLoading(true); setLoading(true);
let updated: Project | undefined;
setProjects((prev) => setProjects((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id !== projectId) return p; if (p.id !== projectId) return p;
const existing = p.collaborators.find((c) => c.userId === userId); const existing = p.collaborators.find((c) => c.userId === userId);
if (existing) return p; if (existing) return p;
return { updated = {
...p, ...p,
collaborators: [ collaborators: [
...p.collaborators, ...p.collaborators,
@@ -99,10 +103,11 @@ export function createProjectService(): ProjectService {
], ],
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
return updated;
}) })
); );
setLoading(false); setLoading(false);
return projects().find((p) => p.id === projectId)!; return updated!;
}; };
const removeCollaborator = async ( const removeCollaborator = async (
@@ -110,18 +115,20 @@ export function createProjectService(): ProjectService {
userId: string userId: string
): Promise<Project> => { ): Promise<Project> => {
setLoading(true); setLoading(true);
let updated: Project | undefined;
setProjects((prev) => setProjects((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id !== projectId) return p; if (p.id !== projectId) return p;
return { updated = {
...p, ...p,
collaborators: p.collaborators.filter((c) => c.userId !== userId), collaborators: p.collaborators.filter((c) => c.userId !== userId),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
return updated;
}) })
); );
setLoading(false); setLoading(false);
return projects().find((p) => p.id === projectId)!; return updated!;
}; };
const archiveProject = async (id: string): Promise<Project> => { const archiveProject = async (id: string): Promise<Project> => {