kilo-cms 0.1.0 → 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 +41 -4
- package/package.json +6 -4
- package/src/admin/pages/ai-settings.tsx +2 -4
- package/src/admin/pages/api-tokens.tsx +2 -5
- package/src/admin/pages/collection-list.tsx +3 -5
- package/src/admin/pages/collection-record.tsx +3 -5
- package/src/admin/pages/dashboard.tsx +2 -4
- package/src/admin/pages/email-settings.tsx +2 -5
- package/src/admin/pages/locales-settings.tsx +2 -5
- package/src/admin/pages/media.tsx +2 -4
- package/src/admin/pages/review-queue.tsx +2 -3
- package/src/admin/pages/role-edit.tsx +2 -5
- package/src/admin/pages/roles.tsx +2 -5
- package/src/admin/pages/scheduled.tsx +3 -5
- package/src/admin/pages/seo-settings.tsx +2 -4
- package/src/admin/pages/single-record.tsx +3 -5
- package/src/admin/pages/site-settings.tsx +2 -4
- package/src/admin/pages/user-edit.tsx +2 -5
- package/src/admin/pages/user-new.tsx +2 -5
- package/src/admin/pages/users.tsx +2 -5
- package/src/admin/pages/webhooks-settings.tsx +2 -5
- package/src/admin/require-session.ts +21 -0
- package/src/cli/index.mjs +49 -1
- package/src/middleware.ts +28 -0
package/README.md
CHANGED
|
@@ -135,6 +135,33 @@ import '@/lib/db'
|
|
|
135
135
|
export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
+
### Middleware
|
|
139
|
+
|
|
140
|
+
Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
|
|
141
|
+
visitors away from `/admin/*` before a page even renders — a fast filter, not the real
|
|
142
|
+
security boundary (every admin page/route still validates the session and RBAC permissions
|
|
143
|
+
for real). Your own `middleware.ts` re-exports it and defines `matcher` itself (Next.js
|
|
144
|
+
statically analyzes `matcher` in the file it scans — it can't be re-exported through another
|
|
145
|
+
module):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// middleware.ts
|
|
149
|
+
export { kiloAdminMiddleware as middleware } from 'kilo-cms/middleware'
|
|
150
|
+
|
|
151
|
+
export const config = {
|
|
152
|
+
matcher: ['/admin/:path*'],
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Admin pages use `requireAdminSession()` from `kilo-cms/admin/require-session` instead of
|
|
157
|
+
hand-rolling `getSessionWithPermissions()` + `redirect()`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { requireAdminSession } from 'kilo-cms/admin/require-session'
|
|
161
|
+
|
|
162
|
+
const result = await requireAdminSession((r) => r.can('projects', 'read'), '/admin/dashboard')
|
|
163
|
+
```
|
|
164
|
+
|
|
138
165
|
## CLI
|
|
139
166
|
|
|
140
167
|
Run from your own app's directory:
|
|
@@ -143,6 +170,7 @@ Run from your own app's directory:
|
|
|
143
170
|
npx kilo-cms add-collection <slug> # scaffold a new collection's fields.ts + table.ts
|
|
144
171
|
npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
|
|
145
172
|
npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
|
|
173
|
+
npx kilo-cms init # migrate, then create the first admin user
|
|
146
174
|
```
|
|
147
175
|
|
|
148
176
|
`add-collection` scaffolds the two files and prints the exact lines to paste into
|
|
@@ -154,6 +182,14 @@ locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
|
|
|
154
182
|
`kilo_cms_migrations` table, independent of your own app's migration history for its content
|
|
155
183
|
tables.
|
|
156
184
|
|
|
185
|
+
`init` runs `migrate`, then creates your first admin user via the existing
|
|
186
|
+
`/api/setup/admin` route — it needs your app already running (`npm run dev` in another
|
|
187
|
+
terminal) since that route executes inside your app's own Next.js runtime:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
CMS_SETUP_TOKEN=... npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
|
|
191
|
+
```
|
|
192
|
+
|
|
157
193
|
## Package layout
|
|
158
194
|
|
|
159
195
|
| Export | What it is |
|
|
@@ -165,6 +201,8 @@ tables.
|
|
|
165
201
|
| `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) |
|
|
166
202
|
| `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
|
|
167
203
|
| `kilo-cms/admin/*` | Admin UI pages and shared components |
|
|
204
|
+
| `kilo-cms/admin/require-session` | `requireAdminSession()` — the shared session/RBAC guard for admin pages |
|
|
205
|
+
| `kilo-cms/middleware` | `kiloAdminMiddleware` — Edge-safe cookie-presence pre-filter for `/admin/*` |
|
|
168
206
|
| `kilo-cms/routes/*` | Route Handler logic for the admin API, public API helpers, auth, cron |
|
|
169
207
|
|
|
170
208
|
## Status / roadmap
|
|
@@ -173,10 +211,9 @@ Built by extracting a working, in-production CMS out of the app it was originall
|
|
|
173
211
|
in — see [`the-usual-dev`'s `WORKLOG.md`](https://github.com/revaldyas/the-usual-dev) for the
|
|
174
212
|
full history of that extraction. Not yet done:
|
|
175
213
|
|
|
176
|
-
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
command).
|
|
214
|
+
- A single catch-all admin route instead of one thin file per page (deliberately skipped for
|
|
215
|
+
now — today's thin per-page files are already minimal; a hand-rolled catch-all router would
|
|
216
|
+
trade that for real complexity with no proven need yet).
|
|
180
217
|
- A second, independent consumer project to prove the install story beyond the one app this
|
|
181
218
|
was extracted from.
|
|
182
219
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kilo-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An installable, config-driven CMS engine for Next.js — admin panel, RBAC, a generic field-type engine, and an API, mounted into your own app. Define your content schema via config, not a hand-maintained registry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cms",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"./dashboard": "./src/dashboard.ts",
|
|
40
40
|
"./routes/admin/*": "./src/routes/admin/*/route.ts",
|
|
41
41
|
"./routes/*": "./src/routes/*.ts",
|
|
42
|
+
"./middleware": "./src/middleware.ts",
|
|
43
|
+
"./admin/require-session": "./src/admin/require-session.ts",
|
|
42
44
|
"./admin/*": "./src/admin/*.tsx",
|
|
43
45
|
"./admin.css": "./src/admin/admin.css",
|
|
44
46
|
"./globals.css": "./src/admin/globals.css"
|
|
@@ -78,7 +80,9 @@
|
|
|
78
80
|
"nodemailer": "^10.0.9",
|
|
79
81
|
"react-easy-crop": "^6.2.3",
|
|
80
82
|
"react-leaflet": "^5.0.0",
|
|
81
|
-
"zod": "^4.0.0"
|
|
83
|
+
"zod": "^4.0.0",
|
|
84
|
+
"@types/leaflet": "^1.9.22",
|
|
85
|
+
"@types/nodemailer": "^8.0.1"
|
|
82
86
|
},
|
|
83
87
|
"peerDependencies": {
|
|
84
88
|
"next": "^16.2.6",
|
|
@@ -86,9 +90,7 @@
|
|
|
86
90
|
"react-dom": "^19.2.0"
|
|
87
91
|
},
|
|
88
92
|
"devDependencies": {
|
|
89
|
-
"@types/leaflet": "^1.9.22",
|
|
90
93
|
"@types/node": "^22.15.0",
|
|
91
|
-
"@types/nodemailer": "^8.0.1",
|
|
92
94
|
"@types/react": "^19.1.0",
|
|
93
95
|
"@types/react-dom": "^19.1.0",
|
|
94
96
|
"drizzle-kit": "^0.31.0",
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { AiSettingsEditor } from '../AiSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function AiSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <AiSettingsEditor userName={result.session.user.name} canManageAi={result.hasSystem('ai.manage')} />
|
|
9
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { ApiTokensEditor } from '../ApiTokensEditor'
|
|
4
3
|
|
|
5
4
|
export default async function ApiTokensPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <ApiTokensEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection } from '../../collections'
|
|
4
4
|
import { CollectionListEditor } from '../CollectionListEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function CollectionListPage({ params }: { params: Promise<{
|
|
|
7
7
|
const { collection } = await params
|
|
8
8
|
const config = getCollection(collection)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<CollectionListEditor
|
|
15
13
|
config={config}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection } from '../../collections'
|
|
4
4
|
import { CollectionRecordEditor } from '../CollectionRecordEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function CollectionRecordPage({ params }: { params: Promise
|
|
|
7
7
|
const { collection, id } = await params
|
|
8
8
|
const config = getCollection(collection)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<CollectionRecordEditor
|
|
15
13
|
config={config}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { getDashboardSources } from '../../dashboard'
|
|
4
3
|
import { DashboardBuilder } from '../DashboardBuilder'
|
|
5
4
|
|
|
6
5
|
export default async function DashboardPage() {
|
|
7
|
-
const result = await
|
|
8
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
6
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
9
7
|
return <DashboardBuilder userName={result.session.user.name} canUpdate={result.can('settings', 'update')} dashboardSources={getDashboardSources()} />
|
|
10
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { EmailSettingsEditor } from '../EmailSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function EmailSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <EmailSettingsEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { LocalesEditor } from '../LocalesEditor'
|
|
4
3
|
|
|
5
4
|
export default async function LocalesPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('locales.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('locales.manage'), '/admin/settings')
|
|
9
6
|
return <LocalesEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { MediaLibrary } from '../MediaLibrary'
|
|
4
3
|
|
|
5
4
|
export default async function MediaPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession()
|
|
8
6
|
return <MediaLibrary userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { redirect } from 'next/navigation'
|
|
2
|
-
import {
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getCollection, isCollectionSlug, reviewableSlugs, type CollectionSlug } from '../../collections'
|
|
4
4
|
import { ReviewQueueEditor, type ReviewCollectionMeta } from '../ReviewQueueEditor'
|
|
5
5
|
|
|
6
6
|
export default async function ReviewQueuePage() {
|
|
7
|
-
const result = await
|
|
8
|
-
if (!result) redirect('/admin/login')
|
|
7
|
+
const result = await requireAdminSession()
|
|
9
8
|
const reviewSlugs = reviewableSlugs.filter((slug): slug is CollectionSlug => isCollectionSlug(slug))
|
|
10
9
|
const canReview = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'review')])) as Record<CollectionSlug, boolean>
|
|
11
10
|
const canApprove = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'approve')])) as Record<CollectionSlug, boolean>
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { RoleEditor } from '../RoleEditor'
|
|
4
3
|
|
|
5
4
|
export default async function RoleEditPage({ params }: { params: Promise<{ id: string }> }) {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('roles.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
|
|
9
6
|
const { id } = await params
|
|
10
7
|
return <RoleEditor id={id} userName={result.session.user.name} />
|
|
11
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { RolesEditor } from '../RolesEditor'
|
|
4
3
|
|
|
5
4
|
export default async function RolesPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('roles.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
|
|
9
6
|
return <RolesEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { getCollection, schedulingSlugs, type CollectionSlug } from '../../collections'
|
|
4
3
|
import { ScheduledEditor, type ScheduledCollectionMeta } from '../ScheduledEditor'
|
|
5
4
|
|
|
6
5
|
export default async function ScheduledPage() {
|
|
7
|
-
const result = await
|
|
8
|
-
const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug =>
|
|
9
|
-
if (!result || !readableSlugs.length) redirect('/admin/login')
|
|
6
|
+
const result = await requireAdminSession((r) => schedulingSlugs.some((slug) => r.can(slug, 'read')), '/admin/login')
|
|
7
|
+
const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug => result.can(slug, 'read'))
|
|
10
8
|
const meta: ScheduledCollectionMeta = Object.fromEntries(
|
|
11
9
|
readableSlugs.map((slug) => {
|
|
12
10
|
const config = getCollection(slug)!
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { SeoSettingsEditor } from '../SeoSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function SeoSettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <SeoSettingsEditor userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { notFound
|
|
2
|
-
import {
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import { requireAdminSession } from '../require-session'
|
|
3
3
|
import { getSingle } from '../../collections'
|
|
4
4
|
import { SingleRecordEditor } from '../SingleRecordEditor'
|
|
5
5
|
|
|
@@ -7,9 +7,7 @@ export default async function SingleRecordPage({ params }: { params: Promise<{ t
|
|
|
7
7
|
const { type } = await params
|
|
8
8
|
const config = getSingle(type)
|
|
9
9
|
if (!config) notFound()
|
|
10
|
-
const result = await
|
|
11
|
-
if (!result) redirect('/admin/login')
|
|
12
|
-
if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
|
|
10
|
+
const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
|
|
13
11
|
return (
|
|
14
12
|
<SingleRecordEditor
|
|
15
13
|
config={config}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { SiteSettingsEditor } from '../SiteSettingsEditor'
|
|
4
3
|
|
|
5
4
|
export default async function SettingsPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result || !result.can('settings', 'read')) redirect('/admin/login')
|
|
5
|
+
const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
|
|
8
6
|
return <SiteSettingsEditor userName={result.session.user.name} />
|
|
9
7
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UserEditor } from '../UserEditor'
|
|
4
3
|
|
|
5
4
|
export default async function UserEditPage({ params }: { params: Promise<{ id: string }> }) {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
const { id } = await params
|
|
10
7
|
return <UserEditor id={id} userName={result.session.user.name} />
|
|
11
8
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UserEditor } from '../UserEditor'
|
|
4
3
|
|
|
5
4
|
export default async function NewUserPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
return <UserEditor id="new" userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { UsersEditor } from '../UsersEditor'
|
|
4
3
|
|
|
5
4
|
export default async function UsersPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('users.manage')) redirect('/admin')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
|
|
9
6
|
return <UsersEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getSessionWithPermissions } from '../../permissions'
|
|
1
|
+
import { requireAdminSession } from '../require-session'
|
|
3
2
|
import { WebhooksEditor } from '../WebhooksEditor'
|
|
4
3
|
|
|
5
4
|
export default async function WebhooksPage() {
|
|
6
|
-
const result = await
|
|
7
|
-
if (!result) redirect('/admin/login')
|
|
8
|
-
if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
|
|
5
|
+
const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
|
|
9
6
|
return <WebhooksEditor userName={result.session.user.name} />
|
|
10
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation'
|
|
2
|
+
import { getSessionWithPermissions, type SessionWithPermissions } from '../permissions'
|
|
3
|
+
|
|
4
|
+
/** For Server Component admin pages only — `redirect()` from `next/navigation` only works
|
|
5
|
+
* inside the RSC render tree, never call this from a Route Handler (use `requireAction`/
|
|
6
|
+
* `requireSystem` from `../permissions` there instead, which return null on failure and let
|
|
7
|
+
* the route decide how to respond).
|
|
8
|
+
*
|
|
9
|
+
* Replaces the repeated
|
|
10
|
+
* const result = await getSessionWithPermissions()
|
|
11
|
+
* if (!result) redirect('/admin/login')
|
|
12
|
+
* if (!result.can(...)) redirect('/admin/dashboard')
|
|
13
|
+
* pattern with one call. `check` is optional — omit it for a page that only needs "is logged
|
|
14
|
+
* in" (its real authorization logic is more than a single predicate, e.g. it depends on data
|
|
15
|
+
* computed from the session first). */
|
|
16
|
+
export async function requireAdminSession(check?: (result: SessionWithPermissions) => boolean, redirectTo = '/admin/dashboard'): Promise<SessionWithPermissions> {
|
|
17
|
+
const result = await getSessionWithPermissions()
|
|
18
|
+
if (!result) redirect('/admin/login')
|
|
19
|
+
if (check && !check(result)) redirect(redirectTo)
|
|
20
|
+
return result
|
|
21
|
+
}
|
package/src/cli/index.mjs
CHANGED
|
@@ -128,6 +128,49 @@ function migrate() {
|
|
|
128
128
|
console.log('\nRun your own `db:generate`/`db:migrate` next for this app\'s own content tables.')
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// --- init -----------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function parseFlags(argv) {
|
|
134
|
+
const flags = {}
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
const arg = argv[i]
|
|
137
|
+
if (arg.startsWith('--')) flags[arg.slice(2)] = argv[i + 1]
|
|
138
|
+
}
|
|
139
|
+
return flags
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function init(argv) {
|
|
143
|
+
const flags = parseFlags(argv)
|
|
144
|
+
const url = flags.url ?? 'http://localhost:3000'
|
|
145
|
+
const token = flags.token ?? process.env.CMS_SETUP_TOKEN
|
|
146
|
+
const { name, email, password } = flags
|
|
147
|
+
|
|
148
|
+
if (!token) fail('missing --token (or set CMS_SETUP_TOKEN in your environment before running this).')
|
|
149
|
+
if (!name || !email || !password) fail('usage: kilo-cms init --name "..." --email you@example.com --password "..." [--url http://localhost:3000] [--token ...]')
|
|
150
|
+
|
|
151
|
+
migrate()
|
|
152
|
+
|
|
153
|
+
console.log(`\nCreating first admin at ${url}/api/setup/admin ...`)
|
|
154
|
+
console.log('(this requires your app to already be running — start it in another terminal first if it isn\'t.)')
|
|
155
|
+
|
|
156
|
+
let response
|
|
157
|
+
try {
|
|
158
|
+
response = await fetch(`${url}/api/setup/admin`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
161
|
+
body: JSON.stringify({ name, email, password }),
|
|
162
|
+
})
|
|
163
|
+
} catch (error) {
|
|
164
|
+
fail(`could not reach ${url} — is the app running? (${error instanceof Error ? error.message : String(error)})`)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const body = await response.json().catch(() => null)
|
|
169
|
+
if (!response.ok) fail(`setup failed (${response.status}): ${body?.error ?? 'unknown error'}`)
|
|
170
|
+
|
|
171
|
+
console.log(`\nDone — admin user created (${email}). You can log in at ${url}/admin/login.`)
|
|
172
|
+
}
|
|
173
|
+
|
|
131
174
|
// --- dispatch -------------------------------------------------------------------------------
|
|
132
175
|
|
|
133
176
|
switch (command) {
|
|
@@ -140,10 +183,15 @@ switch (command) {
|
|
|
140
183
|
case 'migrate':
|
|
141
184
|
migrate()
|
|
142
185
|
break
|
|
186
|
+
case 'init':
|
|
187
|
+
await init(args)
|
|
188
|
+
break
|
|
143
189
|
default:
|
|
144
190
|
console.log(`kilo-cms — usage:
|
|
145
191
|
kilo-cms add-collection <slug> scaffold a new collection's fields.ts + table.ts
|
|
146
192
|
kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
|
|
147
|
-
kilo-cms migrate apply kilo-cms's own package-owned migrations
|
|
193
|
+
kilo-cms migrate apply kilo-cms's own package-owned migrations
|
|
194
|
+
kilo-cms init migrate, then create the first admin user
|
|
195
|
+
(--name, --email, --password, [--url], [--token or $CMS_SETUP_TOKEN])`)
|
|
148
196
|
process.exit(command ? 1 : 0)
|
|
149
197
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { getSessionCookie } from 'better-auth/cookies'
|
|
3
|
+
|
|
4
|
+
/** Optimistic, Edge-safe redirect for unauthenticated requests to admin pages — a fast
|
|
5
|
+
* pre-filter based on cookie PRESENCE only (`getSessionCookie` reads the cookie, no DB call,
|
|
6
|
+
* safe to run in Edge middleware where a raw Postgres connection isn't available). This is
|
|
7
|
+
* NOT the real security boundary: a forged or expired cookie passes this check. Every admin
|
|
8
|
+
* page still calls `requireAdminSession()` (pages) or `requireAction`/`requireSystem` (API
|
|
9
|
+
* routes), which validate the session for real and check actual RBAC permissions — this only
|
|
10
|
+
* saves an unauthenticated visitor the round trip of hitting a page that would reject them
|
|
11
|
+
* anyway, and centralizes what used to be an `if (!result) redirect('/admin/login')` repeated
|
|
12
|
+
* on every single admin page.
|
|
13
|
+
*
|
|
14
|
+
* The host's own `middleware.ts` re-exports this as `middleware` and defines `config.matcher`
|
|
15
|
+
* itself (Next.js statically analyzes `matcher` at build time — it has to be a literal array
|
|
16
|
+
* in the file Next.js is scanning, not re-exported through another module). */
|
|
17
|
+
export function kiloAdminMiddleware(request: NextRequest): NextResponse {
|
|
18
|
+
const { pathname } = request.nextUrl
|
|
19
|
+
if (pathname === '/admin/login') return NextResponse.next()
|
|
20
|
+
|
|
21
|
+
const sessionCookie = getSessionCookie(request)
|
|
22
|
+
if (!sessionCookie) {
|
|
23
|
+
const url = request.nextUrl.clone()
|
|
24
|
+
url.pathname = '/admin/login'
|
|
25
|
+
return NextResponse.redirect(url)
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.next()
|
|
28
|
+
}
|