create-tigra 2.7.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/_claude/rules/client/03-data-and-state.md +44 -5
- package/template/_claude/rules/client/05-security.md +1 -1
- package/template/client/next.config.ts +4 -1
- package/template/client/src/app/globals.css +8 -0
- package/template/client/src/app/layout.tsx +7 -1
- package/template/client/src/app/providers.tsx +5 -4
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +3 -0
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +5 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +27 -44
- package/template/client/src/features/auth/hooks/useAuth.ts +6 -2
- package/template/client/src/features/auth/hooks/useCurrentUser.ts +50 -0
- package/template/server/package.json +0 -1
- package/template/server/src/app.ts +1 -7
- package/template/server/src/config/rate-limit.config.ts +1 -0
- package/template/server/src/libs/auth.ts +9 -2
- package/template/server/src/modules/auth/auth.controller.ts +16 -5
package/package.json
CHANGED
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
Defaults in `app/providers.tsx`:
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
|
-
staleTime:
|
|
29
|
-
gcTime:
|
|
30
|
-
refetchOnWindowFocus:
|
|
28
|
+
staleTime: 30 * 1000 // 30s — short enough that back-navigation refetches
|
|
29
|
+
gcTime: 5 * 60 * 1000 // 5 min
|
|
30
|
+
refetchOnWindowFocus: true // catch cross-tab edits
|
|
31
|
+
refetchOnMount: true // always refetch stale data on mount
|
|
31
32
|
retry: 1
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
**Why these values matter**: With a long `staleTime` (e.g. 5 min) and `refetchOnWindowFocus: false`, navigating back to a list page after editing a record on another page will show stale data until the user hard-refreshes. Keep `staleTime` short and let invalidation + remount refetch do their job.
|
|
36
|
+
|
|
34
37
|
### Query Key Factory Pattern
|
|
35
38
|
|
|
36
39
|
```typescript
|
|
@@ -45,9 +48,35 @@ export const itemKeys = {
|
|
|
45
48
|
|
|
46
49
|
### Mutations
|
|
47
50
|
|
|
48
|
-
On success:
|
|
51
|
+
On success:
|
|
52
|
+
1. **`queryClient.invalidateQueries({ queryKey: ... })`** — refreshes any client-fetched data (React Query).
|
|
53
|
+
2. **`router.refresh()`** — refreshes any Server-Component-rendered data on the next route the user navigates to. Without this, the Next.js Router Cache will serve stale RSC payloads on back-navigation, and your edit will not appear until a hard refresh.
|
|
54
|
+
3. Show a toast.
|
|
55
|
+
4. Navigate if needed.
|
|
56
|
+
|
|
49
57
|
On error: `toast.error(getErrorMessage(error))`.
|
|
50
58
|
|
|
59
|
+
**Always call both `invalidateQueries` AND `router.refresh()`** unless you are 100% certain no Server Component on any reachable route reads the mutated data. The two caches are independent — invalidating one does not touch the other.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const queryClient = useQueryClient();
|
|
64
|
+
|
|
65
|
+
const mutation = useMutation({
|
|
66
|
+
mutationFn: (data) => itemService.updateItem(id, data),
|
|
67
|
+
onSuccess: () => {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: itemKeys.all });
|
|
69
|
+
router.refresh();
|
|
70
|
+
toast.success('Item updated');
|
|
71
|
+
},
|
|
72
|
+
onError: (error) => toast.error(getErrorMessage(error)),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Next.js Router Cache
|
|
77
|
+
|
|
78
|
+
`next.config.ts` sets `experimental.staleTimes: { dynamic: 0, static: 0 }` to disable client-side Router Cache reuse. **Never raise these values** — doing so reintroduces the back-navigation stale-data bug across every page that uses Server Components for data fetching.
|
|
79
|
+
|
|
51
80
|
---
|
|
52
81
|
|
|
53
82
|
## Redux
|
|
@@ -61,7 +90,17 @@ State shape:
|
|
|
61
90
|
{ user: IUser | null; isAuthenticated: boolean; isInitializing: boolean; isLoggingOut: boolean }
|
|
62
91
|
```
|
|
63
92
|
|
|
64
|
-
**Not persisted to localStorage** — auth state is hydrated
|
|
93
|
+
**Not persisted to localStorage** — auth state is hydrated by `AuthInitializer`, which calls the `useCurrentUser()` hook. `useCurrentUser()` is a React Query wrapper around `authService.getMe()` that syncs the result into Redux via a side effect. Tokens are stored in httpOnly cookies (not accessible from JS).
|
|
94
|
+
|
|
95
|
+
**Refreshing the current user**: any mutation that changes the logged-in user's own data (profile update, role change, avatar upload, email verification, subscription change, etc.) MUST invalidate the auth query so Redux picks up the new values:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { authKeys } from '@/features/auth/hooks/useCurrentUser';
|
|
99
|
+
|
|
100
|
+
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Without this, Redux will hold the stale snapshot from initial page load until the next window-focus refetch (30s staleTime), or until logout/hard refresh. Never write directly to the auth slice from outside the auth feature — always go through invalidation.
|
|
65
104
|
|
|
66
105
|
---
|
|
67
106
|
|
|
@@ -37,7 +37,7 @@ X-XSS-Protection: 1; mode=block
|
|
|
37
37
|
default-src 'self';
|
|
38
38
|
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
|
39
39
|
style-src 'self' 'unsafe-inline';
|
|
40
|
-
img-src 'self' blob: data: https
|
|
40
|
+
img-src 'self' blob: data: https: ${apiOrigin};
|
|
41
41
|
font-src 'self';
|
|
42
42
|
object-src 'none';
|
|
43
43
|
base-uri 'self';
|
|
@@ -11,6 +11,9 @@ const apiOrigin = (() => {
|
|
|
11
11
|
|
|
12
12
|
const nextConfig: NextConfig = {
|
|
13
13
|
output: "standalone",
|
|
14
|
+
experimental: {
|
|
15
|
+
staleTimes: { dynamic: 0, static: 0 },
|
|
16
|
+
},
|
|
14
17
|
async headers() {
|
|
15
18
|
return [
|
|
16
19
|
{
|
|
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
|
|
|
26
29
|
"default-src 'self'",
|
|
27
30
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
28
31
|
"style-src 'self' 'unsafe-inline'",
|
|
29
|
-
|
|
32
|
+
`img-src 'self' blob: data: https: ${apiOrigin}`,
|
|
30
33
|
"font-src 'self'",
|
|
31
34
|
"object-src 'none'",
|
|
32
35
|
"base-uri 'self'",
|
|
@@ -88,8 +88,16 @@
|
|
|
88
88
|
transition-duration: 200ms;
|
|
89
89
|
transition-timing-function: ease-out;
|
|
90
90
|
}
|
|
91
|
+
html {
|
|
92
|
+
@apply bg-background;
|
|
93
|
+
overflow-x: hidden;
|
|
94
|
+
-webkit-text-size-adjust: 100%;
|
|
95
|
+
text-size-adjust: 100%;
|
|
96
|
+
}
|
|
91
97
|
body {
|
|
92
98
|
@apply bg-background text-foreground;
|
|
93
99
|
font-feature-settings: "rlig" 1, "calt" 1;
|
|
100
|
+
overflow-x: hidden;
|
|
101
|
+
touch-action: manipulation;
|
|
94
102
|
}
|
|
95
103
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
1
|
+
import type { Metadata, Viewport } from 'next';
|
|
2
2
|
import type React from 'react';
|
|
3
3
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
|
4
4
|
|
|
@@ -21,6 +21,12 @@ export const metadata: Metadata = {
|
|
|
21
21
|
description: 'A full-stack application built with Next.js and Fastify',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export const viewport: Viewport = {
|
|
25
|
+
width: 'device-width',
|
|
26
|
+
initialScale: 1,
|
|
27
|
+
viewportFit: 'cover',
|
|
28
|
+
};
|
|
29
|
+
|
|
24
30
|
export default function RootLayout({
|
|
25
31
|
children,
|
|
26
32
|
}: Readonly<{
|
|
@@ -17,9 +17,10 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
17
17
|
new QueryClient({
|
|
18
18
|
defaultOptions: {
|
|
19
19
|
queries: {
|
|
20
|
-
staleTime:
|
|
21
|
-
gcTime:
|
|
22
|
-
refetchOnWindowFocus:
|
|
20
|
+
staleTime: 30 * 1000,
|
|
21
|
+
gcTime: 5 * 60 * 1000,
|
|
22
|
+
refetchOnWindowFocus: true,
|
|
23
|
+
refetchOnMount: true,
|
|
23
24
|
retry: 1,
|
|
24
25
|
},
|
|
25
26
|
},
|
|
@@ -29,7 +30,7 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
29
30
|
return (
|
|
30
31
|
<ReduxProvider store={store}>
|
|
31
32
|
<QueryClientProvider client={queryClient}>
|
|
32
|
-
<ThemeProvider attribute="class" defaultTheme="light">
|
|
33
|
+
<ThemeProvider attribute="class" defaultTheme="light" enableColorScheme={false}>
|
|
33
34
|
<AuthInitializer>
|
|
34
35
|
{children}
|
|
35
36
|
</AuthInitializer>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { toast } from 'sonner';
|
|
5
6
|
|
|
6
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
@@ -48,6 +49,7 @@ interface UseForceExpireSessionReturn {
|
|
|
48
49
|
|
|
49
50
|
export const useForceExpireSession = (): UseForceExpireSessionReturn => {
|
|
50
51
|
const queryClient = useQueryClient();
|
|
52
|
+
const router = useRouter();
|
|
51
53
|
|
|
52
54
|
const mutation = useMutation({
|
|
53
55
|
mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
|
|
@@ -55,6 +57,7 @@ export const useForceExpireSession = (): UseForceExpireSessionReturn => {
|
|
|
55
57
|
toast.success('Session expired successfully');
|
|
56
58
|
queryClient.invalidateQueries({ queryKey: adminKeys.sessions() });
|
|
57
59
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
60
|
+
router.refresh();
|
|
58
61
|
},
|
|
59
62
|
onError: (error) => {
|
|
60
63
|
toast.error(getErrorMessage(error));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { toast } from 'sonner';
|
|
5
6
|
|
|
6
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
@@ -81,6 +82,7 @@ interface UseUpdateUserStatusReturn {
|
|
|
81
82
|
|
|
82
83
|
export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
|
|
83
84
|
const queryClient = useQueryClient();
|
|
85
|
+
const router = useRouter();
|
|
84
86
|
|
|
85
87
|
const mutation = useMutation({
|
|
86
88
|
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
|
@@ -90,6 +92,7 @@ export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
|
|
|
90
92
|
toast.success(`User ${action} successfully`);
|
|
91
93
|
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
|
92
94
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
95
|
+
router.refresh();
|
|
93
96
|
},
|
|
94
97
|
onError: (error) => {
|
|
95
98
|
toast.error(getErrorMessage(error));
|
|
@@ -111,6 +114,7 @@ interface UseUpdateUserRoleReturn {
|
|
|
111
114
|
|
|
112
115
|
export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
|
|
113
116
|
const queryClient = useQueryClient();
|
|
117
|
+
const router = useRouter();
|
|
114
118
|
|
|
115
119
|
const mutation = useMutation({
|
|
116
120
|
mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
|
|
@@ -119,6 +123,7 @@ export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
|
|
|
119
123
|
toast.success('User role updated successfully');
|
|
120
124
|
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
|
121
125
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
126
|
+
router.refresh();
|
|
122
127
|
},
|
|
123
128
|
onError: (error) => {
|
|
124
129
|
toast.error(getErrorMessage(error));
|
|
@@ -7,11 +7,10 @@ import { usePathname, useRouter } from 'next/navigation';
|
|
|
7
7
|
|
|
8
8
|
import { toast } from 'sonner';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { useAppSelector } from '@/store/hooks';
|
|
11
11
|
import { ROUTES } from '@/lib/constants/routes';
|
|
12
12
|
import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
|
|
13
|
-
import {
|
|
14
|
-
import { setUser, setInitialized } from '../store/authSlice';
|
|
13
|
+
import { useCurrentUser } from '../hooks/useCurrentUser';
|
|
15
14
|
|
|
16
15
|
const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
|
|
17
16
|
|
|
@@ -32,53 +31,37 @@ function isAuthPage(pathname: string): boolean {
|
|
|
32
31
|
return AUTH_PATHS.some((path) => pathname.startsWith(path));
|
|
33
32
|
}
|
|
34
33
|
|
|
34
|
+
interface HttpLikeError {
|
|
35
|
+
response?: { status?: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
35
38
|
export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
|
|
36
|
-
const dispatch = useAppDispatch();
|
|
37
39
|
const pathname = usePathname();
|
|
38
40
|
const router = useRouter();
|
|
39
|
-
const {
|
|
41
|
+
const { isLoggingOut } = useAppSelector((state) => state.auth);
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
43
|
+
// Skip getMe() on auth pages and during logout.
|
|
44
|
+
// React Query handles deduping, refetch-on-focus, and invalidation —
|
|
45
|
+
// any mutation that touches the current user (profile update, role change,
|
|
46
|
+
// avatar upload, email verification) should call:
|
|
47
|
+
// queryClient.invalidateQueries({ queryKey: authKeys.me() })
|
|
48
|
+
// to refresh Redux state automatically.
|
|
49
|
+
const enabled = !isAuthPage(pathname) && !isLoggingOut;
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
if (!isProtectedPath(pathname)) {
|
|
52
|
-
dispatch(setInitialized());
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
51
|
+
const { error } = useCurrentUser({ enabled });
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Only redirect on auth errors (401/403), not network failures
|
|
69
|
-
const status = error?.response?.status;
|
|
70
|
-
if (status === 401 || status === 403) {
|
|
71
|
-
if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
|
|
72
|
-
toast.error('Your account is not yet activated. Please verify your account.');
|
|
73
|
-
}
|
|
74
|
-
router.push(ROUTES.LOGIN);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
return (): void => {
|
|
79
|
-
cancelled = true;
|
|
80
|
-
};
|
|
81
|
-
}, [dispatch, pathname, isAuthenticated, isLoggingOut, router]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!error) return;
|
|
55
|
+
if (!isProtectedPath(pathname)) return;
|
|
56
|
+
|
|
57
|
+
const status = (error as HttpLikeError)?.response?.status;
|
|
58
|
+
if (status === 401 || status === 403) {
|
|
59
|
+
if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
|
|
60
|
+
toast.error('Your account is not yet activated. Please verify your account.');
|
|
61
|
+
}
|
|
62
|
+
router.push(ROUTES.LOGIN);
|
|
63
|
+
}
|
|
64
|
+
}, [error, pathname, router]);
|
|
82
65
|
|
|
83
66
|
return <>{children}</>;
|
|
84
67
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useRef } from 'react';
|
|
4
4
|
|
|
5
|
-
import { useMutation } from '@tanstack/react-query';
|
|
5
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
6
6
|
import { useRouter } from 'next/navigation';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
8
|
|
|
@@ -29,12 +29,14 @@ interface UseAuthReturn {
|
|
|
29
29
|
export const useAuth = (): UseAuthReturn => {
|
|
30
30
|
const dispatch = useAppDispatch();
|
|
31
31
|
const router = useRouter();
|
|
32
|
+
const queryClient = useQueryClient();
|
|
32
33
|
const { user, isAuthenticated, isInitializing, isLoggingOut } = useAppSelector((state) => state.auth);
|
|
33
34
|
const pendingRedirectRef = useRef<string>(ROUTES.DASHBOARD);
|
|
34
35
|
|
|
35
36
|
const loginMutation = useMutation({
|
|
36
37
|
mutationFn: (data: ILoginRequest) => authService.login(data),
|
|
37
38
|
onSuccess: (data) => {
|
|
39
|
+
queryClient.clear();
|
|
38
40
|
dispatch(setUser(data.user));
|
|
39
41
|
toast.success('Signed in successfully');
|
|
40
42
|
router.push(pendingRedirectRef.current);
|
|
@@ -56,6 +58,7 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
56
58
|
router.push(ROUTES.VERIFY_ACCOUNT);
|
|
57
59
|
return;
|
|
58
60
|
}
|
|
61
|
+
queryClient.clear();
|
|
59
62
|
dispatch(setUser(data.user));
|
|
60
63
|
toast.success('Account created successfully');
|
|
61
64
|
router.push(ROUTES.DASHBOARD);
|
|
@@ -72,10 +75,11 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
72
75
|
} catch {
|
|
73
76
|
// Proceed with local logout even if server call fails
|
|
74
77
|
} finally {
|
|
78
|
+
queryClient.clear();
|
|
75
79
|
dispatch(logoutAction());
|
|
76
80
|
router.push(ROUTES.LOGIN);
|
|
77
81
|
}
|
|
78
|
-
}, [dispatch, router]);
|
|
82
|
+
}, [dispatch, router, queryClient]);
|
|
79
83
|
|
|
80
84
|
return {
|
|
81
85
|
user,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useQuery } from '@tanstack/react-query';
|
|
6
|
+
|
|
7
|
+
import { useAppDispatch } from '@/store/hooks';
|
|
8
|
+
import { authService } from '../services/auth.service';
|
|
9
|
+
import { setUser, setInitialized } from '../store/authSlice';
|
|
10
|
+
|
|
11
|
+
import type { IUser } from '../types/auth.types';
|
|
12
|
+
|
|
13
|
+
export const authKeys = {
|
|
14
|
+
all: ['auth'] as const,
|
|
15
|
+
me: () => [...authKeys.all, 'me'] as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface UseCurrentUserOptions {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface UseCurrentUserReturn {
|
|
23
|
+
user: IUser | undefined;
|
|
24
|
+
isLoading: boolean;
|
|
25
|
+
error: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useCurrentUser = (
|
|
29
|
+
{ enabled = true }: UseCurrentUserOptions = {}
|
|
30
|
+
): UseCurrentUserReturn => {
|
|
31
|
+
const dispatch = useAppDispatch();
|
|
32
|
+
|
|
33
|
+
const { data, isLoading, error } = useQuery({
|
|
34
|
+
queryKey: authKeys.me(),
|
|
35
|
+
queryFn: () => authService.getMe(),
|
|
36
|
+
enabled,
|
|
37
|
+
staleTime: 30 * 1000,
|
|
38
|
+
retry: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (data) dispatch(setUser(data));
|
|
43
|
+
}, [data, dispatch]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!enabled || error) dispatch(setInitialized());
|
|
47
|
+
}, [enabled, error, dispatch]);
|
|
48
|
+
|
|
49
|
+
return { user: data, isLoading, error };
|
|
50
|
+
};
|
|
@@ -4,7 +4,6 @@ import helmet from '@fastify/helmet';
|
|
|
4
4
|
import rateLimit from '@fastify/rate-limit';
|
|
5
5
|
import cookie from '@fastify/cookie';
|
|
6
6
|
import jwt from '@fastify/jwt';
|
|
7
|
-
import compress from '@fastify/compress';
|
|
8
7
|
import multipart from '@fastify/multipart';
|
|
9
8
|
import fastifyStatic from '@fastify/static';
|
|
10
9
|
import path from 'path';
|
|
@@ -67,14 +66,9 @@ export async function buildApp() {
|
|
|
67
66
|
// Enhanced security headers for production
|
|
68
67
|
await app.register(helmet, {
|
|
69
68
|
global: true,
|
|
69
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
// Response compression (gzip/brotli) for performance
|
|
73
|
-
await app.register(compress, {
|
|
74
|
-
global: true,
|
|
75
|
-
threshold: 1024, // Only compress responses > 1KB
|
|
76
|
-
encodings: ['gzip', 'deflate'],
|
|
77
|
-
});
|
|
78
72
|
|
|
79
73
|
// Rate limiting: Redis-backed when available, in-memory fallback
|
|
80
74
|
if (RATE_LIMIT_ENABLED) {
|
|
@@ -27,6 +27,7 @@ const MULTIPLIER = env.RATE_LIMIT_MULTIPLIER;
|
|
|
27
27
|
* Ensures minimum of 1 if rate limiting is enabled.
|
|
28
28
|
*/
|
|
29
29
|
function applyMultiplier(max: number): number {
|
|
30
|
+
if (!RATE_LIMIT_ENABLED) return 1_000_000;
|
|
30
31
|
return Math.max(1, Math.round(max * MULTIPLIER));
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
3
3
|
import { env } from '@config/env.js';
|
|
4
4
|
import { prisma } from '@libs/prisma.js';
|
|
5
5
|
import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
|
|
6
|
+
import { clearAuthCookies } from '@libs/cookies.js';
|
|
6
7
|
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
7
8
|
|
|
8
9
|
let app: FastifyInstance | null = null;
|
|
@@ -52,7 +53,7 @@ export function getRefreshTokenExpiresAt(): Date {
|
|
|
52
53
|
|
|
53
54
|
export async function authenticate(
|
|
54
55
|
request: FastifyRequest,
|
|
55
|
-
|
|
56
|
+
reply: FastifyReply,
|
|
56
57
|
): Promise<void> {
|
|
57
58
|
try {
|
|
58
59
|
await request.jwtVerify();
|
|
@@ -60,16 +61,22 @@ export async function authenticate(
|
|
|
60
61
|
throw new UnauthorizedError('Invalid or expired token');
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
// Verify user still exists, is active, and not soft-deleted
|
|
64
|
+
// Verify user still exists, is active, and not soft-deleted.
|
|
65
|
+
// When the session is definitively dead (user gone/deleted/inactive), clear auth
|
|
66
|
+
// cookies on the response so the browser stops replaying stale credentials.
|
|
67
|
+
// Without this, middleware keeps seeing the (still-unexpired) JWT cookie and
|
|
68
|
+
// bounces /login → /dashboard → 401 → /login in an infinite loop.
|
|
64
69
|
const user = await prisma.user.findUnique({
|
|
65
70
|
where: { id: request.user.userId },
|
|
66
71
|
select: { isActive: true, deletedAt: true },
|
|
67
72
|
});
|
|
68
73
|
|
|
69
74
|
if (!user || user.deletedAt) {
|
|
75
|
+
clearAuthCookies(reply);
|
|
70
76
|
throw new UnauthorizedError('Account is deactivated or deleted');
|
|
71
77
|
}
|
|
72
78
|
if (!user.isActive) {
|
|
79
|
+
clearAuthCookies(reply);
|
|
73
80
|
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
74
81
|
}
|
|
75
82
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
2
|
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
-
import { UnauthorizedError } from '@shared/errors/errors.js';
|
|
3
|
+
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
4
4
|
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
5
|
import type {
|
|
6
6
|
RegisterInput,
|
|
@@ -49,13 +49,24 @@ export async function refresh(
|
|
|
49
49
|
): Promise<void> {
|
|
50
50
|
const refreshToken = request.cookies.refresh_token;
|
|
51
51
|
if (!refreshToken) {
|
|
52
|
+
clearAuthCookies(reply);
|
|
52
53
|
throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
try {
|
|
57
|
+
const tokens = await authService.refresh(refreshToken);
|
|
58
|
+
setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
|
|
59
|
+
reply.send(successResponse('Token refreshed successfully', null));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// Session is definitively dead (refresh token revoked, user gone, inactive).
|
|
62
|
+
// Clear all auth cookies so the browser stops replaying them — otherwise the
|
|
63
|
+
// client keeps retrying refresh and middleware keeps redirecting, producing
|
|
64
|
+
// an infinite loop that trips the rate limiter.
|
|
65
|
+
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
|
66
|
+
clearAuthCookies(reply);
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
export async function logout(
|