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