Compare commits

...

6 Commits

Author SHA1 Message Date
Yaojia Wang
d9228057bb fix(frontend): Fix auth hydration and auto-redirect to login
Fix issue where unauthenticated users were not automatically
redirected to the login page.

Root Cause:
- authStore.ts: isLoading was not set to false after hydration
- AuthGuard.tsx: Used isLoading instead of isHydrated for checks

Changes:
- Set isLoading = false in authStore onRehydrateStorage callback
- Changed AuthGuard to use isHydrated instead of isLoading
- Added console log for redirect debugging

This ensures:
- Hydration completes with correct loading state
- Unauthenticated users are immediately redirected to /login
- More explicit state management with isHydrated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 21:12:15 +01:00
Yaojia Wang
605e151f33 fix(frontend): Add localStorage migration logic for userId to id field
Automatically migrate old localStorage format where user object has userId field
to new format with id field. This prevents users from needing to re-login after
the backend API response changed from userId to id.

Changes:
- Added migrate function to Zustand persist config to handle userId → id migration
- Added post-hydration safety check in onRehydrateStorage callback
- Added detailed console.log for debugging migration process

Fixes: User data mismatch after API response format change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 21:07:20 +01:00
Yaojia Wang
6f36bbc3d5 fix(frontend): Fix user field name mapping from backend to frontend
Resolved authentication issue where user.id was undefined, causing Epic creation to fail.

Root Cause:
- Backend /api/auth/login returns UserDto with PascalCase fields (Id, Email, etc.)
- Backend /api/auth/me returns JWT claims with camelCase (userId, email, etc.)
- Frontend User type expects camelCase fields (id, email, etc.)
- Previous code directly assigned backend fields without mapping

Changes:
- useLogin: Added field mapping to handle both PascalCase and camelCase
- useCurrentUser: Map userId -> id and tenantSlug -> tenantName
- Both functions now correctly populate user.id for localStorage persistence

Impact:
- Epic creation now works (user.id is correctly set)
- Auth state persists correctly across page reloads
- Consistent user object structure throughout frontend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 21:03:39 +01:00
Yaojia Wang
be69325797 fix(frontend): Fix Zustand authStore hydration timing issue
Fix race condition where Epic form checked user authentication before
Zustand persist middleware completed hydration from localStorage.

Root cause:
- authStore uses persist middleware to restore from localStorage
- Hydration is asynchronous
- Epic form checked user state before hydration completed
- Result: "User not authenticated" error on page refresh

Changes:
- Add isHydrated state to authStore interface
- Add onRehydrateStorage callback to track hydration completion
- Update epic-form to check isHydrated before checking user
- Disable submit button until hydration completes
- Show "Loading..." button text during hydration
- Improve error messages for better UX
- Add console logging to track hydration process

Testing:
- Page refresh should now wait for hydration
- Epic form correctly identifies logged-in users
- Submit button disabled until auth state ready
- Clear user feedback during loading state

Fixes: Epic creation "User not authenticated" error on refresh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 20:56:13 +01:00
Yaojia Wang
b404fbb006 fix(frontend): Add comprehensive debug logging for Epic creation
Add detailed console logging to diagnose Epic creation issue where
no request is being sent to backend.

Changes:
- Add form submission event logging in epic-form.tsx
- Add API request/response logging in epicsApi.create
- Add HTTP client interceptor logging for all requests/responses
- Log authentication status, payload, and error details
- Log form validation state and errors

This will help identify:
- Whether form submit event fires
- Whether validation passes
- Whether API call is triggered
- Whether authentication token exists
- What errors occur (if any)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 20:50:34 +01:00
Yaojia Wang
048e7e7e6d fix(frontend): Fix SignalR 401 authentication error with dynamic token factory
Fixed SignalR connection failing with 401 Unauthorized error by using
a dynamic token factory instead of a static token value.

Changes:
- Updated accessTokenFactory to call tokenManager.getAccessToken() dynamically
- This ensures SignalR always uses the latest valid JWT token
- Fixes token expiration and refresh issues during connection lifecycle

Issue: SignalR negotiation was failing because it used a stale token
captured at connection creation time, instead of fetching the current
token from localStorage on each request.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 20:45:51 +01:00
7 changed files with 139 additions and 24 deletions

View File

@@ -59,6 +59,7 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
const createEpic = useCreateEpic();
const updateEpic = useUpdateEpic();
const user = useAuthStore((state) => state.user);
const isHydrated = useAuthStore((state) => state.isHydrated);
const form = useForm<EpicFormValues>({
resolver: zodResolver(epicSchema),
@@ -71,9 +72,19 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
});
async function onSubmit(data: EpicFormValues) {
console.log('[EpicForm] onSubmit triggered', { data, user: user?.id, projectId, isHydrated });
try {
// Check if auth store has completed hydration
if (!isHydrated) {
console.warn('[EpicForm] Auth store not hydrated yet, waiting...');
toast.error('Loading user information, please try again in a moment');
return;
}
if (!user?.id) {
toast.error('User not authenticated');
console.error('[EpicForm] User not authenticated');
toast.error('Please log in to create an epic');
return;
}
@@ -82,20 +93,29 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
estimatedHours: data.estimatedHours || undefined,
};
console.log('[EpicForm] Prepared payload', payload);
if (isEditing) {
console.log('[EpicForm] Updating epic', { epicId: epic.id });
await updateEpic.mutateAsync({
id: epic.id,
data: payload,
});
console.log('[EpicForm] Epic updated successfully');
} else {
await createEpic.mutateAsync({
console.log('[EpicForm] Creating epic', { projectId, createdBy: user.id });
const result = await createEpic.mutateAsync({
projectId,
createdBy: user.id,
...payload,
});
console.log('[EpicForm] Epic created successfully', result);
}
console.log('[EpicForm] Calling onSuccess callback');
onSuccess?.();
} catch (error) {
console.error('[EpicForm] Operation failed', error);
const message = error instanceof Error ? error.message : 'Operation failed';
toast.error(message);
}
@@ -112,7 +132,16 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<form
onSubmit={(e) => {
console.log('[EpicForm] Form submit event triggered', {
formState: form.formState,
values: form.getValues(),
errors: form.formState.errors,
});
form.handleSubmit(onSubmit)(e);
}}
className="space-y-6">
<FormField
control={form.control}
name="name"
@@ -218,9 +247,9 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
Cancel
</Button>
)}
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isLoading || !isHydrated}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? 'Update Epic' : 'Create Epic'}
{!isHydrated ? 'Loading...' : isEditing ? 'Update Epic' : 'Create Epic'}
</Button>
</div>
</form>

View File

@@ -7,16 +7,17 @@ import { useCurrentUser } from '@/lib/hooks/useAuth';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuthStore();
const { isAuthenticated, isHydrated } = useAuthStore();
const { isLoading: isUserLoading } = useCurrentUser();
useEffect(() => {
if (!isLoading && !isUserLoading && !isAuthenticated) {
if (isHydrated && !isUserLoading && !isAuthenticated) {
console.log('[AuthGuard] Redirecting to login - user not authenticated');
router.push('/login');
}
}, [isAuthenticated, isLoading, isUserLoading, router]);
}, [isAuthenticated, isHydrated, isUserLoading, router]);
if (isLoading || isUserLoading) {
if (!isHydrated || isUserLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">

View File

@@ -49,9 +49,18 @@ apiClient.interceptors.request.use(
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log('[API] Request:', {
method: config.method?.toUpperCase(),
url: config.url,
hasAuth: !!token,
data: config.data,
});
return config;
},
(error) => Promise.reject(error)
(error) => {
console.error('[API] Request interceptor error:', error);
return Promise.reject(error);
}
);
// Response interceptor: automatically refresh Token
@@ -74,8 +83,22 @@ const processQueue = (error: unknown, token: string | null = null) => {
};
apiClient.interceptors.response.use(
(response) => response,
(response) => {
console.log('[API] Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
async (error: AxiosError) => {
console.error('[API] Response error:', {
status: error.response?.status,
url: error.config?.url,
message: error.message,
data: error.response?.data,
});
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};

View File

@@ -26,7 +26,15 @@ export const epicsApi = {
},
create: async (data: CreateEpicDto): Promise<Epic> => {
return api.post('/api/v1/epics', data);
console.log('[epicsApi.create] Sending request', { url: '/api/v1/epics', data });
try {
const result = await api.post('/api/v1/epics', data);
console.log('[epicsApi.create] Request successful', result);
return result;
} catch (error) {
console.error('[epicsApi.create] Request failed', error);
throw error;
}
},
update: async (id: string, data: UpdateEpicDto): Promise<Epic> => {

View File

@@ -30,16 +30,20 @@ export function useLogin() {
tokenManager.setAccessToken(data.accessToken);
tokenManager.setRefreshToken(data.refreshToken);
// Map backend field names to frontend User type
// Backend returns: { Id, TenantId, Email, FullName, ... }
// Frontend expects: { id, tenantId, email, fullName, ... }
const backendUser = data.user;
setUser({
id: data.user.id,
email: data.user.email,
fullName: data.user.fullName,
tenantId: data.user.tenantId,
tenantName: data.user.tenantName,
role: data.user.role,
isEmailVerified: data.user.isEmailVerified,
createdAt: data.user.createdAt || new Date().toISOString(),
updatedAt: data.user.updatedAt,
id: backendUser.id || backendUser.Id, // Handle both casing
email: backendUser.email || backendUser.Email,
fullName: backendUser.fullName || backendUser.FullName,
tenantId: backendUser.tenantId || backendUser.TenantId,
tenantName: data.tenant?.name || data.tenant?.Name || 'Unknown',
role: data.tenant?.role || backendUser.role || 'TenantMember',
isEmailVerified: backendUser.isEmailVerified ?? backendUser.IsEmailVerified ?? false,
createdAt: backendUser.createdAt || backendUser.CreatedAt || new Date().toISOString(),
updatedAt: backendUser.updatedAt || backendUser.UpdatedAt,
});
router.push('/dashboard');
@@ -95,9 +99,24 @@ export function useCurrentUser() {
queryKey: ['currentUser'],
queryFn: async () => {
const { data } = await apiClient.get(API_ENDPOINTS.ME);
setUser(data);
// Map backend /me response to frontend User type
// Backend returns: { userId, tenantId, email, fullName, tenantSlug, tenantRole, role }
// Frontend expects: { id, tenantId, email, fullName, tenantName, role, isEmailVerified, createdAt }
const mappedUser = {
id: data.userId || data.id, // Backend uses 'userId'
email: data.email,
fullName: data.fullName,
tenantId: data.tenantId,
tenantName: data.tenantSlug || 'Unknown', // Use tenantSlug as tenantName fallback
role: data.tenantRole || data.role || 'TenantMember',
isEmailVerified: true, // Assume verified if token is valid
createdAt: new Date().toISOString(),
};
setUser(mappedUser);
setLoading(false);
return data;
return mappedUser;
},
enabled: !!tokenManager.getAccessToken(),
retry: false,

View File

@@ -36,7 +36,8 @@ export class SignalRConnectionManager {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(this.hubUrl, {
accessTokenFactory: () => token,
// Use dynamic token factory to always get the latest token
accessTokenFactory: () => tokenManager.getAccessToken() || '',
// 备用方案:使用 query stringWebSocket 升级需要)
// transport: signalR.HttpTransportType.WebSockets,
})

View File

@@ -6,6 +6,7 @@ interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
isHydrated: boolean;
setUser: (user: User) => void;
clearUser: () => void;
@@ -18,6 +19,7 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
isHydrated: false,
setUser: (user) =>
set({ user, isAuthenticated: true, isLoading: false }),
@@ -31,6 +33,38 @@ export const useAuthStore = create<AuthState>()(
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
// 数据迁移函数:将旧格式的 userId 转换为新格式的 id
migrate: (persistedState: any, version: number) => {
console.log('[AuthStore] Migrating persisted state', { version, persistedState });
// 如果存在旧的 userId 字段,迁移到 id
if (persistedState?.user?.userId && !persistedState?.user?.id) {
console.log('[AuthStore] Migrating userId to id');
persistedState.user.id = persistedState.user.userId;
delete persistedState.user.userId;
}
return persistedState;
},
onRehydrateStorage: () => (state) => {
console.log('[AuthStore] Hydration started');
if (state) {
// 额外的安全检查:确保 user 对象有 id 字段
if (state.user && (state.user as any).userId && !state.user.id) {
console.log('[AuthStore] Post-hydration migration: userId -> id');
state.user.id = (state.user as any).userId;
delete (state.user as any).userId;
}
state.isHydrated = true;
state.isLoading = false; // 水合完成后停止 loading
console.log('[AuthStore] Hydration completed', {
userId: state.user?.id,
isAuthenticated: state.isAuthenticated,
isLoading: state.isLoading
});
}
},
}
)
);