@dennisrongo/skills 0.1.3 → 0.2.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/README.md +23 -13
- package/package.json +1 -1
- package/skills/nextjs-app-router/SKILL.md +228 -175
- package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
- package/skills/nextjs-app-router/references/folder-layout.md +79 -46
- package/skills/nextjs-app-router/references/good-patterns.md +233 -99
- package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
- package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
- package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
- package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
- package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
- package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
- package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
- package/skills/nextjs-app-router/references/templates/package.md +35 -5
- package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
- package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
- package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
- package/skills/nextjs-app-router/references/templates/testing.md +105 -31
|
@@ -1,95 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NextAuth integration (replaces the old auth slice)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
There is **no `authSlice.ts`** in this project. NextAuth's `SessionProvider` is the single source of session truth. This file documents how the pieces fit together so nobody is tempted to reintroduce a redundant Redux slice.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
5
|
+
## Where session state lives
|
|
6
|
+
|
|
7
|
+
| Need | Source |
|
|
8
|
+
|------|--------|
|
|
9
|
+
| "Is the user signed in?" (client) | `useSession()` from `next-auth/react` |
|
|
10
|
+
| User id, email, role (client) | `useSession().data.user` |
|
|
11
|
+
| Server-side session inside a Route Handler | `await auth()` (or `requireSession()`) |
|
|
12
|
+
| Server-side session inside a server component (layouts only) | `await auth()` — **but layouts should not need this; middleware already gated** |
|
|
13
|
+
| Sign-in trigger | `signIn('credentials', { email, password, redirect: false })` from `next-auth/react` |
|
|
14
|
+
| Sign-out trigger | `signOut({ callbackUrl: '/auth/login', redirect: true })` |
|
|
15
|
+
| Auth gate at the network boundary | `middleware.ts` |
|
|
16
|
+
|
|
17
|
+
## Sign-in (Credentials provider)
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// src/app/(public)/auth/login/_components/LoginForm.tsx
|
|
21
|
+
'use client';
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
import { signIn } from 'next-auth/react';
|
|
24
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
25
|
+
|
|
26
|
+
// inside onSubmit:
|
|
27
|
+
const res = await signIn('credentials', { email, password, redirect: false });
|
|
28
|
+
if (res?.error) {
|
|
29
|
+
// surface "Invalid email or password." — don't leak which field was wrong
|
|
30
|
+
return;
|
|
20
31
|
}
|
|
32
|
+
router.replace(searchParams.get('returnTo') ?? '/app/dashboard');
|
|
33
|
+
router.refresh();
|
|
34
|
+
```
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
36
|
+
## Sign-out
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
'use client';
|
|
40
|
+
import { signOut } from 'next-auth/react';
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
reducers: {
|
|
32
|
-
logout: () => initialState,
|
|
33
|
-
setUser: (state, action: PayloadAction<AuthUser>) => {
|
|
34
|
-
state.user = action.payload;
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
extraReducers: (builder) => {
|
|
38
|
-
builder.addMatcher(authApi.endpoints.login.matchFulfilled, (state, { payload }) => {
|
|
39
|
-
state.user = payload.user;
|
|
40
|
-
state.token = payload.token ?? null;
|
|
41
|
-
state.expiresAt = payload.expiresAt ?? null;
|
|
42
|
-
});
|
|
43
|
-
builder.addMatcher(authApi.endpoints.getMe.matchFulfilled, (state, { payload }) => {
|
|
44
|
-
state.user = payload;
|
|
45
|
-
});
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
export const { logout, setUser } = authSlice.actions;
|
|
42
|
+
<Button onClick={() => signOut({ callbackUrl: '/auth/login', redirect: true })}>
|
|
43
|
+
Sign out
|
|
44
|
+
</Button>
|
|
50
45
|
```
|
|
51
46
|
|
|
52
|
-
|
|
47
|
+
The Redux store's `signOutEvent` is also dispatched from the RTK Query base on 401 (see [`api-base.md`](api-base.md)) to clear cached queries. For a user-initiated sign-out, that happens implicitly when the next request 401s — but if you want to clear the cache immediately on click, dispatch `signOutEvent` before calling `signOut`:
|
|
53
48
|
|
|
54
|
-
|
|
49
|
+
```tsx
|
|
50
|
+
import { useDispatch } from 'react-redux';
|
|
51
|
+
import { signOutEvent } from '@/redux/store';
|
|
52
|
+
|
|
53
|
+
const dispatch = useDispatch();
|
|
54
|
+
const onSignOut = () => {
|
|
55
|
+
dispatch(signOutEvent);
|
|
56
|
+
void signOut({ callbackUrl: '/auth/login', redirect: true });
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Module augmentation — `src/types/next-auth.d.ts`
|
|
61
|
+
|
|
62
|
+
Without this, `session.user.id` is `undefined` in TypeScript even though the runtime callback puts it there.
|
|
55
63
|
|
|
56
64
|
```ts
|
|
57
|
-
import
|
|
58
|
-
import
|
|
65
|
+
import 'next-auth';
|
|
66
|
+
import 'next-auth/jwt';
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
declare module 'next-auth' {
|
|
69
|
+
interface Session {
|
|
70
|
+
user: {
|
|
71
|
+
id: string;
|
|
72
|
+
email: string;
|
|
73
|
+
name?: string | null;
|
|
74
|
+
image?: string | null;
|
|
75
|
+
role?: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
expiresAt?: number;
|
|
79
|
+
interface User {
|
|
80
|
+
id: string;
|
|
81
|
+
role?: string;
|
|
82
|
+
}
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
logout: build.mutation<void, void>({
|
|
79
|
-
query: () => ({ url: '/auth/logout', method: 'POST' }),
|
|
80
|
-
invalidatesTags: ['Auth'],
|
|
81
|
-
}),
|
|
82
|
-
getMe: build.query<AuthUser, void>({
|
|
83
|
-
query: () => '/auth/me',
|
|
84
|
-
providesTags: ['Auth'],
|
|
85
|
-
}),
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
export const { useLoginMutation, useLogoutMutation, useGetMeQuery } = authApi;
|
|
85
|
+
declare module 'next-auth/jwt' {
|
|
86
|
+
interface JWT {
|
|
87
|
+
id: string;
|
|
88
|
+
role?: string;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
-
|
|
93
|
+
## Forbidden
|
|
94
|
+
|
|
95
|
+
- A Redux `authSlice` storing `user`, `token`, or `expiresAt`. Use `useSession()`.
|
|
96
|
+
- Manual `js-cookie` writes (`Cookies.set('token', ...)`). NextAuth owns the session cookie and it's `httpOnly` by design.
|
|
97
|
+
- Decoding the NextAuth JWT on the client. Use the hook.
|
|
98
|
+
- Calling `await auth()` inside a server `layout.tsx` "just to check". Middleware already gated. Calling `auth()` again is a round-trip with no behavior change.
|
|
99
|
+
- A second auth library (`Clerk`, `Lucia`, custom JWT signer) running alongside NextAuth.
|
|
@@ -18,6 +18,27 @@ jobs:
|
|
|
18
18
|
build:
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
timeout-minutes: 15
|
|
21
|
+
|
|
22
|
+
services:
|
|
23
|
+
postgres:
|
|
24
|
+
image: postgres:16
|
|
25
|
+
env:
|
|
26
|
+
POSTGRES_USER: postgres
|
|
27
|
+
POSTGRES_PASSWORD: postgres
|
|
28
|
+
POSTGRES_DB: {{project_db}}_test
|
|
29
|
+
ports:
|
|
30
|
+
- 5432:5432
|
|
31
|
+
options: >-
|
|
32
|
+
--health-cmd "pg_isready -U postgres"
|
|
33
|
+
--health-interval 10s
|
|
34
|
+
--health-timeout 5s
|
|
35
|
+
--health-retries 5
|
|
36
|
+
|
|
37
|
+
env:
|
|
38
|
+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/{{project_db}}_test
|
|
39
|
+
AUTH_SECRET: ci-only-secret-replace-in-prod-1234567890abcdef
|
|
40
|
+
AUTH_URL: http://localhost:3000
|
|
41
|
+
|
|
21
42
|
steps:
|
|
22
43
|
- uses: actions/checkout@v4
|
|
23
44
|
|
|
@@ -32,12 +53,14 @@ jobs:
|
|
|
32
53
|
|
|
33
54
|
- run: pnpm install --frozen-lockfile
|
|
34
55
|
|
|
56
|
+
# Apply committed migrations against the CI Postgres. Never `prisma db push` here.
|
|
57
|
+
- run: pnpm exec prisma migrate deploy
|
|
58
|
+
- run: pnpm exec prisma generate
|
|
59
|
+
|
|
35
60
|
- run: pnpm lint --max-warnings=0
|
|
36
61
|
- run: pnpm typecheck
|
|
37
62
|
- run: pnpm test
|
|
38
63
|
- run: pnpm build
|
|
39
|
-
env:
|
|
40
|
-
NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
|
|
41
64
|
|
|
42
65
|
# Optional separate job for E2E — only run on push to main to save CI minutes.
|
|
43
66
|
e2e:
|
|
@@ -45,6 +68,27 @@ jobs:
|
|
|
45
68
|
runs-on: ubuntu-latest
|
|
46
69
|
timeout-minutes: 20
|
|
47
70
|
needs: build
|
|
71
|
+
|
|
72
|
+
services:
|
|
73
|
+
postgres:
|
|
74
|
+
image: postgres:16
|
|
75
|
+
env:
|
|
76
|
+
POSTGRES_USER: postgres
|
|
77
|
+
POSTGRES_PASSWORD: postgres
|
|
78
|
+
POSTGRES_DB: {{project_db}}_e2e
|
|
79
|
+
ports:
|
|
80
|
+
- 5432:5432
|
|
81
|
+
options: >-
|
|
82
|
+
--health-cmd "pg_isready -U postgres"
|
|
83
|
+
--health-interval 10s
|
|
84
|
+
--health-timeout 5s
|
|
85
|
+
--health-retries 5
|
|
86
|
+
|
|
87
|
+
env:
|
|
88
|
+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/{{project_db}}_e2e
|
|
89
|
+
AUTH_SECRET: ci-only-secret-replace-in-prod-1234567890abcdef
|
|
90
|
+
AUTH_URL: http://localhost:3000
|
|
91
|
+
|
|
48
92
|
steps:
|
|
49
93
|
- uses: actions/checkout@v4
|
|
50
94
|
- uses: pnpm/action-setup@v4
|
|
@@ -52,16 +96,22 @@ jobs:
|
|
|
52
96
|
- uses: actions/setup-node@v4
|
|
53
97
|
with: { node-version: 20, cache: pnpm }
|
|
54
98
|
- run: pnpm install --frozen-lockfile
|
|
99
|
+
- run: pnpm exec prisma migrate deploy
|
|
100
|
+
- run: pnpm exec prisma generate
|
|
101
|
+
- run: pnpm db:seed
|
|
55
102
|
- run: pnpm exec playwright install --with-deps chromium
|
|
56
103
|
- run: pnpm test:e2e
|
|
57
|
-
env:
|
|
58
|
-
NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
|
|
59
104
|
```
|
|
60
105
|
|
|
61
106
|
**Notes:**
|
|
107
|
+
|
|
62
108
|
- `--frozen-lockfile` ensures the lockfile is honored — CI fails if `package.json` and lockfile drift.
|
|
63
109
|
- `--max-warnings=0` on lint means even warnings fail CI. On a fresh scaffold there are none; this catches regressions early.
|
|
64
|
-
-
|
|
110
|
+
- `AUTH_SECRET` in CI is a throwaway. **Do not** reuse it in production. The placeholder above is long enough to satisfy the `z.string().min(32)` check in `src/config/env.ts`.
|
|
111
|
+
- `prisma migrate deploy` applies committed migrations. **Never** `prisma db push` in CI — it bypasses the migration history.
|
|
112
|
+
- `prisma generate` runs after `migrate deploy` so `@prisma/client` matches the schema. (`migrate deploy` does not regenerate the client; `migrate dev` does, but `migrate dev` is interactive and not for CI.)
|
|
113
|
+
- The E2E job seeds a test user via `pnpm db:seed` so the Credentials login flow has something to authenticate against.
|
|
114
|
+
- E2E is gated on `github.event_name == 'push'` so PRs stay fast.
|
|
65
115
|
|
|
66
116
|
## Husky + lint-staged
|
|
67
117
|
|
|
@@ -85,10 +135,12 @@ The `lint-staged` config lives in `package.json` (see [`package.md`](package.md)
|
|
|
85
135
|
```jsonc
|
|
86
136
|
"lint-staged": {
|
|
87
137
|
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
|
|
88
|
-
"*.{json,md,css,yml,yaml}": ["prettier --write"]
|
|
138
|
+
"*.{json,md,css,yml,yaml,prisma}": ["prettier --write"]
|
|
89
139
|
}
|
|
90
140
|
```
|
|
91
141
|
|
|
92
142
|
**Notes:**
|
|
143
|
+
|
|
93
144
|
- Do not put `tsc --noEmit` in lint-staged — it's per-staged-file and TypeScript is project-wide; let CI handle it.
|
|
94
145
|
- Do not skip hooks (`--no-verify`) as a workflow. If a hook is annoying, fix the underlying issue.
|
|
146
|
+
- `prettier-plugin-prisma` (auto-loaded when present in deps) formats `schema.prisma` on commit.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Prisma client (singleton)
|
|
2
|
+
|
|
3
|
+
## `src/lib/db.ts`
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { PrismaClient } from '@prisma/client';
|
|
7
|
+
|
|
8
|
+
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
|
9
|
+
|
|
10
|
+
export const db =
|
|
11
|
+
globalForPrisma.prisma ??
|
|
12
|
+
new PrismaClient({
|
|
13
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
17
|
+
globalForPrisma.prisma = db;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why a singleton
|
|
22
|
+
|
|
23
|
+
Next.js dev hot-reload re-evaluates modules on every change. Without the global cache, each reload constructs a fresh `PrismaClient`, opens a new connection pool, and never closes the old one — within minutes of editing you exhaust your Postgres `max_connections` and start seeing `FATAL: sorry, too many clients already`.
|
|
24
|
+
|
|
25
|
+
The pattern above:
|
|
26
|
+
- In **production**, behaves like a normal module-scoped singleton (one client per process).
|
|
27
|
+
- In **dev**, attaches the client to `globalThis` so hot-reloads reuse it.
|
|
28
|
+
|
|
29
|
+
## Forbidden
|
|
30
|
+
|
|
31
|
+
- `import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient()` anywhere outside this file. Every consumer imports `db` from `@/lib/db`.
|
|
32
|
+
- Importing `db` from `src/auth.config.ts` (Edge runtime — can't import Prisma).
|
|
33
|
+
- Importing `db` from any `src/app/**` file outside `route.ts` files. Server pages and layouts do not touch the database.
|
|
34
|
+
- Calling `db.$connect()` / `db.$disconnect()` manually in handlers. Prisma manages the pool. The only place a manual `$disconnect()` is appropriate is at the bottom of `prisma/seed.ts`.
|
|
35
|
+
|
|
36
|
+
## Where `db` is allowed to be imported
|
|
37
|
+
|
|
38
|
+
| File pattern | Allowed? |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `src/app/api/**/route.ts` | ✅ Yes |
|
|
41
|
+
| `src/auth.ts` | ✅ Yes (Credentials `authorize` reads user table) |
|
|
42
|
+
| `prisma/seed.ts` | ✅ Yes (uses `new PrismaClient()` directly; doesn't share with the app) |
|
|
43
|
+
| `src/lib/*.ts` (server-only helpers) | ✅ Yes |
|
|
44
|
+
| `src/auth.config.ts` | ❌ No — Edge runtime |
|
|
45
|
+
| `middleware.ts` | ❌ No — Edge runtime |
|
|
46
|
+
| `src/app/**/page.tsx` / `layout.tsx` (any non-`route.ts`) | ❌ No — pages/layouts don't fetch data; RTK Query → /api → db |
|
|
47
|
+
| `src/redux/**` | ❌ No — Redux runs in the browser |
|
|
48
|
+
| `src/components/**` | ❌ No |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Env validation, `cn()`,
|
|
1
|
+
# Env validation, `cn()`, shared utils
|
|
2
2
|
|
|
3
3
|
## `src/config/env.ts` (runtime-validated env)
|
|
4
4
|
|
|
@@ -6,16 +6,23 @@
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
|
|
8
8
|
const envSchema = z.object({
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// NextAuth — required.
|
|
10
|
+
AUTH_SECRET: z.string().min(32, 'AUTH_SECRET must be at least 32 chars. Generate with: openssl rand -base64 32'),
|
|
11
|
+
AUTH_URL: z.string().url().optional(), // required in prod, derived locally
|
|
12
|
+
|
|
13
|
+
// Database — required.
|
|
14
|
+
DATABASE_URL: z.string().url(),
|
|
15
|
+
|
|
16
|
+
// OAuth providers — required ONLY if the matching provider is enabled in auth.config.ts.
|
|
17
|
+
AUTH_GITHUB_ID: z.string().optional(),
|
|
18
|
+
AUTH_GITHUB_SECRET: z.string().optional(),
|
|
19
|
+
AUTH_GOOGLE_ID: z.string().optional(),
|
|
20
|
+
AUTH_GOOGLE_SECRET: z.string().optional(),
|
|
11
21
|
});
|
|
12
22
|
|
|
13
23
|
function loadEnv() {
|
|
14
|
-
const parsed = envSchema.safeParse(
|
|
15
|
-
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
16
|
-
});
|
|
24
|
+
const parsed = envSchema.safeParse(process.env);
|
|
17
25
|
if (!parsed.success) {
|
|
18
|
-
// Fail fast at module load.
|
|
19
26
|
console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors);
|
|
20
27
|
throw new Error('Invalid environment configuration. See .env.example for required variables.');
|
|
21
28
|
}
|
|
@@ -25,23 +32,31 @@ function loadEnv() {
|
|
|
25
32
|
export const env = loadEnv();
|
|
26
33
|
```
|
|
27
34
|
|
|
28
|
-
**Why:** misconfiguration (`
|
|
35
|
+
**Why:** misconfiguration (missing `AUTH_SECRET`, malformed `DATABASE_URL`) blows up at startup with a clear error, not via a confusing 500 on the first auth check or DB query.
|
|
29
36
|
|
|
30
|
-
## `.env.example` (committed)
|
|
37
|
+
## `.env.example` (committed — NO secret values)
|
|
31
38
|
|
|
32
|
-
```
|
|
33
|
-
#
|
|
34
|
-
|
|
39
|
+
```dotenv
|
|
40
|
+
# ---- NextAuth ----
|
|
41
|
+
# Generate with: openssl rand -base64 32 (or `npx auth secret`)
|
|
42
|
+
# REQUIRED. Do NOT commit a real value.
|
|
43
|
+
AUTH_SECRET=
|
|
44
|
+
|
|
45
|
+
# Public URL of the app. Required in production. Optional locally (Auth.js derives from the request).
|
|
46
|
+
# AUTH_URL=https://example.com
|
|
35
47
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
48
|
+
# ---- Database ----
|
|
49
|
+
# Required. For local Docker: postgresql://postgres:postgres@localhost:5432/{{project_db}}
|
|
50
|
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{project_db}}
|
|
39
51
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
52
|
+
# ---- OAuth providers (only if enabled in src/auth.config.ts) ----
|
|
53
|
+
# AUTH_GITHUB_ID=
|
|
54
|
+
# AUTH_GITHUB_SECRET=
|
|
55
|
+
# AUTH_GOOGLE_ID=
|
|
56
|
+
# AUTH_GOOGLE_SECRET=
|
|
42
57
|
```
|
|
43
58
|
|
|
44
|
-
`.env`, `.env.local`, `.env.*.local` go in `.gitignore`. Only `.env.example` is committed.
|
|
59
|
+
`.env`, `.env.local`, `.env.*.local` go in `.gitignore`. Only `.env.example` is committed. **`AUTH_SECRET` must never have a real value in `.env.example`** — leave it empty with a comment showing how to generate one.
|
|
45
60
|
|
|
46
61
|
## `src/lib/utils.ts`
|
|
47
62
|
|
|
@@ -56,6 +71,61 @@ export function cn(...inputs: ClassValue[]): string {
|
|
|
56
71
|
|
|
57
72
|
That's the entire file. No bag of unrelated helpers — those go in `src/lib/formatters/` or feature-local utility files.
|
|
58
73
|
|
|
74
|
+
## `src/lib/api-auth.ts` (Route Handler auth helper)
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { NextResponse } from 'next/server';
|
|
78
|
+
import { auth } from '@/auth';
|
|
79
|
+
|
|
80
|
+
export class HttpError extends Error {
|
|
81
|
+
constructor(public status: number, public body: unknown) {
|
|
82
|
+
super(typeof body === 'string' ? body : JSON.stringify(body));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the current session, or throws an HttpError(401) that the wrapping
|
|
88
|
+
* handler converts to a 401 JSON response. Use inside Route Handlers.
|
|
89
|
+
*/
|
|
90
|
+
export async function requireSession() {
|
|
91
|
+
const session = await auth();
|
|
92
|
+
if (!session?.user?.id) {
|
|
93
|
+
throw new HttpError(401, { error: 'Unauthorized' });
|
|
94
|
+
}
|
|
95
|
+
return session as typeof session & { user: { id: string; email: string; role?: string } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wraps a Route Handler so that thrown HttpErrors become JSON responses.
|
|
100
|
+
* Optional — handlers can also try/catch themselves.
|
|
101
|
+
*/
|
|
102
|
+
export function withApiErrors<T extends (...args: never[]) => Promise<Response | NextResponse>>(
|
|
103
|
+
fn: T,
|
|
104
|
+
): T {
|
|
105
|
+
return (async (...args: Parameters<T>) => {
|
|
106
|
+
try {
|
|
107
|
+
return await fn(...args);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof HttpError) {
|
|
110
|
+
return NextResponse.json(err.body, { status: err.status });
|
|
111
|
+
}
|
|
112
|
+
console.error('Unhandled handler error:', err);
|
|
113
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
114
|
+
}
|
|
115
|
+
}) as T;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Asserts that a fetched row belongs to the current user. Returns the row.
|
|
120
|
+
*/
|
|
121
|
+
export function assertOwnership<T extends { userId: string }>(row: T | null, userId: string): T {
|
|
122
|
+
if (!row || row.userId !== userId) {
|
|
123
|
+
throw new HttpError(404, { error: 'Not found' });
|
|
124
|
+
}
|
|
125
|
+
return row;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
59
129
|
## `src/lib/formatters/date.ts`
|
|
60
130
|
|
|
61
131
|
```ts
|
|
@@ -99,12 +169,9 @@ export function UnsavedChangesWarning({ when, message = 'You have unsaved change
|
|
|
99
169
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
100
170
|
}, [when, message]);
|
|
101
171
|
|
|
102
|
-
// Next
|
|
103
|
-
//
|
|
104
|
-
// resolved at scaffold time (use context7 to check whether the official `useBeforeUnload` / `unstable_*` API has landed).
|
|
172
|
+
// Next App Router does not yet expose a stable router-events API for intercepting client-side navigation.
|
|
173
|
+
// Verify at scaffold time via context7 whether a stable hook has landed; if so, use it here in addition to beforeunload.
|
|
105
174
|
|
|
106
175
|
return null;
|
|
107
176
|
}
|
|
108
177
|
```
|
|
109
|
-
|
|
110
|
-
**Adjust at scaffold time:** if the Next.js version resolved in Step 1 exposes a stable navigation-intercept hook, use it instead of the `beforeunload`-only fallback. Verify via context7.
|