fix(frontend): URL-Normalisierung gegen Doppel-Präfix — Workspace-UI & Permission-Refresh in Produktion repariert
Problem: Axios baseURL '/api/v1' + 16 apiX-Calls mit vollem Präfix
('/api/v1/workspaces/...') ergaben '/api/v1/api/v1/...' → 404 live
(bewiesen per curl + Node). Betroffen: komplette Workspace-UI
(Switcher, Manager, Phase-N-Scope-Editor) + Permission-Refresh.
Fix (Defense-in-Depth):
- normalizeApiUrl() in client.ts: alle apiX-Wrapper strippen redundanten
'/api/v1'-Präfix — deckt auch DYNAMISCHE Backend-Contract-Endpoints
(N2-Scope-Editor-Wertquellen) ab, die Call-Sites nicht umschreiben können
- workspaces.ts + useUserPermissions.ts auf relative Pfade gesäubert (16 Calls)
Tests: clientUrl 8/8 neu (Normalisierung + Wrapper-Beweis), tsc clean,
Build OK, Workspace-Regression 3 Dateien grün
This commit is contained in:
@@ -152,28 +152,48 @@ function extractValidationErrors(data: any): Record<string, string[]> | undefine
|
||||
return Object.keys(errors).length > 0 ? errors : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an API URL against the axios baseURL ('/api/v1').
|
||||
*
|
||||
* Runtime-bug fix (Phase N hotfix): calls that pass a FULL prefix
|
||||
* ('/api/v1/workspaces') resolved to '/api/v1/api/v1/...' → 404 in
|
||||
* production (proven via curl + node). This also covers DYNAMIC endpoints
|
||||
* from backend contracts (e.g. scope value sources like
|
||||
* '/api/v1/contact-folders') that call sites cannot rewrite.
|
||||
*/
|
||||
export function normalizeApiUrl(url: string): string {
|
||||
if (url.startsWith('/api/v1/')) {
|
||||
return url.slice('/api/v1'.length);
|
||||
}
|
||||
// exact '/api/v1' without trailing path → root
|
||||
if (url === '/api/v1') {
|
||||
return '/';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.get<T>(url, config);
|
||||
const response = await apiClient.get<T>(normalizeApiUrl(url), config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.post<T>(url, data, config);
|
||||
const response = await apiClient.post<T>(normalizeApiUrl(url), data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.put<T>(url, data, config);
|
||||
const response = await apiClient.put<T>(normalizeApiUrl(url), data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.patch<T>(url, data, config);
|
||||
const response = await apiClient.patch<T>(normalizeApiUrl(url), data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.delete<T>(url, config);
|
||||
const response = await apiClient.delete<T>(normalizeApiUrl(url), config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,21 +41,21 @@ export interface WorkspaceContext {
|
||||
export function useWorkspaces() {
|
||||
return useQuery<{ items: Workspace[]; total: number }>({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: () => apiGet('/api/v1/workspaces'),
|
||||
queryFn: () => apiGet('/workspaces'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyWorkspaces() {
|
||||
return useQuery<{ items: MyWorkspace[]; total: number }>({
|
||||
queryKey: ['my-workspaces'],
|
||||
queryFn: () => apiGet('/api/v1/workspaces/my'),
|
||||
queryFn: () => apiGet('/workspaces/my'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspaceContext(workspaceId: string | null) {
|
||||
return useQuery<WorkspaceContext>({
|
||||
queryKey: ['workspace-context', workspaceId],
|
||||
queryFn: () => apiGet('/api/v1/workspaces/context', {
|
||||
queryFn: () => apiGet('/workspaces/context', {
|
||||
headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {},
|
||||
}),
|
||||
enabled: !!workspaceId,
|
||||
@@ -66,7 +66,7 @@ export function useCreateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) =>
|
||||
apiPost('/api/v1/workspaces', data),
|
||||
apiPost('/workspaces', data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -78,7 +78,7 @@ export function useUpdateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) =>
|
||||
apiPut(`/api/v1/workspaces/${id}`, data),
|
||||
apiPut(`/workspaces/${id}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -89,7 +89,7 @@ export function useUpdateWorkspace() {
|
||||
export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/v1/workspaces/${id}`),
|
||||
mutationFn: (id: string) => apiDelete(`/workspaces/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -101,7 +101,7 @@ export function useSetWorkspaceModules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) =>
|
||||
apiPost(`/api/v1/workspaces/${workspaceId}/modules`, { modules }),
|
||||
apiPost(`/workspaces/${workspaceId}/modules`, { modules }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -113,7 +113,7 @@ export function useAssignWorkspaceUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) =>
|
||||
apiPost(`/api/v1/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }),
|
||||
apiPost(`/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -125,7 +125,7 @@ export function useRemoveWorkspaceUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) =>
|
||||
apiDelete(`/api/v1/workspaces/${workspaceId}/users/${userId}`),
|
||||
apiDelete(`/workspaces/${workspaceId}/users/${userId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
@@ -149,7 +149,7 @@ export interface WorkspaceWidget {
|
||||
export function useWorkspaceWidgets(workspaceId: string | null) {
|
||||
return useQuery<{ items: WorkspaceWidget[]; total: number }>({
|
||||
queryKey: ['workspace-widgets', workspaceId],
|
||||
queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`),
|
||||
queryFn: () => apiGet(`/workspaces/${workspaceId}/widgets`),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function useCreateWorkspaceWidget() {
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data),
|
||||
}) => apiPost(`/workspaces/${workspaceId}/widgets`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
@@ -184,7 +184,7 @@ export function useUpdateWorkspaceWidget() {
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
}) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data),
|
||||
}) => apiPut(`/workspaces/${workspaceId}/widgets/${widgetId}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
@@ -196,7 +196,7 @@ export function useDeleteWorkspaceWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) =>
|
||||
apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`),
|
||||
apiDelete(`/workspaces/${workspaceId}/widgets/${widgetId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
|
||||
qc.invalidateQueries({ queryKey: ['workspace-context'] });
|
||||
@@ -210,7 +210,7 @@ export function useSetDefaultWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) =>
|
||||
apiPost(`/api/v1/workspaces/${workspaceId}/set-default`),
|
||||
apiPost(`/workspaces/${workspaceId}/set-default`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['my-workspaces'] });
|
||||
},
|
||||
@@ -247,7 +247,7 @@ export interface WorkspaceScopeDefinitions {
|
||||
export function useWorkspaceScopeDefinitions() {
|
||||
return useQuery<WorkspaceScopeDefinitions>({
|
||||
queryKey: ['workspace-scope-definitions'],
|
||||
queryFn: () => apiGet('/api/v1/workspaces/scope-definitions'),
|
||||
queryFn: () => apiGet('/workspaces/scope-definitions'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user