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.
@@ -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
- export const LoginForm = (): React.ReactElement => {
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, useSearchParams } from 'next/navigation';
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 searchParams = useSearchParams();
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
- const from = searchParams.get('from');
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: loginMutation.mutate,
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 (auto-generated, unique per project)
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
- _reply: FastifyReply,
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
- const tokens = await authService.refresh(refreshToken);
56
-
57
- setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
58
- reply.send(successResponse('Token refreshed successfully', null));
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(