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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/index.js +73 -0
- package/package.json +26 -0
- package/template/backend/.env.example +8 -0
- package/template/backend/README.md +8 -0
- package/template/backend/api/index.ts +9 -0
- package/template/backend/drizzle/0000_grey_lethal_legion.sql +19 -0
- package/template/backend/drizzle/meta/0000_snapshot.json +137 -0
- package/template/backend/drizzle/meta/_journal.json +13 -0
- package/template/backend/drizzle.config.ts +11 -0
- package/template/backend/gitignore +31 -0
- package/template/backend/package.json +34 -0
- package/template/backend/public/.gitkeep +0 -0
- package/template/backend/src/config/.gitkeep +0 -0
- package/template/backend/src/config/env.ts +22 -0
- package/template/backend/src/domains/auth/.gitkeep +0 -0
- package/template/backend/src/domains/auth/auth.model.ts +20 -0
- package/template/backend/src/domains/auth/auth.repo.ts +51 -0
- package/template/backend/src/domains/auth/auth.routes.ts +93 -0
- package/template/backend/src/domains/auth/auth.service.test.ts +143 -0
- package/template/backend/src/domains/auth/auth.service.ts +97 -0
- package/template/backend/src/domains/auth/auth.types.ts +7 -0
- package/template/backend/src/domains/user/.gitkeep +0 -0
- package/template/backend/src/domains/user/user.model.ts +14 -0
- package/template/backend/src/domains/user/user.repo.ts +29 -0
- package/template/backend/src/domains/user/user.routes.test.ts +82 -0
- package/template/backend/src/domains/user/user.routes.ts +48 -0
- package/template/backend/src/domains/user/user.service.ts +40 -0
- package/template/backend/src/hono-app.ts +42 -0
- package/template/backend/src/index.ts +13 -0
- package/template/backend/src/shared/db/.gitkeep +0 -0
- package/template/backend/src/shared/db/index.ts +11 -0
- package/template/backend/src/shared/logger/logger.ts +15 -0
- package/template/backend/src/shared/middlewares/.gitkeep +0 -0
- package/template/backend/src/shared/middlewares/rate-limit.ts +43 -0
- package/template/backend/src/shared/middlewares/require-auth.test.ts +51 -0
- package/template/backend/src/shared/middlewares/require-auth.ts +31 -0
- package/template/backend/tsconfig.json +16 -0
- package/template/backend/vercel.json +6 -0
- package/template/frontend/AGENTS.md +5 -0
- package/template/frontend/CLAUDE.md +1 -0
- package/template/frontend/README.md +36 -0
- package/template/frontend/app/(auth)/login/page.tsx +24 -0
- package/template/frontend/app/(auth)/register/page.tsx +24 -0
- package/template/frontend/app/dashboard/page.tsx +29 -0
- package/template/frontend/app/globals.css +131 -0
- package/template/frontend/app/icon.png +0 -0
- package/template/frontend/app/layout.tsx +36 -0
- package/template/frontend/app/page.tsx +33 -0
- package/template/frontend/app/profile/page.tsx +23 -0
- package/template/frontend/components/ui/button.tsx +67 -0
- package/template/frontend/components/ui/card.tsx +103 -0
- package/template/frontend/components/ui/input.tsx +19 -0
- package/template/frontend/components/ui/label.tsx +24 -0
- package/template/frontend/components.json +24 -0
- package/template/frontend/domains/auth/components/.gitkeep +0 -0
- package/template/frontend/domains/auth/components/login-form.tsx +75 -0
- package/template/frontend/domains/auth/components/logout-button.tsx +30 -0
- package/template/frontend/domains/auth/components/register-form.tsx +87 -0
- package/template/frontend/domains/auth/hooks/.gitkeep +0 -0
- package/template/frontend/domains/auth/hooks/use-login.ts +17 -0
- package/template/frontend/domains/auth/hooks/use-register.ts +17 -0
- package/template/frontend/domains/auth/services/.gitkeep +0 -0
- package/template/frontend/domains/auth/services/auth.api.ts +27 -0
- package/template/frontend/domains/user/components/.gitkeep +0 -0
- package/template/frontend/domains/user/components/profile-card.tsx +33 -0
- package/template/frontend/domains/user/hooks/use-profile.ts +39 -0
- package/template/frontend/domains/user/services/user.api.ts +21 -0
- package/template/frontend/eslint.config.mjs +18 -0
- package/template/frontend/gitignore +43 -0
- package/template/frontend/lib/utils.ts +11 -0
- package/template/frontend/next.config.ts +7 -0
- package/template/frontend/package.json +35 -0
- package/template/frontend/postcss.config.mjs +7 -0
- package/template/frontend/public/favicon.ico +0 -0
- package/template/frontend/public/logo.png +0 -0
- package/template/frontend/shared/components/require-auth.tsx +25 -0
- package/template/frontend/shared/components/ui/.gitkeep +0 -0
- package/template/frontend/shared/hooks/.gitkeep +0 -0
- package/template/frontend/shared/hooks/use-auth-mutation.ts +37 -0
- package/template/frontend/shared/lib/.gitkeep +0 -0
- package/template/frontend/shared/lib/api-client.ts +73 -0
- package/template/frontend/tsconfig.json +34 -0
- package/template/gitignore +35 -0
- package/template/package.json +13 -0
- package/template/packages/shared/package.json +24 -0
- package/template/packages/shared/src/index.ts +2 -0
- package/template/packages/shared/src/schemas/auth.schema.ts +20 -0
- package/template/packages/shared/src/schemas/user.schema.ts +16 -0
- package/template/packages/shared/tsconfig.json +51 -0
- 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'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
|
+
}
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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,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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
+
}
|