@dennisrongo/skills 0.1.2 → 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 +45 -16
- 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
- package/skills/tauri-2-app/SKILL.md +381 -0
- package/skills/tauri-2-app/references/anti-patterns.md +434 -0
- package/skills/tauri-2-app/references/folder-layout.md +161 -0
- package/skills/tauri-2-app/references/good-patterns.md +477 -0
- package/skills/tauri-2-app/references/templates/build-rs.md +51 -0
- package/skills/tauri-2-app/references/templates/capabilities.md +68 -0
- package/skills/tauri-2-app/references/templates/cargo-toml.md +118 -0
- package/skills/tauri-2-app/references/templates/ci-workflow.md +99 -0
- package/skills/tauri-2-app/references/templates/encryption-mod.md +228 -0
- package/skills/tauri-2-app/references/templates/error-mod.md +126 -0
- package/skills/tauri-2-app/references/templates/lib-rs.md +98 -0
- package/skills/tauri-2-app/references/templates/macos-plist.md +89 -0
- package/skills/tauri-2-app/references/templates/main-rs.md +21 -0
- package/skills/tauri-2-app/references/templates/platform-traits.md +217 -0
- package/skills/tauri-2-app/references/templates/storage-mod.md +172 -0
- package/skills/tauri-2-app/references/templates/tauri-conf.md +136 -0
- package/skills/tauri-2-app/references/templates/use-tauri-command.md +194 -0
- package/skills/tauri-2-app/references/templates/vite-and-tsconfig.md +189 -0
|
@@ -1,71 +1,66 @@
|
|
|
1
1
|
# Feature slice template (Mode 2 — `add-feature`)
|
|
2
2
|
|
|
3
|
-
A feature is a route folder under a route group, with its own private `_components/` and `_hooks/`, a local Zod `schema.ts
|
|
3
|
+
A feature is a route folder under a route group, with its own private `_components/` and `_hooks/`, a local Zod `schema.ts` (shared with the matching Route Handler), and one or more RTK Query endpoints injected into the appropriate domain slice. **Every `page.tsx` is `'use client'`**.
|
|
4
4
|
|
|
5
|
-
## Layout for feature `{{feature}}` under `(app)`
|
|
5
|
+
## Layout for feature `{{feature}}` under `(app)` (list + create + edit)
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
src/app/(app)/{{feature}}/
|
|
9
|
-
├── page.tsx #
|
|
10
|
-
├──
|
|
11
|
-
├── error.tsx # optional but recommended
|
|
12
|
-
├── schema.ts # Zod schemas owned by this feature
|
|
13
|
-
├── _components/
|
|
14
|
-
│ ├── {{Feature}}Table.tsx # 'use client' if it has interactions
|
|
15
|
-
│ └── {{Feature}}Form.tsx # 'use client'
|
|
16
|
-
└── _hooks/
|
|
17
|
-
└── use{{Feature}}Filters.ts
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
For a feature with sub-routes (list + detail + new):
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
src/app/(app)/{{feature}}/
|
|
24
|
-
├── page.tsx # list
|
|
25
|
-
├── new/
|
|
26
|
-
│ └── page.tsx # SERVER renders <{{Feature}}Form mode="create" />
|
|
8
|
+
src/app/(app)/{{feature}}s/
|
|
9
|
+
├── page.tsx # 'use client' — list view, uses useGet{{Feature}}sQuery
|
|
10
|
+
├── new/page.tsx # 'use client' — renders <{{Feature}}Form mode="create" />
|
|
27
11
|
├── [id]/
|
|
28
|
-
│ ├── page.tsx #
|
|
12
|
+
│ ├── page.tsx # 'use client' — renders <{{Feature}}Form mode="edit" id={params.id} />
|
|
29
13
|
│ ├── loading.tsx
|
|
30
|
-
│ └── _components/
|
|
31
|
-
├──
|
|
14
|
+
│ └── _components/
|
|
15
|
+
├── loading.tsx
|
|
16
|
+
├── error.tsx
|
|
17
|
+
├── schema.ts # Zod — SHARED with src/app/api/{{feature}}/route.ts
|
|
32
18
|
├── _components/
|
|
33
|
-
│ ├── {{Feature}}
|
|
19
|
+
│ ├── {{Feature}}sTable.tsx
|
|
34
20
|
│ └── {{Feature}}Form.tsx
|
|
35
21
|
└── _hooks/
|
|
22
|
+
└── use{{Feature}}Filters.ts
|
|
23
|
+
|
|
24
|
+
src/app/api/{{feature}}s/
|
|
25
|
+
├── route.ts # GET (list) + POST (create)
|
|
26
|
+
└── [id]/route.ts # GET + PATCH + DELETE
|
|
36
27
|
```
|
|
37
28
|
|
|
38
|
-
## `page.tsx` for the list view (
|
|
29
|
+
## `page.tsx` for the list view (CLIENT)
|
|
39
30
|
|
|
40
31
|
```tsx
|
|
41
|
-
|
|
42
|
-
import { {{Feature}}Table } from './_components/{{Feature}}Table';
|
|
32
|
+
'use client';
|
|
43
33
|
|
|
44
|
-
|
|
34
|
+
import Link from 'next/link';
|
|
35
|
+
import { Button } from '@/components/ui/button';
|
|
36
|
+
import { {{Feature}}sTable } from './_components/{{Feature}}sTable';
|
|
45
37
|
|
|
46
38
|
export default function {{Feature}}sPage() {
|
|
47
39
|
return (
|
|
48
40
|
<section className="space-y-4">
|
|
49
41
|
<header className="flex items-center justify-between">
|
|
50
42
|
<h1 className="text-2xl font-semibold">{{Feature}}s</h1>
|
|
43
|
+
<Button asChild>
|
|
44
|
+
<Link href="/app/{{feature}}s/new">New {{feature}}</Link>
|
|
45
|
+
</Button>
|
|
51
46
|
</header>
|
|
52
|
-
<
|
|
53
|
-
<{{Feature}}Table />
|
|
54
|
-
</Suspense>
|
|
47
|
+
<{{Feature}}sTable />
|
|
55
48
|
</section>
|
|
56
49
|
);
|
|
57
50
|
}
|
|
58
51
|
```
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
Per-route metadata for client pages lives at the layout level (server). If this feature needs a specific `<title>`, lift it into a `(app)/{{feature}}s/layout.tsx` server component with a `metadata` export.
|
|
54
|
+
|
|
55
|
+
## `_components/{{Feature}}sTable.tsx` (CLIENT)
|
|
61
56
|
|
|
62
57
|
```tsx
|
|
63
58
|
'use client';
|
|
64
59
|
|
|
65
|
-
import {
|
|
60
|
+
import { useGet{{Feature}}sQuery } from '@/redux/api/{{feature}}sApi';
|
|
66
61
|
|
|
67
62
|
export function {{Feature}}sTable() {
|
|
68
|
-
const { data, isLoading, error } =
|
|
63
|
+
const { data, isLoading, error } = useGet{{Feature}}sQuery();
|
|
69
64
|
|
|
70
65
|
if (isLoading) return <p className="text-muted-foreground">Loading…</p>;
|
|
71
66
|
if (error) return <p className="text-destructive">Failed to load.</p>;
|
|
@@ -92,7 +87,7 @@ export function {{Feature}}sTable() {
|
|
|
92
87
|
}
|
|
93
88
|
```
|
|
94
89
|
|
|
95
|
-
## `schema.ts`
|
|
90
|
+
## `schema.ts` (SHARED with Route Handler)
|
|
96
91
|
|
|
97
92
|
```ts
|
|
98
93
|
import { z } from 'zod';
|
|
@@ -105,6 +100,44 @@ export const {{feature}}Schema = z.object({
|
|
|
105
100
|
export type {{Feature}}FormValues = z.infer<typeof {{feature}}Schema>;
|
|
106
101
|
```
|
|
107
102
|
|
|
103
|
+
The matching `src/app/api/{{feature}}s/route.ts` imports `{{feature}}Schema` and calls `{{feature}}Schema.safeParse(await req.json())`. One schema, two sides.
|
|
104
|
+
|
|
105
|
+
## `new/page.tsx` (CLIENT)
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
'use client';
|
|
109
|
+
|
|
110
|
+
import { {{Feature}}Form } from '../_components/{{Feature}}Form';
|
|
111
|
+
|
|
112
|
+
export default function New{{Feature}}Page() {
|
|
113
|
+
return (
|
|
114
|
+
<section className="space-y-4">
|
|
115
|
+
<h1 className="text-2xl font-semibold">New {{feature}}</h1>
|
|
116
|
+
<{{Feature}}Form mode="create" />
|
|
117
|
+
</section>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## `[id]/page.tsx` (CLIENT)
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
'use client';
|
|
126
|
+
|
|
127
|
+
import { useParams } from 'next/navigation';
|
|
128
|
+
import { {{Feature}}Form } from '../_components/{{Feature}}Form';
|
|
129
|
+
|
|
130
|
+
export default function Edit{{Feature}}Page() {
|
|
131
|
+
const params = useParams<{ id: string }>();
|
|
132
|
+
return (
|
|
133
|
+
<section className="space-y-4">
|
|
134
|
+
<h1 className="text-2xl font-semibold">Edit {{feature}}</h1>
|
|
135
|
+
<{{Feature}}Form mode="edit" id={params.id} />
|
|
136
|
+
</section>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
108
141
|
## `_components/{{Feature}}Form.tsx` (CLIENT)
|
|
109
142
|
|
|
110
143
|
```tsx
|
|
@@ -118,25 +151,47 @@ import { Button } from '@/components/ui/button';
|
|
|
118
151
|
import { FormInputField } from '@/components/forms/FormInputField';
|
|
119
152
|
import { UnsavedChangesWarning } from '@/components/UnsavedChangesWarning';
|
|
120
153
|
import { useToast } from '@/components/ui/use-toast';
|
|
121
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
useCreate{{Feature}}Mutation,
|
|
156
|
+
useUpdate{{Feature}}Mutation,
|
|
157
|
+
useGet{{Feature}}Query,
|
|
158
|
+
} from '@/redux/api/{{feature}}sApi';
|
|
122
159
|
import { getDefaultValuesFromSchema, unwrapZodEffects } from '@/lib/zod-utils';
|
|
123
160
|
import { {{feature}}Schema, type {{Feature}}FormValues } from '../schema';
|
|
124
161
|
|
|
125
|
-
|
|
162
|
+
interface Props {
|
|
163
|
+
mode: 'create' | 'edit';
|
|
164
|
+
id?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function {{Feature}}Form({ mode, id }: Props) {
|
|
126
168
|
const router = useRouter();
|
|
127
169
|
const { toast } = useToast();
|
|
128
|
-
|
|
170
|
+
|
|
171
|
+
const { data: existing } = useGet{{Feature}}Query(id ?? '', { skip: mode !== 'edit' || !id });
|
|
172
|
+
const [create, { isLoading: isCreating }] = useCreate{{Feature}}Mutation();
|
|
173
|
+
const [update, { isLoading: isUpdating }] = useUpdate{{Feature}}Mutation();
|
|
129
174
|
|
|
130
175
|
const form = useForm<{{Feature}}FormValues>({
|
|
131
176
|
resolver: zodResolver({{feature}}Schema),
|
|
132
|
-
defaultValues:
|
|
177
|
+
defaultValues:
|
|
178
|
+
existing ??
|
|
179
|
+
(getDefaultValuesFromSchema(unwrapZodEffects({{feature}}Schema)) as {{Feature}}FormValues),
|
|
180
|
+
values: existing,
|
|
133
181
|
});
|
|
134
182
|
|
|
183
|
+
const isSubmitting = isCreating || isUpdating;
|
|
184
|
+
|
|
135
185
|
const onSubmit = async (values: {{Feature}}FormValues) => {
|
|
136
186
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
187
|
+
if (mode === 'create') {
|
|
188
|
+
const created = await create(values).unwrap();
|
|
189
|
+
toast({ title: '{{Feature}} created.' });
|
|
190
|
+
router.replace(`/app/{{feature}}s/${created.id}`);
|
|
191
|
+
} else if (id) {
|
|
192
|
+
await update({ id, patch: values }).unwrap();
|
|
193
|
+
toast({ title: '{{Feature}} updated.' });
|
|
194
|
+
}
|
|
140
195
|
} catch {
|
|
141
196
|
toast({ title: 'Could not save', variant: 'destructive' });
|
|
142
197
|
}
|
|
@@ -149,8 +204,12 @@ export function {{Feature}}Form() {
|
|
|
149
204
|
<FormInputField form={form} schema={{feature}}Schema} fieldName="name" label="Name" />
|
|
150
205
|
<FormInputField form={form} schema={{feature}}Schema} fieldName="email" label="Email" type="email" />
|
|
151
206
|
<div className="flex gap-2">
|
|
152
|
-
<Button type="submit" disabled={
|
|
153
|
-
|
|
207
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
208
|
+
{isSubmitting ? 'Saving…' : 'Save'}
|
|
209
|
+
</Button>
|
|
210
|
+
<Button type="button" variant="ghost" onClick={() => router.back()}>
|
|
211
|
+
Cancel
|
|
212
|
+
</Button>
|
|
154
213
|
</div>
|
|
155
214
|
</form>
|
|
156
215
|
</Form>
|
|
@@ -179,6 +238,10 @@ describe('{{feature}}Schema', () => {
|
|
|
179
238
|
});
|
|
180
239
|
```
|
|
181
240
|
|
|
182
|
-
##
|
|
241
|
+
## Route Handlers
|
|
242
|
+
|
|
243
|
+
Always generate the matching `src/app/api/{{feature}}s/route.ts` and `[id]/route.ts` alongside the client-side feature. See [`route-handler.md`](route-handler.md).
|
|
244
|
+
|
|
245
|
+
## Endpoint additions to the RTK slice
|
|
183
246
|
|
|
184
|
-
If the domain slice doesn't exist yet, run Mode 3
|
|
247
|
+
If the domain slice doesn't exist yet, run Mode 3 (`add-api-slice`). Then add the feature's query/mutation pair — see [`api-slice.md`](api-slice.md).
|
|
@@ -122,10 +122,14 @@ export function FormInputField<TForm extends FieldValues>({
|
|
|
122
122
|
|
|
123
123
|
## A real form — `src/app/(public)/auth/login/_components/LoginForm.tsx`
|
|
124
124
|
|
|
125
|
+
The login form uses NextAuth's `signIn` directly — there is no `useLoginMutation` because auth is not a domain in the RTK Query slice.
|
|
126
|
+
|
|
125
127
|
```tsx
|
|
126
128
|
'use client';
|
|
127
129
|
|
|
130
|
+
import { useState } from 'react';
|
|
128
131
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
132
|
+
import { signIn } from 'next-auth/react';
|
|
129
133
|
import { useForm } from 'react-hook-form';
|
|
130
134
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
131
135
|
import { z } from 'zod';
|
|
@@ -133,22 +137,20 @@ import { z } from 'zod';
|
|
|
133
137
|
import { Form } from '@/components/ui/form';
|
|
134
138
|
import { Button } from '@/components/ui/button';
|
|
135
139
|
import { FormInputField } from '@/components/forms/FormInputField';
|
|
136
|
-
import {
|
|
137
|
-
import { useToast } from '@/components/ui/use-toast';
|
|
140
|
+
import { FormPasswordField } from '@/components/forms/FormPasswordField';
|
|
138
141
|
import { getDefaultValuesFromSchema, unwrapZodEffects } from '@/lib/zod-utils';
|
|
139
142
|
|
|
140
143
|
const loginSchema = z.object({
|
|
141
144
|
email: z.string().email(),
|
|
142
|
-
password: z.string().min(
|
|
145
|
+
password: z.string().min(1, 'Required'),
|
|
143
146
|
});
|
|
144
147
|
type LoginValues = z.infer<typeof loginSchema>;
|
|
145
148
|
|
|
146
149
|
export function LoginForm() {
|
|
147
150
|
const router = useRouter();
|
|
148
151
|
const searchParams = useSearchParams();
|
|
149
|
-
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
|
|
150
|
-
const
|
|
151
|
-
const [login, { isLoading }] = useLoginMutation();
|
|
152
|
+
const returnTo = searchParams.get('returnTo') ?? '/app/dashboard';
|
|
153
|
+
const [error, setError] = useState<string | null>(null);
|
|
152
154
|
|
|
153
155
|
const form = useForm<LoginValues>({
|
|
154
156
|
resolver: zodResolver(loginSchema),
|
|
@@ -156,21 +158,25 @@ export function LoginForm() {
|
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
const onSubmit = async (values: LoginValues) => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
setError(null);
|
|
162
|
+
const res = await signIn('credentials', { ...values, redirect: false });
|
|
163
|
+
if (res?.error) {
|
|
164
|
+
// Don't reveal which field was wrong — security best practice.
|
|
165
|
+
setError('Invalid email or password.');
|
|
166
|
+
return;
|
|
164
167
|
}
|
|
168
|
+
router.replace(returnTo);
|
|
169
|
+
router.refresh();
|
|
165
170
|
};
|
|
166
171
|
|
|
167
172
|
return (
|
|
168
173
|
<Form {...form}>
|
|
169
174
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
170
|
-
|
|
171
|
-
<FormInputField form={form} schema={loginSchema} fieldName="
|
|
172
|
-
<
|
|
173
|
-
|
|
175
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
176
|
+
<FormInputField form={form} schema={loginSchema} fieldName="email" label="Email" type="email" autoComplete="email" />
|
|
177
|
+
<FormPasswordField form={form} schema={loginSchema} fieldName="password" label="Password" autoComplete="current-password" />
|
|
178
|
+
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
|
|
179
|
+
{form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
|
|
174
180
|
</Button>
|
|
175
181
|
</form>
|
|
176
182
|
</Form>
|
|
@@ -178,6 +184,8 @@ export function LoginForm() {
|
|
|
178
184
|
}
|
|
179
185
|
```
|
|
180
186
|
|
|
187
|
+
**Why no `useLoginMutation`:** auth flows go through NextAuth (`signIn` / `signOut`), not through RTK Query. RTK Query is for domain data. Keeping these separate avoids the temptation to re-implement session state inside Redux.
|
|
188
|
+
|
|
181
189
|
## `UnsavedChangesWarning` integration
|
|
182
190
|
|
|
183
191
|
For non-trivial forms, wrap the submit area with the guard:
|
|
@@ -1,88 +1,98 @@
|
|
|
1
|
-
# `middleware.ts` template
|
|
1
|
+
# `middleware.ts` template (NextAuth-driven)
|
|
2
2
|
|
|
3
3
|
This is **the** auth gate. Place at the project root (not in `src/`). Next.js requires this exact filename and location.
|
|
4
4
|
|
|
5
|
-
## Variant A — `
|
|
5
|
+
## Variant A — Plain NextAuth `auth` (covers most projects)
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
// middleware.ts
|
|
9
|
-
import
|
|
9
|
+
import NextAuth from 'next-auth';
|
|
10
|
+
import authConfig from '@/auth.config';
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
-
const AUTH_PATH = '/auth/login';
|
|
12
|
+
export const { auth: middleware } = NextAuth(authConfig);
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// Exclude NextAuth's own API routes and static assets.
|
|
15
|
+
export const config = {
|
|
16
|
+
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
17
|
+
};
|
|
18
|
+
```
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
if (session && pathname.startsWith('/auth')) {
|
|
21
|
-
const url = req.nextUrl.clone();
|
|
22
|
-
url.pathname = '/app/dashboard';
|
|
23
|
-
url.search = '';
|
|
24
|
-
return NextResponse.redirect(url);
|
|
25
|
-
}
|
|
20
|
+
NextAuth's `auth` returns 302s to `pages.signIn` for unauthenticated requests to anything matched by the `authorized` callback in `auth.config.ts`. If `authorized` isn't defined, all matched routes require a session.
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
if (!session && isProtected) {
|
|
29
|
-
const url = req.nextUrl.clone();
|
|
30
|
-
url.pathname = AUTH_PATH;
|
|
31
|
-
url.search = '';
|
|
32
|
-
url.searchParams.set('returnTo', pathname + search);
|
|
33
|
-
return NextResponse.redirect(url);
|
|
34
|
-
}
|
|
22
|
+
The `authorized` callback in `auth.config.ts`:
|
|
35
23
|
|
|
36
|
-
|
|
24
|
+
```ts
|
|
25
|
+
// inside src/auth.config.ts
|
|
26
|
+
callbacks: {
|
|
27
|
+
authorized({ auth, request: { nextUrl } }) {
|
|
28
|
+
const isLoggedIn = !!auth?.user;
|
|
29
|
+
const isOnPublic = nextUrl.pathname.startsWith('/auth');
|
|
30
|
+
if (isOnPublic) {
|
|
31
|
+
return isLoggedIn
|
|
32
|
+
? Response.redirect(new URL('/app/dashboard', nextUrl))
|
|
33
|
+
: true;
|
|
34
|
+
}
|
|
35
|
+
return isLoggedIn; // false → redirected to pages.signIn
|
|
36
|
+
},
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
export const config = {
|
|
40
|
-
// Run on everything except Next internals and static assets.
|
|
41
|
-
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
42
|
-
};
|
|
43
38
|
```
|
|
44
39
|
|
|
45
|
-
## Variant B —
|
|
40
|
+
## Variant B — Custom wrapper for role/path logic
|
|
46
41
|
|
|
47
|
-
|
|
42
|
+
When `(admin)` exists or `/` needs role-based routing:
|
|
48
43
|
|
|
49
44
|
```ts
|
|
50
|
-
|
|
51
|
-
import
|
|
45
|
+
// middleware.ts
|
|
46
|
+
import NextAuth from 'next-auth';
|
|
47
|
+
import { NextResponse } from 'next/server';
|
|
48
|
+
import authConfig from '@/auth.config';
|
|
52
49
|
|
|
53
|
-
const
|
|
54
|
-
const SECRET = new TextEncoder().encode(process.env.JWT_VERIFY_KEY!);
|
|
50
|
+
const { auth } = NextAuth(authConfig);
|
|
55
51
|
|
|
56
|
-
export
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const isProtected = PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
52
|
+
export default auth((req) => {
|
|
53
|
+
const { pathname } = req.nextUrl;
|
|
54
|
+
const session = req.auth;
|
|
60
55
|
|
|
61
|
-
|
|
62
|
-
if (
|
|
56
|
+
// Root redirect
|
|
57
|
+
if (pathname === '/') {
|
|
58
|
+
const url = req.nextUrl.clone();
|
|
59
|
+
url.pathname = session ? '/app/dashboard' : '/auth/login';
|
|
60
|
+
return NextResponse.redirect(url);
|
|
61
|
+
}
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// Admin gate
|
|
64
|
+
if (pathname.startsWith('/admin')) {
|
|
65
|
+
if (!session) {
|
|
66
|
+
const url = req.nextUrl.clone();
|
|
67
|
+
url.pathname = '/auth/login';
|
|
68
|
+
url.searchParams.set('returnTo', pathname);
|
|
69
|
+
return NextResponse.redirect(url);
|
|
70
|
+
}
|
|
71
|
+
if (session.user?.role !== 'admin') {
|
|
72
|
+
return new NextResponse('Forbidden', { status: 403 });
|
|
73
|
+
}
|
|
69
74
|
}
|
|
70
|
-
}
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// App gate
|
|
77
|
+
if (pathname.startsWith('/app') && !session) {
|
|
78
|
+
const url = req.nextUrl.clone();
|
|
79
|
+
url.pathname = '/auth/login';
|
|
80
|
+
url.searchParams.set('returnTo', pathname);
|
|
81
|
+
return NextResponse.redirect(url);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return NextResponse.next();
|
|
85
|
+
});
|
|
78
86
|
|
|
79
87
|
export const config = {
|
|
80
|
-
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
88
|
+
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
81
89
|
};
|
|
82
90
|
```
|
|
83
91
|
|
|
84
92
|
**Notes:**
|
|
85
|
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
93
|
+
|
|
94
|
+
- `auth.config.ts` must be **Edge-safe**: no Prisma adapter, no `@/lib/db`, no Node-only imports. The middleware runs on the Edge runtime; importing Node-only code crashes the production build with a confusing "module not found in edge runtime" error.
|
|
95
|
+
- The matcher excludes `api/auth` so NextAuth's own `/api/auth/[...nextauth]` route is reachable without recursion.
|
|
96
|
+
- Other `/api/**` routes (your Route Handlers) are excluded *from middleware redirects* because the matcher targets pages — handlers run their own `requireSession()` check and return JSON 401s rather than 302s.
|
|
97
|
+
- Role claims: add them in the `jwt` callback inside `auth.config.ts` (or `auth.ts` if the role comes from the DB). Module-augment `Session` and `JWT` in `src/types/next-auth.d.ts`.
|
|
98
|
+
- Do **not** add a `src/proxy.ts` or `src/middleware/*.ts` — Next.js only looks at `middleware.ts` at the project root.
|
|
@@ -3,23 +3,12 @@
|
|
|
3
3
|
```ts
|
|
4
4
|
import type { NextConfig } from 'next';
|
|
5
5
|
|
|
6
|
-
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
7
|
-
|
|
8
6
|
const nextConfig: NextConfig = {
|
|
9
7
|
reactStrictMode: true,
|
|
10
8
|
poweredByHeader: false,
|
|
11
9
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
async rewrites() {
|
|
15
|
-
if (!apiBase) return [];
|
|
16
|
-
return [
|
|
17
|
-
{
|
|
18
|
-
source: '/api/:path*',
|
|
19
|
-
destination: `${apiBase}/:path*`,
|
|
20
|
-
},
|
|
21
|
-
];
|
|
22
|
-
},
|
|
10
|
+
// The backend is in-app under src/app/api/**/route.ts. No rewrites are needed.
|
|
11
|
+
// (Do not add a rewrite forwarding /api/* to an external host — this project is fullstack.)
|
|
23
12
|
|
|
24
13
|
images: {
|
|
25
14
|
remotePatterns: [
|
|
@@ -36,7 +25,8 @@ export default nextConfig;
|
|
|
36
25
|
```
|
|
37
26
|
|
|
38
27
|
**Notes:**
|
|
28
|
+
- The app is fullstack: `/api/*` routes are Route Handlers in `src/app/api/**/route.ts`, not proxies to an external backend. **Do not** add a `rewrites()` entry forwarding `/api/*` to another host — that breaks NextAuth (which expects `/api/auth/*` to land on the in-app handlers) and removes the same-origin guarantee RTK Query relies on for `credentials: 'include'`.
|
|
39
29
|
- Do not enable `output: 'standalone'` by default; only when the user is deploying to a container.
|
|
40
|
-
- Do not enable `output: 'export'` (static export)
|
|
30
|
+
- Do not enable `output: 'export'` (static export) — it disables middleware, Route Handlers, and image optimization. The skill's whole architecture depends on those.
|
|
41
31
|
- `reactStrictMode: true` is non-negotiable for a new project.
|
|
42
32
|
- `poweredByHeader: false` removes the `X-Powered-By` header.
|