create-restack-pattern 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/index.js +73 -0
  4. package/package.json +26 -0
  5. package/template/backend/.env.example +8 -0
  6. package/template/backend/README.md +8 -0
  7. package/template/backend/api/index.ts +9 -0
  8. package/template/backend/drizzle/0000_grey_lethal_legion.sql +19 -0
  9. package/template/backend/drizzle/meta/0000_snapshot.json +137 -0
  10. package/template/backend/drizzle/meta/_journal.json +13 -0
  11. package/template/backend/drizzle.config.ts +11 -0
  12. package/template/backend/gitignore +31 -0
  13. package/template/backend/package.json +34 -0
  14. package/template/backend/public/.gitkeep +0 -0
  15. package/template/backend/src/config/.gitkeep +0 -0
  16. package/template/backend/src/config/env.ts +22 -0
  17. package/template/backend/src/domains/auth/.gitkeep +0 -0
  18. package/template/backend/src/domains/auth/auth.model.ts +20 -0
  19. package/template/backend/src/domains/auth/auth.repo.ts +51 -0
  20. package/template/backend/src/domains/auth/auth.routes.ts +93 -0
  21. package/template/backend/src/domains/auth/auth.service.test.ts +143 -0
  22. package/template/backend/src/domains/auth/auth.service.ts +97 -0
  23. package/template/backend/src/domains/auth/auth.types.ts +7 -0
  24. package/template/backend/src/domains/user/.gitkeep +0 -0
  25. package/template/backend/src/domains/user/user.model.ts +14 -0
  26. package/template/backend/src/domains/user/user.repo.ts +29 -0
  27. package/template/backend/src/domains/user/user.routes.test.ts +82 -0
  28. package/template/backend/src/domains/user/user.routes.ts +48 -0
  29. package/template/backend/src/domains/user/user.service.ts +40 -0
  30. package/template/backend/src/hono-app.ts +42 -0
  31. package/template/backend/src/index.ts +13 -0
  32. package/template/backend/src/shared/db/.gitkeep +0 -0
  33. package/template/backend/src/shared/db/index.ts +11 -0
  34. package/template/backend/src/shared/logger/logger.ts +15 -0
  35. package/template/backend/src/shared/middlewares/.gitkeep +0 -0
  36. package/template/backend/src/shared/middlewares/rate-limit.ts +43 -0
  37. package/template/backend/src/shared/middlewares/require-auth.test.ts +51 -0
  38. package/template/backend/src/shared/middlewares/require-auth.ts +31 -0
  39. package/template/backend/tsconfig.json +16 -0
  40. package/template/backend/vercel.json +6 -0
  41. package/template/frontend/AGENTS.md +5 -0
  42. package/template/frontend/CLAUDE.md +1 -0
  43. package/template/frontend/README.md +36 -0
  44. package/template/frontend/app/(auth)/login/page.tsx +24 -0
  45. package/template/frontend/app/(auth)/register/page.tsx +24 -0
  46. package/template/frontend/app/dashboard/page.tsx +29 -0
  47. package/template/frontend/app/globals.css +131 -0
  48. package/template/frontend/app/icon.png +0 -0
  49. package/template/frontend/app/layout.tsx +36 -0
  50. package/template/frontend/app/page.tsx +33 -0
  51. package/template/frontend/app/profile/page.tsx +23 -0
  52. package/template/frontend/components/ui/button.tsx +67 -0
  53. package/template/frontend/components/ui/card.tsx +103 -0
  54. package/template/frontend/components/ui/input.tsx +19 -0
  55. package/template/frontend/components/ui/label.tsx +24 -0
  56. package/template/frontend/components.json +24 -0
  57. package/template/frontend/domains/auth/components/.gitkeep +0 -0
  58. package/template/frontend/domains/auth/components/login-form.tsx +75 -0
  59. package/template/frontend/domains/auth/components/logout-button.tsx +30 -0
  60. package/template/frontend/domains/auth/components/register-form.tsx +87 -0
  61. package/template/frontend/domains/auth/hooks/.gitkeep +0 -0
  62. package/template/frontend/domains/auth/hooks/use-login.ts +17 -0
  63. package/template/frontend/domains/auth/hooks/use-register.ts +17 -0
  64. package/template/frontend/domains/auth/services/.gitkeep +0 -0
  65. package/template/frontend/domains/auth/services/auth.api.ts +27 -0
  66. package/template/frontend/domains/user/components/.gitkeep +0 -0
  67. package/template/frontend/domains/user/components/profile-card.tsx +33 -0
  68. package/template/frontend/domains/user/hooks/use-profile.ts +39 -0
  69. package/template/frontend/domains/user/services/user.api.ts +21 -0
  70. package/template/frontend/eslint.config.mjs +18 -0
  71. package/template/frontend/gitignore +43 -0
  72. package/template/frontend/lib/utils.ts +11 -0
  73. package/template/frontend/next.config.ts +7 -0
  74. package/template/frontend/package.json +35 -0
  75. package/template/frontend/postcss.config.mjs +7 -0
  76. package/template/frontend/public/favicon.ico +0 -0
  77. package/template/frontend/public/logo.png +0 -0
  78. package/template/frontend/shared/components/require-auth.tsx +25 -0
  79. package/template/frontend/shared/components/ui/.gitkeep +0 -0
  80. package/template/frontend/shared/hooks/.gitkeep +0 -0
  81. package/template/frontend/shared/hooks/use-auth-mutation.ts +37 -0
  82. package/template/frontend/shared/lib/.gitkeep +0 -0
  83. package/template/frontend/shared/lib/api-client.ts +73 -0
  84. package/template/frontend/tsconfig.json +34 -0
  85. package/template/gitignore +35 -0
  86. package/template/package.json +13 -0
  87. package/template/packages/shared/package.json +24 -0
  88. package/template/packages/shared/src/index.ts +2 -0
  89. package/template/packages/shared/src/schemas/auth.schema.ts +20 -0
  90. package/template/packages/shared/src/schemas/user.schema.ts +16 -0
  91. package/template/packages/shared/tsconfig.json +51 -0
  92. package/template/pnpm-workspace.yaml +4 -0
@@ -0,0 +1,75 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import Link from "next/link"
5
+ import { Button } from "@/components/ui/button"
6
+ import { Input } from "@/components/ui/input"
7
+ import { Label } from "@/components/ui/label"
8
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
9
+ import { useLogin } from "../hooks/use-login"
10
+
11
+ /**
12
+ * LoginForm UI Component (Auth Domain).
13
+ * Handles rendering the login form and calling the `useLogin` hook.
14
+ *
15
+ * Semi-DDD rules:
16
+ * - UI components should stay isolated from direct backend API fetching logic.
17
+ * - Local react state controls input values, and mutation runs through the domain hook.
18
+ */
19
+ export function LoginForm() {
20
+ const { login, error, isPending } = useLogin()
21
+ const [email, setEmail] = useState("")
22
+ const [password, setPassword] = useState("")
23
+
24
+ const handleSubmit = (e: React.FormEvent) => {
25
+ e.preventDefault()
26
+ login({ email, password })
27
+ }
28
+
29
+ return (
30
+ <Card className="w-full max-w-sm">
31
+ <CardHeader>
32
+ <CardTitle>Login</CardTitle>
33
+ <CardDescription>Enter your credentials to continue</CardDescription>
34
+ </CardHeader>
35
+ <CardContent>
36
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
37
+ <div className="flex flex-col gap-1">
38
+ <Label htmlFor="email">Email</Label>
39
+ <Input
40
+ id="email"
41
+ type="email"
42
+ value={email}
43
+ onChange={(e) => setEmail(e.target.value)}
44
+ placeholder="you@example.com"
45
+ required
46
+ />
47
+ </div>
48
+ <div className="flex flex-col gap-1">
49
+ <Label htmlFor="password">Password</Label>
50
+ <Input
51
+ id="password"
52
+ type="password"
53
+ value={password}
54
+ onChange={(e) => setPassword(e.target.value)}
55
+ placeholder="••••••••"
56
+ required
57
+ />
58
+ </div>
59
+ {error && <p className="text-sm text-red-500">{error}</p>}
60
+ <Button type="submit" disabled={isPending} className="w-full">
61
+ {isPending ? "Logging in..." : "Login"}
62
+ </Button>
63
+ </form>
64
+ </CardContent>
65
+ <CardFooter className="justify-center">
66
+ <p className="text-sm text-muted-foreground">
67
+ Don&apos;t have an account?{" "}
68
+ <Link href="/register" className="text-primary underline underline-offset-4">
69
+ Register
70
+ </Link>
71
+ </p>
72
+ </CardFooter>
73
+ </Card>
74
+ )
75
+ }
@@ -0,0 +1,30 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { Button } from "@/components/ui/button"
6
+ import { authApi } from "../services/auth.api"
7
+
8
+ /**
9
+ * LogoutButton Component (Auth Domain).
10
+ * Simple action component to trigger sessions termination and redirect.
11
+ *
12
+ * Semi-DDD rules:
13
+ * - Simple action triggers can invoke services directly if no reactive local states/business orchestration is required.
14
+ */
15
+ export function LogoutButton() {
16
+ const router = useRouter()
17
+ const [isPending, setIsPending] = useState(false)
18
+
19
+ const handleLogout = async () => {
20
+ setIsPending(true)
21
+ await authApi.logout()
22
+ router.push("/login")
23
+ }
24
+
25
+ return (
26
+ <Button variant="outline" onClick={handleLogout} disabled={isPending}>
27
+ {isPending ? "Logging out..." : "Logout"}
28
+ </Button>
29
+ )
30
+ }
@@ -0,0 +1,87 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import Link from "next/link"
5
+ import { Button } from "@/components/ui/button"
6
+ import { Input } from "@/components/ui/input"
7
+ import { Label } from "@/components/ui/label"
8
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
9
+ import { useRegister } from "../hooks/use-register"
10
+
11
+ /**
12
+ * RegisterForm UI Component (Auth Domain).
13
+ * Handles rendering the register form and calling the `useRegister` hook.
14
+ *
15
+ * Semi-DDD rules:
16
+ * - UI components should stay isolated from direct backend API fetching logic.
17
+ * - Local react state controls input values, and mutation runs through the domain hook.
18
+ */
19
+ export function RegisterForm() {
20
+ const { register, error, isPending } = useRegister()
21
+ const [name, setName] = useState("")
22
+ const [email, setEmail] = useState("")
23
+ const [password, setPassword] = useState("")
24
+
25
+ const handleSubmit = (e: React.FormEvent) => {
26
+ e.preventDefault()
27
+ register({ name, email, password })
28
+ }
29
+
30
+ return (
31
+ <Card className="w-full max-w-sm">
32
+ <CardHeader>
33
+ <CardTitle>Create an account</CardTitle>
34
+ <CardDescription>Fill in the details below to get started</CardDescription>
35
+ </CardHeader>
36
+ <CardContent>
37
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
38
+ <div className="flex flex-col gap-1">
39
+ <Label htmlFor="name">Name</Label>
40
+ <Input
41
+ id="name"
42
+ type="text"
43
+ value={name}
44
+ onChange={(e) => setName(e.target.value)}
45
+ placeholder="Your name"
46
+ required
47
+ />
48
+ </div>
49
+ <div className="flex flex-col gap-1">
50
+ <Label htmlFor="email">Email</Label>
51
+ <Input
52
+ id="email"
53
+ type="email"
54
+ value={email}
55
+ onChange={(e) => setEmail(e.target.value)}
56
+ placeholder="you@example.com"
57
+ required
58
+ />
59
+ </div>
60
+ <div className="flex flex-col gap-1">
61
+ <Label htmlFor="password">Password</Label>
62
+ <Input
63
+ id="password"
64
+ type="password"
65
+ value={password}
66
+ onChange={(e) => setPassword(e.target.value)}
67
+ placeholder="••••••••"
68
+ required
69
+ />
70
+ </div>
71
+ {error && <p className="text-sm text-red-500">{error}</p>}
72
+ <Button type="submit" disabled={isPending} className="w-full">
73
+ {isPending ? "Registering..." : "Register"}
74
+ </Button>
75
+ </form>
76
+ </CardContent>
77
+ <CardFooter className="justify-center">
78
+ <p className="text-sm text-muted-foreground">
79
+ Already have an account?{" "}
80
+ <Link href="/login" className="text-primary underline underline-offset-4">
81
+ Login
82
+ </Link>
83
+ </p>
84
+ </CardFooter>
85
+ </Card>
86
+ )
87
+ }
File without changes
@@ -0,0 +1,17 @@
1
+ "use client"
2
+
3
+ import { LoginSchema, type LoginInput } from "@restack/shared"
4
+ import { useAuthMutation } from "@/shared/hooks/use-auth-mutation"
5
+ import { authApi } from "../services/auth.api"
6
+
7
+ /**
8
+ * Domain-specific hook for Login (Auth Domain).
9
+ * Orchestrates login form mutation by wrapping the reusable useAuthMutation hook.
10
+ *
11
+ * Semi-DDD rules:
12
+ * - Domain hooks hide specific Zod validation schemas, API calls, and loading states from components.
13
+ */
14
+ export function useLogin() {
15
+ const { submit, error, isPending } = useAuthMutation<LoginInput>(LoginSchema, authApi.login)
16
+ return { login: submit, error, isPending }
17
+ }
@@ -0,0 +1,17 @@
1
+ "use client"
2
+
3
+ import { RegisterSchema, type RegisterInput } from "@restack/shared"
4
+ import { useAuthMutation } from "@/shared/hooks/use-auth-mutation"
5
+ import { authApi } from "../services/auth.api"
6
+
7
+ /**
8
+ * Domain-specific hook for Registration (Auth Domain).
9
+ * Orchestrates registration form mutation by wrapping the reusable useAuthMutation hook.
10
+ *
11
+ * Semi-DDD rules:
12
+ * - Domain hooks hide specific Zod validation schemas, API calls, and loading states from components.
13
+ */
14
+ export function useRegister() {
15
+ const { submit, error, isPending } = useAuthMutation<RegisterInput>(RegisterSchema, authApi.register)
16
+ return { register: submit, error, isPending }
17
+ }
@@ -0,0 +1,27 @@
1
+ import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
2
+ import { apiClient } from "@/shared/lib/api-client"
3
+
4
+ /**
5
+ * Service API layer for the Auth domain.
6
+ * Thin wrappers that perform API calls to the Hono backend endpoints.
7
+ *
8
+ * Semi-DDD rules:
9
+ * - Domain services (`domains/[feature]/services/*.api.ts`) handle only raw HTTP calls.
10
+ * - Request and response structures are mapped using schemas shared from `@restack/shared`.
11
+ */
12
+ export const authApi = {
13
+ register: (data: RegisterInput) =>
14
+ apiClient<{ user: AuthUser }>("/api/auth/register", {
15
+ method: "POST",
16
+ body: JSON.stringify(data),
17
+ }),
18
+
19
+ login: (data: LoginInput) =>
20
+ apiClient<{ user: AuthUser }>("/api/auth/login", {
21
+ method: "POST",
22
+ body: JSON.stringify(data),
23
+ }),
24
+
25
+ logout: () =>
26
+ apiClient<{ success: boolean }>("/api/auth/logout", { method: "POST" }),
27
+ }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
4
+ import { useProfile } from "../hooks/use-profile"
5
+
6
+ /**
7
+ * ProfileCard UI component.
8
+ * Displays the loaded user profile information in a card layout.
9
+ *
10
+ * Semi-DDD rules:
11
+ * - Domain components (`domains/[feature]/components/*`) are responsible for UI layout and rendering.
12
+ * - They should never perform direct HTTP calls or manage global state; instead, they consume data
13
+ * and actions via domain-specific hooks (`useProfile`).
14
+ */
15
+ export function ProfileCard() {
16
+ const { profile, isLoading, error } = useProfile()
17
+
18
+ if (isLoading) return <p className="text-sm text-muted-foreground">Loading profile...</p>
19
+ if (error) return <p className="text-sm text-red-500">{error}</p>
20
+ if (!profile) return null
21
+
22
+ return (
23
+ <Card className="w-full max-w-sm">
24
+ <CardHeader>
25
+ <CardTitle>{profile.name}</CardTitle>
26
+ <CardDescription>{profile.email}</CardDescription>
27
+ </CardHeader>
28
+ <CardContent className="text-xs text-muted-foreground">
29
+ User ID: {profile.id}
30
+ </CardContent>
31
+ </Card>
32
+ )
33
+ }
@@ -0,0 +1,39 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import type { AuthUser } from "@restack/shared"
6
+ import { userApi } from "../services/user.api"
7
+
8
+ /**
9
+ * Domain-specific Hook for user profiles.
10
+ * Manages loading, error, and profile states, abstracting backend calls.
11
+ *
12
+ * Redirects to /login on failure: there is no server-side (proxy.ts) auth gate
13
+ * anymore — presence-only cookie checks can't work once frontend and backend
14
+ * sit on unrelated domains (see docs/deployment-verification.md, Topology B),
15
+ * so this client-side check is the only thing actually protecting this page.
16
+ *
17
+ * Semi-DDD rules:
18
+ * - Domain hooks (`domains/[feature]/hooks/*.ts`) manage the reactive state and orchestrate calls to `services/`.
19
+ * - UI components should import hooks to retrieve state and trigger actions, keeping the components themselves focused purely on layout/rendering.
20
+ */
21
+ export function useProfile() {
22
+ const router = useRouter()
23
+ const [profile, setProfile] = useState<AuthUser | null>(null)
24
+ const [isLoading, setIsLoading] = useState(true)
25
+ const [error, setError] = useState<string | null>(null)
26
+
27
+ useEffect(() => {
28
+ userApi
29
+ .me()
30
+ .then((res) => setProfile(res.user))
31
+ .catch((e) => {
32
+ setError(e instanceof Error ? e.message : "Failed to load profile")
33
+ router.replace("/login")
34
+ })
35
+ .finally(() => setIsLoading(false))
36
+ }, [router])
37
+
38
+ return { profile, isLoading, error }
39
+ }
@@ -0,0 +1,21 @@
1
+ import type { AuthUser, UpdateProfileInput } from "@restack/shared"
2
+ import { apiClient } from "@/shared/lib/api-client"
3
+
4
+ /**
5
+ * Service API layer for the User domain.
6
+ * Thin wrappers that perform API calls to the Hono backend endpoints.
7
+ *
8
+ * Semi-DDD rules:
9
+ * - Domain services (`domains/[feature]/services/*.api.ts`) handle only raw HTTP calls.
10
+ * - They must not contain UI state or react state; delegate that strictly to domain hooks.
11
+ * - Request and response structures are mapped using schemas shared from `@restack/shared`.
12
+ */
13
+ export const userApi = {
14
+ me: () => apiClient<{ user: AuthUser }>("/api/user/me"),
15
+
16
+ updateProfile: (data: UpdateProfileInput) =>
17
+ apiClient<{ user: AuthUser }>("/api/user/profile", {
18
+ method: "PUT",
19
+ body: JSON.stringify(data),
20
+ }),
21
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,43 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
42
+
43
+ .vercel
@@ -0,0 +1,11 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ /**
5
+ * Global utility class merger.
6
+ * Combines `clsx` (conditional classes) and `tailwind-merge` (overrides duplicate Tailwind classes)
7
+ * to safely handle dynamic styling inputs without layout conflicts.
8
+ */
9
+ export function cn(...inputs: ClassValue[]) {
10
+ return twMerge(clsx(inputs))
11
+ }
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ transpilePackages: ["@restack/shared"],
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@restack/shared": "workspace:*",
13
+ "class-variance-authority": "^0.7.1",
14
+ "clsx": "^2.1.1",
15
+ "next": "16.2.9",
16
+ "radix-ui": "^1.6.1",
17
+ "react": "19.2.4",
18
+ "react-dom": "19.2.4",
19
+ "shadcn": "^4.12.0",
20
+ "sharp": "^0.35.3",
21
+ "tailwind-merge": "^3.6.0",
22
+ "tw-animate-css": "^1.4.0",
23
+ "zod": "^4.4.3"
24
+ },
25
+ "devDependencies": {
26
+ "@tailwindcss/postcss": "^4",
27
+ "@types/node": "^20",
28
+ "@types/react": "^19",
29
+ "@types/react-dom": "^19",
30
+ "eslint": "^9",
31
+ "eslint-config-next": "16.2.9",
32
+ "tailwindcss": "^4",
33
+ "typescript": "^5"
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,25 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { apiClient } from "../lib/api-client"
6
+
7
+ /**
8
+ * Client-side auth gate for pages with no data fetch of their own to piggyback
9
+ * a redirect on (see use-profile.ts for pages that need both). Necessary
10
+ * because there is no server-side proxy.ts guard anymore — see the Next.js 16
11
+ * caveat in CLAUDE.md for why.
12
+ */
13
+ export function RequireAuth({ children }: { children: React.ReactNode }) {
14
+ const router = useRouter()
15
+ const [checked, setChecked] = useState(false)
16
+
17
+ useEffect(() => {
18
+ apiClient("/api/user/me")
19
+ .then(() => setChecked(true))
20
+ .catch(() => router.replace("/login"))
21
+ }, [router])
22
+
23
+ if (!checked) return null
24
+ return <>{children}</>
25
+ }
File without changes
@@ -0,0 +1,37 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import type { ZodType } from "zod"
6
+
7
+ /**
8
+ * Reusable mutation hook for authorization forms (Login, Register).
9
+ * Standardizes Zod input validation, pending state tracking, error handling,
10
+ * and automatic client-side redirection on successful mutation.
11
+ */
12
+ export function useAuthMutation<TInput>(schema: ZodType<TInput>, mutate: (data: TInput) => Promise<unknown>) {
13
+ const router = useRouter()
14
+ const [error, setError] = useState<string | null>(null)
15
+ const [isPending, setIsPending] = useState(false)
16
+
17
+ const submit = async (data: TInput) => {
18
+ const parsed = schema.safeParse(data)
19
+ if (!parsed.success) {
20
+ setError(parsed.error.issues[0].message)
21
+ return
22
+ }
23
+
24
+ setIsPending(true)
25
+ setError(null)
26
+ try {
27
+ await mutate(parsed.data)
28
+ router.push("/dashboard")
29
+ } catch (e) {
30
+ setError(e instanceof Error ? e.message : "Something went wrong")
31
+ } finally {
32
+ setIsPending(false)
33
+ }
34
+ }
35
+
36
+ return { submit, error, isPending }
37
+ }
File without changes
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Global HTTP client helper for frontend domains.
3
+ * Wraps global fetch and configures default options such as content types,
4
+ * credentials handling (sending HTTP cookies), and base API URL resolution.
5
+ */
6
+ const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"
7
+
8
+ /**
9
+ * Custom API Error wrapping utility.
10
+ * Captures both the backend error message payload and the raw HTTP response status code.
11
+ */
12
+ class ApiError extends Error {
13
+ constructor(message: string, public status: number) {
14
+ super(message)
15
+ }
16
+ }
17
+
18
+ interface ApiErrorBody {
19
+ error?: string
20
+ }
21
+
22
+ async function request<T>(path: string, options?: RequestInit): Promise<T> {
23
+ const res = await fetch(`${BASE_URL}${path}`, {
24
+ ...options,
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ ...options?.headers,
28
+ },
29
+ credentials: "include",
30
+ })
31
+
32
+ const contentType = res.headers.get("content-type")
33
+ const data: unknown =
34
+ contentType && contentType.includes("application/json")
35
+ ? await res.json()
36
+ : { error: (await res.text()) || "Request failed" }
37
+
38
+ if (!res.ok) throw new ApiError((data as ApiErrorBody).error ?? "Request failed", res.status)
39
+ return data as T
40
+ }
41
+
42
+ // The refresh token is single-use (backend rotates it atomically), so if several
43
+ // requests 401 at once they must share one refresh call, not each fire their own —
44
+ // only the first would succeed and the rest would wrongly fail as "logged out".
45
+ let refreshPromise: Promise<unknown> | null = null
46
+
47
+ function refreshSession() {
48
+ if (!refreshPromise) {
49
+ refreshPromise = request("/api/auth/refresh", { method: "POST" }).finally(() => {
50
+ refreshPromise = null
51
+ })
52
+ }
53
+ return refreshPromise
54
+ }
55
+
56
+ export async function apiClient<T>(
57
+ path: string,
58
+ options?: RequestInit
59
+ ): Promise<T> {
60
+ try {
61
+ return await request<T>(path, options)
62
+ } catch (e) {
63
+ // The access_token cookie is short-lived by design (15m). On a 401, silently
64
+ // exchange the refresh_token for a new pair once, then retry the original call.
65
+ // Auth routes are excluded so a failed refresh can't loop back into itself.
66
+ const isAuthRoute = path.startsWith("/api/auth/")
67
+ if (e instanceof ApiError && e.status === 401 && !isAuthRoute) {
68
+ await refreshSession()
69
+ return request<T>(path, options)
70
+ }
71
+ throw e
72
+ }
73
+ }