create-tigra 1.1.0 → 2.0.1
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/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +259 -308
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +82 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -13
- package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
- package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
- package/template/server/README.md +0 -183
- package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/fix-all-imports.ps1 +0 -52
- package/template/server/scripts/fix-imports-reference.ps1 +0 -16
- package/template/server/scripts/fix-imports.mjs +0 -55
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useMutation } from '@tanstack/react-query';
|
|
6
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
7
|
+
import { toast } from 'sonner';
|
|
8
|
+
|
|
9
|
+
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
|
10
|
+
import { getErrorMessage } from '@/lib/utils/error';
|
|
11
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
12
|
+
import { authService } from '../services/auth.service';
|
|
13
|
+
import { setUser, setLoggingOut, logout as logoutAction } from '../store/authSlice';
|
|
14
|
+
|
|
15
|
+
import type { ILoginRequest, IRegisterRequest, IUser } from '../types/auth.types';
|
|
16
|
+
|
|
17
|
+
interface UseAuthReturn {
|
|
18
|
+
user: IUser | null;
|
|
19
|
+
isAuthenticated: boolean;
|
|
20
|
+
isInitializing: boolean;
|
|
21
|
+
login: (data: ILoginRequest) => void;
|
|
22
|
+
register: (data: IRegisterRequest) => void;
|
|
23
|
+
logout: () => Promise<void>;
|
|
24
|
+
isLoggingIn: boolean;
|
|
25
|
+
isRegistering: boolean;
|
|
26
|
+
isLoggingOut: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const useAuth = (): UseAuthReturn => {
|
|
30
|
+
const dispatch = useAppDispatch();
|
|
31
|
+
const router = useRouter();
|
|
32
|
+
const searchParams = useSearchParams();
|
|
33
|
+
const { user, isAuthenticated, isInitializing, isLoggingOut } = useAppSelector((state) => state.auth);
|
|
34
|
+
|
|
35
|
+
const loginMutation = useMutation({
|
|
36
|
+
mutationFn: (data: ILoginRequest) => authService.login(data),
|
|
37
|
+
onSuccess: (data) => {
|
|
38
|
+
dispatch(setUser(data.user));
|
|
39
|
+
toast.success('Signed in successfully');
|
|
40
|
+
const from = searchParams.get('from');
|
|
41
|
+
const redirectTo = from && from.startsWith('/') && !from.startsWith('//') ? from : '/';
|
|
42
|
+
router.push(redirectTo);
|
|
43
|
+
},
|
|
44
|
+
onError: (error) => {
|
|
45
|
+
toast.error(getErrorMessage(error));
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const registerMutation = useMutation({
|
|
50
|
+
mutationFn: (data: IRegisterRequest) => authService.register(data),
|
|
51
|
+
onSuccess: (data) => {
|
|
52
|
+
dispatch(setUser(data.user));
|
|
53
|
+
toast.success('Account created successfully');
|
|
54
|
+
router.push(ROUTES.HOME);
|
|
55
|
+
},
|
|
56
|
+
onError: (error) => {
|
|
57
|
+
toast.error(getErrorMessage(error));
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const logout = useCallback(async (): Promise<void> => {
|
|
62
|
+
dispatch(setLoggingOut(true));
|
|
63
|
+
try {
|
|
64
|
+
await authService.logout();
|
|
65
|
+
} catch {
|
|
66
|
+
// Proceed with local logout even if server call fails
|
|
67
|
+
} finally {
|
|
68
|
+
dispatch(logoutAction());
|
|
69
|
+
router.push(ROUTES.LOGIN);
|
|
70
|
+
}
|
|
71
|
+
}, [dispatch, router]);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
user,
|
|
75
|
+
isAuthenticated,
|
|
76
|
+
isInitializing,
|
|
77
|
+
login: loginMutation.mutate,
|
|
78
|
+
register: registerMutation.mutate,
|
|
79
|
+
logout,
|
|
80
|
+
isLoggingIn: loginMutation.isPending,
|
|
81
|
+
isRegistering: registerMutation.isPending,
|
|
82
|
+
isLoggingOut,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { apiClient } from '@/lib/api/axios.config';
|
|
2
|
+
import { API_ENDPOINTS } from '@/lib/constants/api-endpoints';
|
|
3
|
+
|
|
4
|
+
import type { ApiResponse } from '@/lib/api/api.types';
|
|
5
|
+
import type { IUser, ILoginRequest, IRegisterRequest } from '../types/auth.types';
|
|
6
|
+
|
|
7
|
+
interface AuthResponse {
|
|
8
|
+
user: IUser;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class AuthService {
|
|
12
|
+
async register(data: IRegisterRequest): Promise<AuthResponse> {
|
|
13
|
+
const response = await apiClient.post<ApiResponse<AuthResponse>>(
|
|
14
|
+
API_ENDPOINTS.AUTH.REGISTER,
|
|
15
|
+
data
|
|
16
|
+
);
|
|
17
|
+
return response.data.data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async login(data: ILoginRequest): Promise<AuthResponse> {
|
|
21
|
+
const response = await apiClient.post<ApiResponse<AuthResponse>>(
|
|
22
|
+
API_ENDPOINTS.AUTH.LOGIN,
|
|
23
|
+
data
|
|
24
|
+
);
|
|
25
|
+
return response.data.data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async logout(): Promise<void> {
|
|
29
|
+
await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getMe(): Promise<IUser> {
|
|
33
|
+
const response = await apiClient.get<ApiResponse<IUser>>(
|
|
34
|
+
API_ENDPOINTS.AUTH.ME
|
|
35
|
+
);
|
|
36
|
+
return response.data.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async verifyEmail(token: string): Promise<void> {
|
|
40
|
+
await apiClient.post(API_ENDPOINTS.AUTH.VERIFY_EMAIL, { token });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
44
|
+
await apiClient.post(API_ENDPOINTS.AUTH.REQUEST_PASSWORD_RESET, { email });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async resetPassword(token: string, newPassword: string): Promise<void> {
|
|
48
|
+
await apiClient.post(API_ENDPOINTS.AUTH.RESET_PASSWORD, { token, newPassword });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const authService = new AuthService();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
|
|
3
|
+
import type { PayloadAction } from '@reduxjs/toolkit';
|
|
4
|
+
import type { IAuthState, IUser } from '../types/auth.types';
|
|
5
|
+
|
|
6
|
+
const initialState: IAuthState = {
|
|
7
|
+
user: null,
|
|
8
|
+
isAuthenticated: false,
|
|
9
|
+
isInitializing: true,
|
|
10
|
+
isLoggingOut: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const authSlice = createSlice({
|
|
14
|
+
name: 'auth',
|
|
15
|
+
initialState,
|
|
16
|
+
reducers: {
|
|
17
|
+
setUser: (state, action: PayloadAction<IUser>) => {
|
|
18
|
+
state.user = action.payload;
|
|
19
|
+
state.isAuthenticated = true;
|
|
20
|
+
state.isInitializing = false;
|
|
21
|
+
state.isLoggingOut = false;
|
|
22
|
+
},
|
|
23
|
+
setInitialized: (state) => {
|
|
24
|
+
state.isInitializing = false;
|
|
25
|
+
},
|
|
26
|
+
setLoggingOut: (state, action: PayloadAction<boolean>) => {
|
|
27
|
+
state.isLoggingOut = action.payload;
|
|
28
|
+
},
|
|
29
|
+
logout: (state) => {
|
|
30
|
+
state.user = null;
|
|
31
|
+
state.isAuthenticated = false;
|
|
32
|
+
state.isLoggingOut = false;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const { setUser, setInitialized, setLoggingOut, logout } = authSlice.actions;
|
|
38
|
+
export default authSlice.reducer;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface IUser {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
firstName: string;
|
|
5
|
+
lastName: string;
|
|
6
|
+
role: UserRole;
|
|
7
|
+
isActive: boolean;
|
|
8
|
+
avatarUrl: string | null;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type UserRole = 'USER' | 'ADMIN';
|
|
14
|
+
|
|
15
|
+
export interface ILoginRequest {
|
|
16
|
+
email: string;
|
|
17
|
+
password: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IRegisterRequest {
|
|
21
|
+
email: string;
|
|
22
|
+
password: string;
|
|
23
|
+
firstName: string;
|
|
24
|
+
lastName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IAuthState {
|
|
28
|
+
user: IUser | null;
|
|
29
|
+
isAuthenticated: boolean;
|
|
30
|
+
isInitializing: boolean;
|
|
31
|
+
isLoggingOut: boolean;
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export const useDebounce = <T,>(value: T, delay = 500): T => {
|
|
6
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
10
|
+
return () => clearTimeout(timer);
|
|
11
|
+
}, [value, delay]);
|
|
12
|
+
|
|
13
|
+
return debouncedValue;
|
|
14
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
export const useLocalStorage = <T,>(key: string, initialValue: T): readonly [T, (value: T | ((val: T) => T)) => void] => {
|
|
6
|
+
const subscribe = useCallback(
|
|
7
|
+
(callback: () => void): (() => void) => {
|
|
8
|
+
const handler = (e: StorageEvent): void => {
|
|
9
|
+
if (e.key === key) callback();
|
|
10
|
+
};
|
|
11
|
+
window.addEventListener('storage', handler);
|
|
12
|
+
return () => window.removeEventListener('storage', handler);
|
|
13
|
+
},
|
|
14
|
+
[key],
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const getSnapshot = useCallback((): string | null => {
|
|
18
|
+
return window.localStorage.getItem(key);
|
|
19
|
+
}, [key]);
|
|
20
|
+
|
|
21
|
+
const getServerSnapshot = useCallback((): string | null => {
|
|
22
|
+
return null;
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const rawValue = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
26
|
+
|
|
27
|
+
const storedValue: T = rawValue !== null
|
|
28
|
+
? (() => {
|
|
29
|
+
try { return JSON.parse(rawValue) as T; }
|
|
30
|
+
catch { return initialValue; }
|
|
31
|
+
})()
|
|
32
|
+
: initialValue;
|
|
33
|
+
|
|
34
|
+
const setValue = useCallback(
|
|
35
|
+
(value: T | ((val: T) => T)): void => {
|
|
36
|
+
try {
|
|
37
|
+
const currentRaw = window.localStorage.getItem(key);
|
|
38
|
+
const current: T = currentRaw !== null
|
|
39
|
+
? (JSON.parse(currentRaw) as T)
|
|
40
|
+
: initialValue;
|
|
41
|
+
const valueToStore = value instanceof Function ? value(current) : value;
|
|
42
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
43
|
+
// Dispatch a storage event so useSyncExternalStore picks up the change
|
|
44
|
+
window.dispatchEvent(new StorageEvent('storage', { key }));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (process.env.NODE_ENV === 'development') {
|
|
47
|
+
console.warn(`[useLocalStorage] Failed to write key "${key}":`, error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
[key, initialValue],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return [storedValue, setValue] as const;
|
|
55
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
export const useMediaQuery = (query: string): boolean => {
|
|
6
|
+
const subscribe = useCallback(
|
|
7
|
+
(callback: () => void): (() => void) => {
|
|
8
|
+
const media = window.matchMedia(query);
|
|
9
|
+
media.addEventListener('change', callback);
|
|
10
|
+
return () => media.removeEventListener('change', callback);
|
|
11
|
+
},
|
|
12
|
+
[query],
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const getSnapshot = (): boolean => {
|
|
16
|
+
return window.matchMedia(query).matches;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getServerSnapshot = (): boolean => {
|
|
20
|
+
return false;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const useIsMobile = (): boolean => useMediaQuery('(max-width: 768px)');
|
|
27
|
+
export const useIsTablet = (): boolean => useMediaQuery('(max-width: 1024px)');
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface ApiResponse<T> {
|
|
2
|
+
success: boolean;
|
|
3
|
+
message: string;
|
|
4
|
+
data: T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PaginatedApiResponse<T> {
|
|
8
|
+
success: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
data: {
|
|
11
|
+
items: T[];
|
|
12
|
+
pagination: {
|
|
13
|
+
page: number;
|
|
14
|
+
limit: number;
|
|
15
|
+
totalItems: number;
|
|
16
|
+
totalPages: number;
|
|
17
|
+
hasNextPage: boolean;
|
|
18
|
+
hasPreviousPage: boolean;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApiError {
|
|
24
|
+
success: false;
|
|
25
|
+
error: {
|
|
26
|
+
code: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PaginationParams {
|
|
32
|
+
page?: number;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import type { InternalAxiosRequestConfig } from 'axios';
|
|
4
|
+
|
|
5
|
+
import { API_ENDPOINTS } from '@/lib/constants/api-endpoints';
|
|
6
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
7
|
+
|
|
8
|
+
const apiClient = axios.create({
|
|
9
|
+
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000/api/v1',
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
withCredentials: true, // Send cookies with every request
|
|
12
|
+
headers: {
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// No request interceptor needed — cookies are sent automatically
|
|
18
|
+
|
|
19
|
+
let isRefreshing = false;
|
|
20
|
+
let refreshTimestamp = 0;
|
|
21
|
+
const REFRESH_TIMEOUT_MS = 15000;
|
|
22
|
+
let failedQueue: { resolve: () => void; reject: (error: unknown) => void }[] = [];
|
|
23
|
+
|
|
24
|
+
const processQueue = (error: unknown): void => {
|
|
25
|
+
failedQueue.forEach((promise) => {
|
|
26
|
+
if (error) {
|
|
27
|
+
promise.reject(error);
|
|
28
|
+
} else {
|
|
29
|
+
promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
failedQueue = [];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const resetRefreshState = (): void => {
|
|
36
|
+
isRefreshing = false;
|
|
37
|
+
refreshTimestamp = 0;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
apiClient.interceptors.response.use(
|
|
41
|
+
(response) => response,
|
|
42
|
+
async (error) => {
|
|
43
|
+
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
44
|
+
|
|
45
|
+
if (error.response?.status !== 401 || originalRequest._retry) {
|
|
46
|
+
return Promise.reject(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Don't retry refresh endpoint itself
|
|
50
|
+
if (originalRequest.url === API_ENDPOINTS.AUTH.REFRESH) {
|
|
51
|
+
return Promise.reject(error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If a refresh is in progress but has exceeded the timeout, reset the stale flag
|
|
55
|
+
if (isRefreshing && Date.now() - refreshTimestamp > REFRESH_TIMEOUT_MS) {
|
|
56
|
+
processQueue(new Error('Token refresh timed out'));
|
|
57
|
+
resetRefreshState();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isRefreshing) {
|
|
61
|
+
return new Promise<void>((resolve, reject) => {
|
|
62
|
+
failedQueue.push({ resolve, reject });
|
|
63
|
+
}).then(() => {
|
|
64
|
+
return apiClient(originalRequest);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
originalRequest._retry = true;
|
|
69
|
+
isRefreshing = true;
|
|
70
|
+
refreshTimestamp = Date.now();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Refresh — cookie is sent automatically
|
|
74
|
+
await apiClient.post(API_ENDPOINTS.AUTH.REFRESH);
|
|
75
|
+
|
|
76
|
+
processQueue(null);
|
|
77
|
+
|
|
78
|
+
// Retry original request — new cookie is set automatically
|
|
79
|
+
return apiClient(originalRequest);
|
|
80
|
+
} catch (refreshError) {
|
|
81
|
+
processQueue(refreshError);
|
|
82
|
+
|
|
83
|
+
const { logout } = await import('@/features/auth/store/authSlice');
|
|
84
|
+
const { store } = await import('@/store');
|
|
85
|
+
store.dispatch(logout());
|
|
86
|
+
|
|
87
|
+
if (typeof window !== 'undefined') {
|
|
88
|
+
window.location.href = ROUTES.LOGIN;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Promise.reject(refreshError);
|
|
92
|
+
} finally {
|
|
93
|
+
resetRefreshState();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export { apiClient };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const API_ENDPOINTS = {
|
|
2
|
+
AUTH: {
|
|
3
|
+
REGISTER: '/auth/register',
|
|
4
|
+
LOGIN: '/auth/login',
|
|
5
|
+
LOGOUT: '/auth/logout',
|
|
6
|
+
REFRESH: '/auth/refresh',
|
|
7
|
+
ME: '/auth/me',
|
|
8
|
+
VERIFY_EMAIL: '/auth/verify-email',
|
|
9
|
+
RESEND_VERIFICATION: '/auth/resend-verification',
|
|
10
|
+
REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
|
|
11
|
+
RESET_PASSWORD: '/auth/reset-password',
|
|
12
|
+
},
|
|
13
|
+
USERS: {
|
|
14
|
+
ME: '/users/me',
|
|
15
|
+
UPDATE_ME: '/users/me',
|
|
16
|
+
DELETE_ME: '/users/me',
|
|
17
|
+
},
|
|
18
|
+
} as const;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
import type { ApiError } from '@/lib/api/api.types';
|
|
4
|
+
|
|
5
|
+
export const getErrorMessage = (error: unknown): string => {
|
|
6
|
+
if (axios.isAxiosError(error)) {
|
|
7
|
+
const apiError = error.response?.data as ApiError;
|
|
8
|
+
if (apiError?.error?.message) {
|
|
9
|
+
return apiError.error.message;
|
|
10
|
+
}
|
|
11
|
+
if (error.code === 'ERR_NETWORK') {
|
|
12
|
+
return 'Network error. Check your connection.';
|
|
13
|
+
}
|
|
14
|
+
return error.message;
|
|
15
|
+
}
|
|
16
|
+
if (error instanceof Error) {
|
|
17
|
+
return error.message;
|
|
18
|
+
}
|
|
19
|
+
return 'An unexpected error occurred';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getErrorCode = (error: unknown): string | undefined => {
|
|
23
|
+
if (axios.isAxiosError(error)) {
|
|
24
|
+
const data = error.response?.data as ApiError | undefined;
|
|
25
|
+
return data?.error?.code;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const isErrorCode = (error: unknown, code: string): boolean => {
|
|
31
|
+
return getErrorCode(error) === code;
|
|
32
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const formatDate = (date: string | Date): string => {
|
|
2
|
+
const parsed = new Date(date);
|
|
3
|
+
if (isNaN(parsed.getTime())) return 'Invalid date';
|
|
4
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
5
|
+
year: 'numeric',
|
|
6
|
+
month: 'long',
|
|
7
|
+
day: 'numeric',
|
|
8
|
+
}).format(parsed);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const formatRelativeTime = (date: string | Date): string => {
|
|
12
|
+
const then = new Date(date);
|
|
13
|
+
if (isNaN(then.getTime())) return 'Invalid date';
|
|
14
|
+
|
|
15
|
+
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const diffInSeconds = (then.getTime() - now.getTime()) / 1000;
|
|
18
|
+
|
|
19
|
+
const units: { unit: Intl.RelativeTimeFormatUnit; seconds: number }[] = [
|
|
20
|
+
{ unit: 'year', seconds: 31536000 },
|
|
21
|
+
{ unit: 'month', seconds: 2592000 },
|
|
22
|
+
{ unit: 'day', seconds: 86400 },
|
|
23
|
+
{ unit: 'hour', seconds: 3600 },
|
|
24
|
+
{ unit: 'minute', seconds: 60 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const { unit, seconds } of units) {
|
|
28
|
+
if (Math.abs(diffInSeconds) >= seconds) {
|
|
29
|
+
return rtf.format(Math.round(diffInSeconds / seconds), unit);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return rtf.format(Math.round(diffInSeconds), 'second');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const truncate = (str: string, length: number): string => {
|
|
36
|
+
return str.length > length ? `${str.substring(0, length)}...` : str;
|
|
37
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const isSafeUrl = (url: string): boolean => {
|
|
2
|
+
try {
|
|
3
|
+
const parsed = new URL(url);
|
|
4
|
+
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
|
|
5
|
+
} catch {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const sanitizeString = (input: string): string => {
|
|
11
|
+
return input
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[<>&"']/g, (char) => {
|
|
14
|
+
const entities: Record<string, string> = {
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'&': '&',
|
|
18
|
+
'"': '"',
|
|
19
|
+
"'": ''',
|
|
20
|
+
};
|
|
21
|
+
return entities[char] ?? char;
|
|
22
|
+
})
|
|
23
|
+
.slice(0, 1000);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const sanitizeEmail = (email: string): string => {
|
|
27
|
+
return email.toLowerCase().trim().slice(0, 255);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const maskEmail = (email: string): string => {
|
|
31
|
+
const [local, domain] = email.split('@');
|
|
32
|
+
if (!local || !domain) return email;
|
|
33
|
+
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
|
34
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
import type { NextRequest } from 'next/server';
|
|
4
|
+
|
|
5
|
+
const protectedPaths = ['/', '/dashboard', '/profile', '/admin'];
|
|
6
|
+
const authPaths = ['/login', '/register'];
|
|
7
|
+
|
|
8
|
+
function isTokenExpired(token: string): boolean {
|
|
9
|
+
try {
|
|
10
|
+
const [, payload] = token.split('.');
|
|
11
|
+
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
12
|
+
return !decoded.exp || decoded.exp * 1000 < Date.now();
|
|
13
|
+
} catch {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function middleware(request: NextRequest): NextResponse {
|
|
19
|
+
const { pathname } = request.nextUrl;
|
|
20
|
+
const token = request.cookies.get('access_token')?.value;
|
|
21
|
+
|
|
22
|
+
const isProtectedPath = protectedPaths.some((path) =>
|
|
23
|
+
path === '/' ? pathname === '/' : pathname.startsWith(path)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (isProtectedPath && (!token || isTokenExpired(token))) {
|
|
27
|
+
const loginUrl = new URL('/login', request.url);
|
|
28
|
+
loginUrl.searchParams.set('from', pathname);
|
|
29
|
+
return NextResponse.redirect(loginUrl);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isAuthPath = authPaths.some((path) => pathname.startsWith(path));
|
|
33
|
+
|
|
34
|
+
if (isAuthPath && token) {
|
|
35
|
+
if (isTokenExpired(token)) {
|
|
36
|
+
// Only delete the expired access token — keep the refresh token
|
|
37
|
+
// so the client-side interceptor can still recover the session
|
|
38
|
+
const response = NextResponse.next();
|
|
39
|
+
response.cookies.delete('access_token');
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
return NextResponse.redirect(new URL('/', request.url));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const config = {
|
|
49
|
+
matcher: [
|
|
50
|
+
'/',
|
|
51
|
+
'/dashboard/:path*',
|
|
52
|
+
'/profile/:path*',
|
|
53
|
+
'/admin/:path*',
|
|
54
|
+
'/login',
|
|
55
|
+
'/register',
|
|
56
|
+
],
|
|
57
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
2
|
+
|
|
3
|
+
import type { TypedUseSelectorHook } from 'react-redux';
|
|
4
|
+
import type { RootState, AppDispatch } from './index';
|
|
5
|
+
|
|
6
|
+
export const useAppDispatch: () => AppDispatch = useDispatch;
|
|
7
|
+
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
2
|
+
|
|
3
|
+
import authReducer from '@/features/auth/store/authSlice';
|
|
4
|
+
|
|
5
|
+
export const store = configureStore({
|
|
6
|
+
reducer: {
|
|
7
|
+
auth: authReducer,
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
12
|
+
export type AppDispatch = typeof store.dispatch;
|