create-tigra 2.7.1 → 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/bin/create-tigra.js +445 -445
- 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/_claude/skills/clean-ui/SKILL.md +63 -0
- package/template/_claude/skills/theme/SKILL.md +109 -0
- package/template/client/next.config.ts +4 -1
- package/template/client/package.json +47 -47
- 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/components/common/Pagination.tsx +10 -2
- 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/components/LoginForm.tsx +15 -2
- package/template/client/src/features/auth/hooks/useAuth.ts +17 -10
- package/template/client/src/features/auth/hooks/useCurrentUser.ts +50 -0
- package/template/client/src/styles/themes/default.css +92 -92
- package/template/server/.env.example +8 -2
- package/template/server/package.json +1 -1
- package/template/server/src/app.ts +2 -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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type React from 'react';
|
|
4
|
+
import { Suspense } from 'react';
|
|
4
5
|
|
|
5
6
|
import Link from 'next/link';
|
|
7
|
+
import { useSearchParams } from 'next/navigation';
|
|
6
8
|
import { useForm } from 'react-hook-form';
|
|
7
9
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
8
10
|
import { z } from 'zod';
|
|
@@ -22,8 +24,11 @@ const loginSchema = z.object({
|
|
|
22
24
|
|
|
23
25
|
type LoginFormData = z.infer<typeof loginSchema>;
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
const LoginFormInner = (): React.ReactElement => {
|
|
26
28
|
const { login, isLoggingIn } = useAuth();
|
|
29
|
+
const searchParams = useSearchParams();
|
|
30
|
+
const from = searchParams.get('from');
|
|
31
|
+
const redirectTo = from && from.startsWith('/') && !from.startsWith('//') ? from : undefined;
|
|
27
32
|
|
|
28
33
|
const {
|
|
29
34
|
register,
|
|
@@ -34,7 +39,7 @@ export const LoginForm = (): React.ReactElement => {
|
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
const onSubmit = (data: LoginFormData): void => {
|
|
37
|
-
login(data);
|
|
42
|
+
login(data, redirectTo);
|
|
38
43
|
};
|
|
39
44
|
|
|
40
45
|
return (
|
|
@@ -105,3 +110,11 @@ export const LoginForm = (): React.ReactElement => {
|
|
|
105
110
|
</div>
|
|
106
111
|
);
|
|
107
112
|
};
|
|
113
|
+
|
|
114
|
+
export const LoginForm = (): React.ReactElement => {
|
|
115
|
+
return (
|
|
116
|
+
<Suspense>
|
|
117
|
+
<LoginFormInner />
|
|
118
|
+
</Suspense>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback } from 'react';
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
4
|
|
|
5
|
-
import { useMutation } from '@tanstack/react-query';
|
|
6
|
-
import { useRouter
|
|
5
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { useRouter } from 'next/navigation';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
8
|
|
|
9
9
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
|
@@ -18,7 +18,7 @@ interface UseAuthReturn {
|
|
|
18
18
|
user: IUser | null;
|
|
19
19
|
isAuthenticated: boolean;
|
|
20
20
|
isInitializing: boolean;
|
|
21
|
-
login: (data: ILoginRequest) => void;
|
|
21
|
+
login: (data: ILoginRequest, redirectTo?: string) => void;
|
|
22
22
|
register: (data: IRegisterRequest) => void;
|
|
23
23
|
logout: () => Promise<void>;
|
|
24
24
|
isLoggingIn: boolean;
|
|
@@ -29,17 +29,17 @@ interface UseAuthReturn {
|
|
|
29
29
|
export const useAuth = (): UseAuthReturn => {
|
|
30
30
|
const dispatch = useAppDispatch();
|
|
31
31
|
const router = useRouter();
|
|
32
|
-
const
|
|
32
|
+
const queryClient = useQueryClient();
|
|
33
33
|
const { user, isAuthenticated, isInitializing, isLoggingOut } = useAppSelector((state) => state.auth);
|
|
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
|
-
|
|
41
|
-
const redirectTo = from && from.startsWith('/') && !from.startsWith('//') ? from : ROUTES.DASHBOARD;
|
|
42
|
-
router.push(redirectTo);
|
|
42
|
+
router.push(pendingRedirectRef.current);
|
|
43
43
|
},
|
|
44
44
|
onError: (error) => {
|
|
45
45
|
if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
|
|
@@ -58,6 +58,7 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
58
58
|
router.push(ROUTES.VERIFY_ACCOUNT);
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
+
queryClient.clear();
|
|
61
62
|
dispatch(setUser(data.user));
|
|
62
63
|
toast.success('Account created successfully');
|
|
63
64
|
router.push(ROUTES.DASHBOARD);
|
|
@@ -74,16 +75,22 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
74
75
|
} catch {
|
|
75
76
|
// Proceed with local logout even if server call fails
|
|
76
77
|
} finally {
|
|
78
|
+
queryClient.clear();
|
|
77
79
|
dispatch(logoutAction());
|
|
78
80
|
router.push(ROUTES.LOGIN);
|
|
79
81
|
}
|
|
80
|
-
}, [dispatch, router]);
|
|
82
|
+
}, [dispatch, router, queryClient]);
|
|
81
83
|
|
|
82
84
|
return {
|
|
83
85
|
user,
|
|
84
86
|
isAuthenticated,
|
|
85
87
|
isInitializing,
|
|
86
|
-
login:
|
|
88
|
+
login: (data: ILoginRequest, redirectTo?: string) => {
|
|
89
|
+
if (redirectTo) {
|
|
90
|
+
pendingRedirectRef.current = redirectTo;
|
|
91
|
+
}
|
|
92
|
+
loginMutation.mutate(data);
|
|
93
|
+
},
|
|
87
94
|
register: registerMutation.mutate,
|
|
88
95
|
logout,
|
|
89
96
|
isLoggingIn: loginMutation.isPending,
|
|
@@ -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
|
+
};
|
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Default Theme — Claude-inspired warm palette
|
|
3
|
-
* Accent: Terracotta orange
|
|
4
|
-
* Vibe: Earthy, warm, approachable
|
|
5
|
-
*
|
|
6
|
-
* This is the single theme file. Colors use HEX values.
|
|
7
|
-
* Light mode: :root selectors. Dark mode: .dark selectors.
|
|
8
|
-
* To customize: edit the HEX values below.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
:root {
|
|
12
|
-
--radius: 0.625rem;
|
|
13
|
-
/* Warm cream background with terracotta orange accent */
|
|
14
|
-
--background: #f4f3ee;
|
|
15
|
-
--foreground: #1a170f;
|
|
16
|
-
--card: #ffffff;
|
|
17
|
-
--card-foreground: #1a170f;
|
|
18
|
-
--popover: #ffffff;
|
|
19
|
-
--popover-foreground: #1a170f;
|
|
20
|
-
--primary: #c15f3c;
|
|
21
|
-
--primary-foreground: #ffffff;
|
|
22
|
-
--secondary: #ebeae3;
|
|
23
|
-
--secondary-foreground: #302e25;
|
|
24
|
-
--muted: #ebeae3;
|
|
25
|
-
--muted-foreground: #b1ada1;
|
|
26
|
-
--accent: #ebeae3;
|
|
27
|
-
--accent-foreground: #302e25;
|
|
28
|
-
--destructive: #e7000b;
|
|
29
|
-
--border: #deddd4;
|
|
30
|
-
--input: #deddd4;
|
|
31
|
-
--ring: #c15f3c;
|
|
32
|
-
--success: #008339;
|
|
33
|
-
--success-foreground: #ffffff;
|
|
34
|
-
--warning: #e99b2a;
|
|
35
|
-
--warning-foreground: #242119;
|
|
36
|
-
--info: #0079bf;
|
|
37
|
-
--info-foreground: #ffffff;
|
|
38
|
-
--chart-1: #c15f3c;
|
|
39
|
-
--chart-2: #009689;
|
|
40
|
-
--chart-3: #104e64;
|
|
41
|
-
--chart-4: #ebc065;
|
|
42
|
-
--chart-5: #b1ada1;
|
|
43
|
-
--sidebar: #f0efe9;
|
|
44
|
-
--sidebar-foreground: #1a170f;
|
|
45
|
-
--sidebar-primary: #302e25;
|
|
46
|
-
--sidebar-primary-foreground: #f4f3ee;
|
|
47
|
-
--sidebar-accent: #ebeae3;
|
|
48
|
-
--sidebar-accent-foreground: #302e25;
|
|
49
|
-
--sidebar-border: #deddd4;
|
|
50
|
-
--sidebar-ring: #c15f3c;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
.dark {
|
|
54
|
-
/* Dark mode: inverted with same warm undertone */
|
|
55
|
-
--background: #15130d;
|
|
56
|
-
--foreground: #e9e8e3;
|
|
57
|
-
--card: #201e18;
|
|
58
|
-
--card-foreground: #e9e8e3;
|
|
59
|
-
--popover: #201e18;
|
|
60
|
-
--popover-foreground: #e9e8e3;
|
|
61
|
-
--primary: #d6724f;
|
|
62
|
-
--primary-foreground: #15130d;
|
|
63
|
-
--secondary: #2b2922;
|
|
64
|
-
--secondary-foreground: #e9e8e3;
|
|
65
|
-
--muted: #2b2922;
|
|
66
|
-
--muted-foreground: #928f85;
|
|
67
|
-
--accent: #2b2922;
|
|
68
|
-
--accent-foreground: #e9e8e3;
|
|
69
|
-
--destructive: #ff6467;
|
|
70
|
-
--border: rgba(255, 255, 250, 0.12);
|
|
71
|
-
--input: rgba(255, 255, 250, 0.15);
|
|
72
|
-
--ring: #d6724f;
|
|
73
|
-
--success: #009c50;
|
|
74
|
-
--success-foreground: #ffffff;
|
|
75
|
-
--warning: #faab3f;
|
|
76
|
-
--warning-foreground: #242119;
|
|
77
|
-
--info: #0099e0;
|
|
78
|
-
--info-foreground: #ffffff;
|
|
79
|
-
--chart-1: #d6724f;
|
|
80
|
-
--chart-2: #00bc7d;
|
|
81
|
-
--chart-3: #e5a658;
|
|
82
|
-
--chart-4: #a066df;
|
|
83
|
-
--chart-5: #e25969;
|
|
84
|
-
--sidebar: #201e18;
|
|
85
|
-
--sidebar-foreground: #e9e8e3;
|
|
86
|
-
--sidebar-primary: #d6724f;
|
|
87
|
-
--sidebar-primary-foreground: #e9e8e3;
|
|
88
|
-
--sidebar-accent: #2b2922;
|
|
89
|
-
--sidebar-accent-foreground: #e9e8e3;
|
|
90
|
-
--sidebar-border: rgba(255, 255, 250, 0.12);
|
|
91
|
-
--sidebar-ring: #d6724f;
|
|
92
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Default Theme — Claude-inspired warm palette
|
|
3
|
+
* Accent: Terracotta orange
|
|
4
|
+
* Vibe: Earthy, warm, approachable
|
|
5
|
+
*
|
|
6
|
+
* This is the single theme file. Colors use HEX values.
|
|
7
|
+
* Light mode: :root selectors. Dark mode: .dark selectors.
|
|
8
|
+
* To customize: edit the HEX values below.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--radius: 0.625rem;
|
|
13
|
+
/* Warm cream background with terracotta orange accent */
|
|
14
|
+
--background: #f4f3ee;
|
|
15
|
+
--foreground: #1a170f;
|
|
16
|
+
--card: #ffffff;
|
|
17
|
+
--card-foreground: #1a170f;
|
|
18
|
+
--popover: #ffffff;
|
|
19
|
+
--popover-foreground: #1a170f;
|
|
20
|
+
--primary: #c15f3c;
|
|
21
|
+
--primary-foreground: #ffffff;
|
|
22
|
+
--secondary: #ebeae3;
|
|
23
|
+
--secondary-foreground: #302e25;
|
|
24
|
+
--muted: #ebeae3;
|
|
25
|
+
--muted-foreground: #b1ada1;
|
|
26
|
+
--accent: #ebeae3;
|
|
27
|
+
--accent-foreground: #302e25;
|
|
28
|
+
--destructive: #e7000b;
|
|
29
|
+
--border: #deddd4;
|
|
30
|
+
--input: #deddd4;
|
|
31
|
+
--ring: #c15f3c;
|
|
32
|
+
--success: #008339;
|
|
33
|
+
--success-foreground: #ffffff;
|
|
34
|
+
--warning: #e99b2a;
|
|
35
|
+
--warning-foreground: #242119;
|
|
36
|
+
--info: #0079bf;
|
|
37
|
+
--info-foreground: #ffffff;
|
|
38
|
+
--chart-1: #c15f3c;
|
|
39
|
+
--chart-2: #009689;
|
|
40
|
+
--chart-3: #104e64;
|
|
41
|
+
--chart-4: #ebc065;
|
|
42
|
+
--chart-5: #b1ada1;
|
|
43
|
+
--sidebar: #f0efe9;
|
|
44
|
+
--sidebar-foreground: #1a170f;
|
|
45
|
+
--sidebar-primary: #302e25;
|
|
46
|
+
--sidebar-primary-foreground: #f4f3ee;
|
|
47
|
+
--sidebar-accent: #ebeae3;
|
|
48
|
+
--sidebar-accent-foreground: #302e25;
|
|
49
|
+
--sidebar-border: #deddd4;
|
|
50
|
+
--sidebar-ring: #c15f3c;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.dark {
|
|
54
|
+
/* Dark mode: inverted with same warm undertone */
|
|
55
|
+
--background: #15130d;
|
|
56
|
+
--foreground: #e9e8e3;
|
|
57
|
+
--card: #201e18;
|
|
58
|
+
--card-foreground: #e9e8e3;
|
|
59
|
+
--popover: #201e18;
|
|
60
|
+
--popover-foreground: #e9e8e3;
|
|
61
|
+
--primary: #d6724f;
|
|
62
|
+
--primary-foreground: #15130d;
|
|
63
|
+
--secondary: #2b2922;
|
|
64
|
+
--secondary-foreground: #e9e8e3;
|
|
65
|
+
--muted: #2b2922;
|
|
66
|
+
--muted-foreground: #928f85;
|
|
67
|
+
--accent: #2b2922;
|
|
68
|
+
--accent-foreground: #e9e8e3;
|
|
69
|
+
--destructive: #ff6467;
|
|
70
|
+
--border: rgba(255, 255, 250, 0.12);
|
|
71
|
+
--input: rgba(255, 255, 250, 0.15);
|
|
72
|
+
--ring: #d6724f;
|
|
73
|
+
--success: #009c50;
|
|
74
|
+
--success-foreground: #ffffff;
|
|
75
|
+
--warning: #faab3f;
|
|
76
|
+
--warning-foreground: #242119;
|
|
77
|
+
--info: #0099e0;
|
|
78
|
+
--info-foreground: #ffffff;
|
|
79
|
+
--chart-1: #d6724f;
|
|
80
|
+
--chart-2: #00bc7d;
|
|
81
|
+
--chart-3: #e5a658;
|
|
82
|
+
--chart-4: #a066df;
|
|
83
|
+
--chart-5: #e25969;
|
|
84
|
+
--sidebar: #201e18;
|
|
85
|
+
--sidebar-foreground: #e9e8e3;
|
|
86
|
+
--sidebar-primary: #d6724f;
|
|
87
|
+
--sidebar-primary-foreground: #e9e8e3;
|
|
88
|
+
--sidebar-accent: #2b2922;
|
|
89
|
+
--sidebar-accent-foreground: #e9e8e3;
|
|
90
|
+
--sidebar-border: rgba(255, 255, 250, 0.12);
|
|
91
|
+
--sidebar-ring: #d6724f;
|
|
92
|
+
}
|
|
@@ -93,10 +93,16 @@ MAX_FILE_SIZE_MB=10
|
|
|
93
93
|
# Without this, all uploaded files are lost on every redeployment.
|
|
94
94
|
|
|
95
95
|
# ===================================================================
|
|
96
|
-
# DOCKER PORTS
|
|
96
|
+
# DOCKER PORTS — LOCAL DEVELOPMENT ONLY
|
|
97
97
|
# ===================================================================
|
|
98
|
+
#
|
|
99
|
+
# These ports are used by docker-compose to expose MySQL, Redis, and
|
|
100
|
+
# their admin UIs on your local machine. They are NOT needed in
|
|
101
|
+
# production — production connects via DATABASE_URL and REDIS_URL
|
|
102
|
+
# (typically over a private network), not through exposed ports.
|
|
103
|
+
#
|
|
104
|
+
# Change these if they conflict with other services on your machine.
|
|
98
105
|
|
|
99
|
-
# Change these if they conflict with other services on your machine
|
|
100
106
|
MYSQL_PORT={{MYSQL_PORT}}
|
|
101
107
|
PHPMYADMIN_PORT={{PHPMYADMIN_PORT}}
|
|
102
108
|
REDIS_PORT={{REDIS_PORT}}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"prisma:seed": "prisma db seed",
|
|
20
20
|
"prisma:studio": "prisma studio",
|
|
21
21
|
"lint": "eslint src/",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
22
23
|
"redis:flush": "tsx scripts/flush-redis.ts",
|
|
23
24
|
"docker:up": "docker compose up -d",
|
|
24
25
|
"docker:down": "docker compose down",
|
|
@@ -29,7 +30,6 @@
|
|
|
29
30
|
"seed": "tsx prisma/seed.ts"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"@fastify/compress": "^8.3.1",
|
|
33
33
|
"@fastify/cookie": "^11.0.2",
|
|
34
34
|
"@fastify/cors": "^11.2.0",
|
|
35
35
|
"@fastify/helmet": "^13.0.2",
|
|
@@ -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';
|
|
@@ -61,19 +60,15 @@ export async function buildApp() {
|
|
|
61
60
|
await app.register(cors, {
|
|
62
61
|
origin: corsOrigin,
|
|
63
62
|
credentials: true,
|
|
63
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
// Enhanced security headers for production
|
|
67
67
|
await app.register(helmet, {
|
|
68
68
|
global: true,
|
|
69
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
// Response compression (gzip/brotli) for performance
|
|
72
|
-
await app.register(compress, {
|
|
73
|
-
global: true,
|
|
74
|
-
threshold: 1024, // Only compress responses > 1KB
|
|
75
|
-
encodings: ['gzip', 'deflate'],
|
|
76
|
-
});
|
|
77
72
|
|
|
78
73
|
// Rate limiting: Redis-backed when available, in-memory fallback
|
|
79
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(
|