@questpie/admin 0.0.1 → 1.0.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 +439 -424
- package/dist/auth-layout-M8K8_q5R.mjs +181 -0
- package/dist/auth-layout-M8K8_q5R.mjs.map +1 -0
- package/dist/bulk-upload-dialog-h7zXD78Y.mjs +274 -0
- package/dist/bulk-upload-dialog-h7zXD78Y.mjs.map +1 -0
- package/dist/{components/ui/card.mjs → card-BKHjBQfw.mjs} +8 -8
- package/dist/card-BKHjBQfw.mjs.map +1 -0
- package/dist/client/styles/index.css +434 -0
- package/dist/client-BCGpkAz6.mjs +22635 -0
- package/dist/client-BCGpkAz6.mjs.map +1 -0
- package/dist/client-CcWZbkBP.d.mts +13585 -0
- package/dist/client-CcWZbkBP.d.mts.map +1 -0
- package/dist/client.d.mts +3 -0
- package/dist/client.mjs +14 -0
- package/dist/content-locales-provider-BXvuIgfg.mjs +1650 -0
- package/dist/content-locales-provider-BXvuIgfg.mjs.map +1 -0
- package/dist/dashboard-page-B4PGEdc2.mjs +2500 -0
- package/dist/dashboard-page-B4PGEdc2.mjs.map +1 -0
- package/dist/dashboard-page-CVlyR40m.mjs +6 -0
- package/dist/dropzone-Do3awXKd.mjs +634 -0
- package/dist/dropzone-Do3awXKd.mjs.map +1 -0
- package/dist/{views/auth/forgot-password-form.mjs → forgot-password-page-Bcp-An4Y.mjs} +87 -14
- package/dist/forgot-password-page-Bcp-An4Y.mjs.map +1 -0
- package/dist/forgot-password-page-CIILVhfo.mjs +7 -0
- package/dist/index-B9Xwk4hi.d.mts +2753 -0
- package/dist/index-B9Xwk4hi.d.mts.map +1 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +14 -0
- package/dist/login-page-8K7fo0qK.mjs +7 -0
- package/dist/login-page-CP4gA-dl.mjs +298 -0
- package/dist/login-page-CP4gA-dl.mjs.map +1 -0
- package/dist/preview-utils-BKQ9-TMa.mjs +65 -0
- package/dist/preview-utils-BKQ9-TMa.mjs.map +1 -0
- package/dist/{views/auth/reset-password-form.mjs → reset-password-page-BqfDmLxA.mjs} +111 -14
- package/dist/reset-password-page-BqfDmLxA.mjs.map +1 -0
- package/dist/reset-password-page-DLATv0xQ.mjs +7 -0
- package/dist/runtime-6VZM878K.mjs +69 -0
- package/dist/runtime-6VZM878K.mjs.map +1 -0
- package/dist/saved-views.types-BMsz5mCy.d.mts +42 -0
- package/dist/saved-views.types-BMsz5mCy.d.mts.map +1 -0
- package/dist/server.d.mts +250 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +832 -0
- package/dist/server.mjs.map +1 -0
- package/dist/setup-page-CMZ5P_OE.mjs +6 -0
- package/dist/setup-page-YAP_fzqh.mjs +264 -0
- package/dist/setup-page-YAP_fzqh.mjs.map +1 -0
- package/dist/shared.d.mts +57 -0
- package/dist/shared.d.mts.map +1 -0
- package/dist/shared.mjs +3 -0
- package/dist/{hooks/use-auth.mjs → use-auth-BoLmWtmU.mjs} +42 -30
- package/dist/use-auth-BoLmWtmU.mjs.map +1 -0
- package/package.json +48 -197
- package/.turbo/turbo-build.log +0 -108
- package/CHANGELOG.md +0 -10
- package/STATUS.md +0 -917
- package/VALIDATION.md +0 -602
- package/components.json +0 -24
- package/dist/__tests__/setup.mjs +0 -38
- package/dist/__tests__/test-utils.mjs +0 -45
- package/dist/__tests__/vitest.d.mjs +0 -3
- package/dist/components/admin-app.mjs +0 -69
- package/dist/components/fields/array-field.mjs +0 -190
- package/dist/components/fields/checkbox-field.mjs +0 -34
- package/dist/components/fields/custom-field.mjs +0 -32
- package/dist/components/fields/date-field.mjs +0 -41
- package/dist/components/fields/datetime-field.mjs +0 -42
- package/dist/components/fields/email-field.mjs +0 -37
- package/dist/components/fields/embedded-collection.mjs +0 -253
- package/dist/components/fields/field-types.mjs +0 -1
- package/dist/components/fields/field-utils.mjs +0 -10
- package/dist/components/fields/field-wrapper.mjs +0 -34
- package/dist/components/fields/index.mjs +0 -23
- package/dist/components/fields/json-field.mjs +0 -243
- package/dist/components/fields/locale-badge.mjs +0 -16
- package/dist/components/fields/number-field.mjs +0 -39
- package/dist/components/fields/password-field.mjs +0 -37
- package/dist/components/fields/relation-field.mjs +0 -104
- package/dist/components/fields/relation-picker.mjs +0 -229
- package/dist/components/fields/relation-select.mjs +0 -188
- package/dist/components/fields/rich-text-editor/index.mjs +0 -897
- package/dist/components/fields/select-field.mjs +0 -41
- package/dist/components/fields/switch-field.mjs +0 -34
- package/dist/components/fields/text-field.mjs +0 -38
- package/dist/components/fields/textarea-field.mjs +0 -38
- package/dist/components/index.mjs +0 -59
- package/dist/components/primitives/checkbox-input.mjs +0 -127
- package/dist/components/primitives/date-input.mjs +0 -303
- package/dist/components/primitives/index.mjs +0 -12
- package/dist/components/primitives/number-input.mjs +0 -104
- package/dist/components/primitives/select-input.mjs +0 -177
- package/dist/components/primitives/tag-input.mjs +0 -135
- package/dist/components/primitives/text-input.mjs +0 -39
- package/dist/components/primitives/textarea-input.mjs +0 -37
- package/dist/components/primitives/toggle-input.mjs +0 -31
- package/dist/components/primitives/types.mjs +0 -12
- package/dist/components/ui/accordion.mjs +0 -55
- package/dist/components/ui/avatar.mjs +0 -54
- package/dist/components/ui/badge.mjs +0 -34
- package/dist/components/ui/button.mjs +0 -48
- package/dist/components/ui/checkbox.mjs +0 -21
- package/dist/components/ui/combobox.mjs +0 -163
- package/dist/components/ui/dialog.mjs +0 -95
- package/dist/components/ui/dropdown-menu.mjs +0 -138
- package/dist/components/ui/field.mjs +0 -113
- package/dist/components/ui/input-group.mjs +0 -82
- package/dist/components/ui/input.mjs +0 -17
- package/dist/components/ui/label.mjs +0 -15
- package/dist/components/ui/popover.mjs +0 -56
- package/dist/components/ui/scroll-area.mjs +0 -38
- package/dist/components/ui/select.mjs +0 -100
- package/dist/components/ui/separator.mjs +0 -16
- package/dist/components/ui/sheet.mjs +0 -90
- package/dist/components/ui/sidebar.mjs +0 -387
- package/dist/components/ui/skeleton.mjs +0 -14
- package/dist/components/ui/spinner.mjs +0 -16
- package/dist/components/ui/switch.mjs +0 -22
- package/dist/components/ui/table.mjs +0 -68
- package/dist/components/ui/tabs.mjs +0 -48
- package/dist/components/ui/textarea.mjs +0 -15
- package/dist/components/ui/tooltip.mjs +0 -44
- package/dist/config/component-registry.mjs +0 -38
- package/dist/config/index.mjs +0 -129
- package/dist/hooks/admin-provider.mjs +0 -70
- package/dist/hooks/index.mjs +0 -7
- package/dist/hooks/store.mjs +0 -178
- package/dist/hooks/use-collection-db.mjs +0 -146
- package/dist/hooks/use-collection.mjs +0 -112
- package/dist/hooks/use-global.mjs +0 -46
- package/dist/hooks/use-mobile.mjs +0 -20
- package/dist/lib/utils.mjs +0 -10
- package/dist/styles/index.css +0 -336
- package/dist/styles/index.mjs +0 -1
- package/dist/utils/index.mjs +0 -9
- package/dist/views/auth/auth-layout.mjs +0 -52
- package/dist/views/auth/index.mjs +0 -6
- package/dist/views/auth/login-form.mjs +0 -156
- package/dist/views/collection/auto-form-fields.mjs +0 -525
- package/dist/views/collection/collection-form.mjs +0 -91
- package/dist/views/collection/collection-list.mjs +0 -76
- package/dist/views/collection/form-field.mjs +0 -42
- package/dist/views/collection/index.mjs +0 -6
- package/dist/views/common/index.mjs +0 -4
- package/dist/views/common/locale-switcher.mjs +0 -39
- package/dist/views/common/version-history.mjs +0 -272
- package/dist/views/index.mjs +0 -9
- package/dist/views/layout/admin-layout.mjs +0 -40
- package/dist/views/layout/admin-router.mjs +0 -95
- package/dist/views/layout/admin-sidebar.mjs +0 -63
- package/dist/views/layout/index.mjs +0 -5
- package/src/__tests__/setup.ts +0 -44
- package/src/__tests__/test-utils.tsx +0 -49
- package/src/__tests__/vitest.d.ts +0 -9
- package/src/components/admin-app.tsx +0 -221
- package/src/components/fields/array-field.tsx +0 -237
- package/src/components/fields/checkbox-field.tsx +0 -47
- package/src/components/fields/custom-field.tsx +0 -50
- package/src/components/fields/date-field.tsx +0 -65
- package/src/components/fields/datetime-field.tsx +0 -67
- package/src/components/fields/email-field.tsx +0 -51
- package/src/components/fields/embedded-collection.tsx +0 -315
- package/src/components/fields/field-types.ts +0 -162
- package/src/components/fields/field-utils.ts +0 -6
- package/src/components/fields/field-wrapper.tsx +0 -52
- package/src/components/fields/index.ts +0 -66
- package/src/components/fields/json-field.tsx +0 -440
- package/src/components/fields/locale-badge.tsx +0 -15
- package/src/components/fields/number-field.tsx +0 -57
- package/src/components/fields/password-field.tsx +0 -51
- package/src/components/fields/relation-field.tsx +0 -243
- package/src/components/fields/relation-picker.tsx +0 -402
- package/src/components/fields/relation-select.tsx +0 -327
- package/src/components/fields/rich-text-editor/index.tsx +0 -1337
- package/src/components/fields/select-field.tsx +0 -61
- package/src/components/fields/switch-field.tsx +0 -47
- package/src/components/fields/text-field.tsx +0 -55
- package/src/components/fields/textarea-field.tsx +0 -55
- package/src/components/index.ts +0 -40
- package/src/components/primitives/checkbox-input.tsx +0 -193
- package/src/components/primitives/date-input.tsx +0 -401
- package/src/components/primitives/index.ts +0 -24
- package/src/components/primitives/number-input.tsx +0 -132
- package/src/components/primitives/select-input.tsx +0 -296
- package/src/components/primitives/tag-input.tsx +0 -200
- package/src/components/primitives/text-input.tsx +0 -49
- package/src/components/primitives/textarea-input.tsx +0 -46
- package/src/components/primitives/toggle-input.tsx +0 -36
- package/src/components/primitives/types.ts +0 -235
- package/src/components/ui/accordion.tsx +0 -72
- package/src/components/ui/avatar.tsx +0 -106
- package/src/components/ui/badge.tsx +0 -48
- package/src/components/ui/button.tsx +0 -53
- package/src/components/ui/card.tsx +0 -94
- package/src/components/ui/checkbox.tsx +0 -27
- package/src/components/ui/combobox.tsx +0 -290
- package/src/components/ui/dialog.tsx +0 -151
- package/src/components/ui/dropdown-menu.tsx +0 -254
- package/src/components/ui/field.tsx +0 -227
- package/src/components/ui/input-group.tsx +0 -149
- package/src/components/ui/input.tsx +0 -20
- package/src/components/ui/label.tsx +0 -18
- package/src/components/ui/popover.tsx +0 -88
- package/src/components/ui/scroll-area.tsx +0 -53
- package/src/components/ui/select.tsx +0 -192
- package/src/components/ui/separator.tsx +0 -23
- package/src/components/ui/sheet.tsx +0 -127
- package/src/components/ui/sidebar.tsx +0 -723
- package/src/components/ui/skeleton.tsx +0 -13
- package/src/components/ui/spinner.tsx +0 -10
- package/src/components/ui/switch.tsx +0 -32
- package/src/components/ui/table.tsx +0 -99
- package/src/components/ui/tabs.tsx +0 -82
- package/src/components/ui/textarea.tsx +0 -18
- package/src/components/ui/tooltip.tsx +0 -70
- package/src/config/component-registry.ts +0 -190
- package/src/config/index.ts +0 -1099
- package/src/hooks/README.md +0 -269
- package/src/hooks/admin-provider.tsx +0 -110
- package/src/hooks/index.ts +0 -41
- package/src/hooks/store.ts +0 -248
- package/src/hooks/use-auth.ts +0 -168
- package/src/hooks/use-collection-db.ts +0 -209
- package/src/hooks/use-collection.ts +0 -156
- package/src/hooks/use-global.ts +0 -69
- package/src/hooks/use-mobile.ts +0 -21
- package/src/lib/utils.ts +0 -6
- package/src/styles/index.css +0 -340
- package/src/utils/index.ts +0 -6
- package/src/views/auth/auth-layout.tsx +0 -77
- package/src/views/auth/forgot-password-form.tsx +0 -192
- package/src/views/auth/index.ts +0 -21
- package/src/views/auth/login-form.tsx +0 -229
- package/src/views/auth/reset-password-form.tsx +0 -232
- package/src/views/collection/auto-form-fields.tsx +0 -982
- package/src/views/collection/collection-form.tsx +0 -186
- package/src/views/collection/collection-list.tsx +0 -223
- package/src/views/collection/form-field.tsx +0 -52
- package/src/views/collection/index.ts +0 -15
- package/src/views/common/index.ts +0 -8
- package/src/views/common/locale-switcher.tsx +0 -45
- package/src/views/common/version-history.tsx +0 -406
- package/src/views/index.ts +0 -25
- package/src/views/layout/admin-layout.tsx +0 -117
- package/src/views/layout/admin-router.tsx +0 -206
- package/src/views/layout/admin-sidebar.tsx +0 -185
- package/src/views/layout/index.ts +0 -12
- package/tsconfig.json +0 -13
- package/tsconfig.tsbuildinfo +0 -1
- package/tsdown.config.ts +0 -13
- package/vitest.config.ts +0 -29
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { r as getPreviewSecret } from "./preview-utils-BKQ9-TMa.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { fn, q, starterModule } from "questpie";
|
|
4
|
+
import { boolean, jsonb, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { eq, sql } from "drizzle-orm";
|
|
7
|
+
|
|
8
|
+
//#region src/server/auth-helpers.ts
|
|
9
|
+
/**
|
|
10
|
+
* Check if user is authenticated with required role on the server.
|
|
11
|
+
* Returns a redirect Response if not authenticated, null if authenticated.
|
|
12
|
+
*
|
|
13
|
+
* Use this in server loaders/middleware to protect routes.
|
|
14
|
+
*
|
|
15
|
+
* @example TanStack Router
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const Route = createFileRoute("/admin")({
|
|
18
|
+
* beforeLoad: async ({ context }) => {
|
|
19
|
+
* const redirect = await requireAdminAuth({
|
|
20
|
+
* request: context.request,
|
|
21
|
+
* cms,
|
|
22
|
+
* loginPath: "/admin/login",
|
|
23
|
+
* });
|
|
24
|
+
* if (redirect) throw redirect;
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example Next.js Middleware
|
|
30
|
+
* ```ts
|
|
31
|
+
* export async function middleware(request: NextRequest) {
|
|
32
|
+
* const redirect = await requireAdminAuth({
|
|
33
|
+
* request,
|
|
34
|
+
* cms,
|
|
35
|
+
* loginPath: "/admin/login",
|
|
36
|
+
* });
|
|
37
|
+
* if (redirect) return redirect;
|
|
38
|
+
* return NextResponse.next();
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
async function requireAdminAuth({ request, cms, loginPath = "/admin/login", requiredRole = "admin", redirectParam = "redirect" }) {
|
|
43
|
+
if (!cms.auth) {
|
|
44
|
+
console.warn("requireAdminAuth: Auth not configured on CMS instance");
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const session = await cms.auth.api.getSession({ headers: request.headers });
|
|
49
|
+
if (!session || !session.user) {
|
|
50
|
+
const currentUrl = new URL(request.url);
|
|
51
|
+
const redirectUrl = new URL(loginPath, currentUrl.origin);
|
|
52
|
+
redirectUrl.searchParams.set(redirectParam, currentUrl.pathname);
|
|
53
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
54
|
+
}
|
|
55
|
+
if (session.user.role !== requiredRole) {
|
|
56
|
+
const currentUrl = new URL(request.url);
|
|
57
|
+
const redirectUrl = new URL(loginPath, currentUrl.origin);
|
|
58
|
+
redirectUrl.searchParams.set(redirectParam, currentUrl.pathname);
|
|
59
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("requireAdminAuth: Error checking session", error);
|
|
64
|
+
const currentUrl = new URL(request.url);
|
|
65
|
+
const redirectUrl = new URL(loginPath, currentUrl.origin);
|
|
66
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the current admin session on the server.
|
|
71
|
+
* Returns null if not authenticated.
|
|
72
|
+
*
|
|
73
|
+
* Use this when you need access to the session data in server code.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const session = await getAdminSession({ request, cms });
|
|
78
|
+
* if (!session) {
|
|
79
|
+
* return redirect("/admin/login");
|
|
80
|
+
* }
|
|
81
|
+
* console.log("User:", session.user.name);
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
async function getAdminSession({ request, cms }) {
|
|
85
|
+
if (!cms.auth) return null;
|
|
86
|
+
try {
|
|
87
|
+
const session = await cms.auth.api.getSession({ headers: request.headers });
|
|
88
|
+
if (!session || !session.user) return null;
|
|
89
|
+
return session;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error("getAdminSession: Error getting session", error);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check if the current user has admin role on the server.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* const isAdmin = await isAdminUser({ request, cms });
|
|
101
|
+
* if (!isAdmin) {
|
|
102
|
+
* return json({ error: "Unauthorized" }, { status: 403 });
|
|
103
|
+
* }
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
async function isAdminUser({ request, cms, requiredRole = "admin" }) {
|
|
107
|
+
return ((await getAdminSession({
|
|
108
|
+
request,
|
|
109
|
+
cms
|
|
110
|
+
}))?.user)?.role === requiredRole;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/server/adapters/tanstack.ts
|
|
115
|
+
/**
|
|
116
|
+
* Create a TanStack Router beforeLoad guard for admin authentication.
|
|
117
|
+
*
|
|
118
|
+
* Returns a function that can be used as beforeLoad in route definitions.
|
|
119
|
+
* Throws a redirect Response when authentication fails.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { createFileRoute } from "@tanstack/react-router";
|
|
124
|
+
* import { createTanStackAuthGuard } from "@questpie/admin/server/adapters/tanstack";
|
|
125
|
+
* import { cms } from "~/questpie/server/cms";
|
|
126
|
+
*
|
|
127
|
+
* export const Route = createFileRoute("/admin")({
|
|
128
|
+
* beforeLoad: createTanStackAuthGuard({
|
|
129
|
+
* cms,
|
|
130
|
+
* loginPath: "/admin/login",
|
|
131
|
+
* requiredRole: "admin",
|
|
132
|
+
* }),
|
|
133
|
+
* component: AdminLayout,
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* @example With custom context
|
|
138
|
+
* ```ts
|
|
139
|
+
* export const Route = createFileRoute("/admin")({
|
|
140
|
+
* beforeLoad: async (ctx) => {
|
|
141
|
+
* // Run auth guard
|
|
142
|
+
* await createTanStackAuthGuard({ cms })(ctx);
|
|
143
|
+
*
|
|
144
|
+
* // Add additional context
|
|
145
|
+
* return { user: ctx.context.user };
|
|
146
|
+
* },
|
|
147
|
+
* });
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
function createTanStackAuthGuard({ cms, loginPath = "/admin/login", requiredRole = "admin", redirectParam = "redirect" }) {
|
|
151
|
+
return async function beforeLoad({ context }) {
|
|
152
|
+
const request = context.request;
|
|
153
|
+
if (!request) {
|
|
154
|
+
console.warn("createTanStackAuthGuard: No request in context. Make sure you're using TanStack Start with SSR enabled.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const redirect = await requireAdminAuth({
|
|
158
|
+
request,
|
|
159
|
+
cms,
|
|
160
|
+
loginPath,
|
|
161
|
+
requiredRole,
|
|
162
|
+
redirectParam
|
|
163
|
+
});
|
|
164
|
+
if (redirect) throw redirect;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create a TanStack Router loader that injects the admin session into context.
|
|
169
|
+
*
|
|
170
|
+
* Use this when you need access to the session in your components.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* import { createTanStackSessionLoader } from "@questpie/admin/server/adapters/tanstack";
|
|
175
|
+
*
|
|
176
|
+
* export const Route = createFileRoute("/admin")({
|
|
177
|
+
* loader: createTanStackSessionLoader({ cms }),
|
|
178
|
+
* component: AdminLayout,
|
|
179
|
+
* });
|
|
180
|
+
*
|
|
181
|
+
* function AdminLayout() {
|
|
182
|
+
* const { session } = Route.useLoaderData();
|
|
183
|
+
* return <div>Hello {session?.user?.name}</div>;
|
|
184
|
+
* }
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
function createTanStackSessionLoader({ cms }) {
|
|
188
|
+
return async function loader({ context }) {
|
|
189
|
+
const request = context.request;
|
|
190
|
+
if (!request || !cms.auth) return { session: null };
|
|
191
|
+
try {
|
|
192
|
+
return { session: await cms.auth.api.getSession({ headers: request.headers }) ?? null };
|
|
193
|
+
} catch {
|
|
194
|
+
return { session: null };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/server/adapters/nextjs.ts
|
|
201
|
+
/**
|
|
202
|
+
* Check if a path matches any of the given patterns
|
|
203
|
+
*/
|
|
204
|
+
function pathMatches(pathname, patterns) {
|
|
205
|
+
return patterns.some((pattern) => pathname === pattern || pathname.startsWith(`${pattern}/`) || pathname.startsWith(pattern));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Create a Next.js middleware for admin authentication.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* // middleware.ts
|
|
213
|
+
* import { createNextAuthMiddleware } from "@questpie/admin/server/adapters/nextjs";
|
|
214
|
+
* import { cms } from "./questpie/server/cms";
|
|
215
|
+
*
|
|
216
|
+
* export default createNextAuthMiddleware({
|
|
217
|
+
* cms,
|
|
218
|
+
* loginPath: "/admin/login",
|
|
219
|
+
* });
|
|
220
|
+
*
|
|
221
|
+
* export const config = {
|
|
222
|
+
* matcher: ["/admin/:path*"],
|
|
223
|
+
* };
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
function createNextAuthMiddleware({ cms, loginPath = "/admin/login", requiredRole = "admin", protectedPaths = ["/admin"], publicPaths = [
|
|
227
|
+
"/admin/login",
|
|
228
|
+
"/admin/forgot-password",
|
|
229
|
+
"/admin/reset-password",
|
|
230
|
+
"/admin/accept-invite"
|
|
231
|
+
], redirectParam = "redirect" }) {
|
|
232
|
+
return async function middleware(request) {
|
|
233
|
+
const pathname = new URL(request.url).pathname;
|
|
234
|
+
const isProtected = pathMatches(pathname, protectedPaths);
|
|
235
|
+
const isPublic = pathMatches(pathname, publicPaths);
|
|
236
|
+
if (!isProtected || isPublic) return new Response(null, {
|
|
237
|
+
status: 200,
|
|
238
|
+
headers: { "x-middleware-next": "1" }
|
|
239
|
+
});
|
|
240
|
+
const redirect = await requireAdminAuth({
|
|
241
|
+
request,
|
|
242
|
+
cms,
|
|
243
|
+
loginPath,
|
|
244
|
+
requiredRole,
|
|
245
|
+
redirectParam
|
|
246
|
+
});
|
|
247
|
+
if (redirect) return redirect;
|
|
248
|
+
return new Response(null, {
|
|
249
|
+
status: 200,
|
|
250
|
+
headers: { "x-middleware-next": "1" }
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get the admin session in a Next.js server component or API route.
|
|
256
|
+
*
|
|
257
|
+
* @example Server Component
|
|
258
|
+
* ```tsx
|
|
259
|
+
* // app/admin/layout.tsx
|
|
260
|
+
* import { getNextAdminSession } from "@questpie/admin/server/adapters/nextjs";
|
|
261
|
+
* import { headers } from "next/headers";
|
|
262
|
+
* import { cms } from "~/questpie/server/cms";
|
|
263
|
+
*
|
|
264
|
+
* export default async function AdminLayout({ children }) {
|
|
265
|
+
* const headersList = headers();
|
|
266
|
+
* const session = await getNextAdminSession({
|
|
267
|
+
* headers: headersList,
|
|
268
|
+
* cms,
|
|
269
|
+
* });
|
|
270
|
+
*
|
|
271
|
+
* if (!session) {
|
|
272
|
+
* redirect("/admin/login");
|
|
273
|
+
* }
|
|
274
|
+
*
|
|
275
|
+
* return <div>{children}</div>;
|
|
276
|
+
* }
|
|
277
|
+
* ```
|
|
278
|
+
*
|
|
279
|
+
* @example API Route
|
|
280
|
+
* ```ts
|
|
281
|
+
* // app/api/admin/users/route.ts
|
|
282
|
+
* import { getNextAdminSession } from "@questpie/admin/server/adapters/nextjs";
|
|
283
|
+
* import { cms } from "~/questpie/server/cms";
|
|
284
|
+
*
|
|
285
|
+
* export async function GET(request: Request) {
|
|
286
|
+
* const session = await getNextAdminSession({
|
|
287
|
+
* headers: request.headers,
|
|
288
|
+
* cms,
|
|
289
|
+
* });
|
|
290
|
+
*
|
|
291
|
+
* if (!session || session.user.role !== "admin") {
|
|
292
|
+
* return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
293
|
+
* }
|
|
294
|
+
*
|
|
295
|
+
* // ... handle request
|
|
296
|
+
* }
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
async function getNextAdminSession({ headers, cms }) {
|
|
300
|
+
return getAdminSession({
|
|
301
|
+
request: new Request("http://localhost", { headers }),
|
|
302
|
+
cms
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/server/modules/admin-preferences/collections/admin-preferences.collection.ts
|
|
308
|
+
/**
|
|
309
|
+
* Admin Preferences Collection
|
|
310
|
+
*
|
|
311
|
+
* Stores user-specific preferences for admin UI state.
|
|
312
|
+
* This includes view configurations (columns, filters, sort)
|
|
313
|
+
* that persist across devices and sessions.
|
|
314
|
+
*
|
|
315
|
+
* Key format: "viewState:{collectionName}" for view state preferences
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```ts
|
|
319
|
+
* import { adminModule } from "@questpie/admin/server";
|
|
320
|
+
*
|
|
321
|
+
* const cms = q({ name: "my-app" })
|
|
322
|
+
* .use(adminModule)
|
|
323
|
+
* .collections({ ... })
|
|
324
|
+
* .build({ ... });
|
|
325
|
+
*
|
|
326
|
+
* // Access preferences
|
|
327
|
+
* const prefs = await cms.api.collections.admin_preferences.findOne({
|
|
328
|
+
* where: { userId: currentUser.id, key: "viewState:posts" }
|
|
329
|
+
* });
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
const adminPreferencesCollection = q.collection("admin_preferences").fields({
|
|
333
|
+
userId: varchar("user_id", { length: 255 }).notNull(),
|
|
334
|
+
key: varchar("key", { length: 255 }).notNull(),
|
|
335
|
+
value: jsonb("value").notNull()
|
|
336
|
+
}).options({ timestamps: true }).indexes(({ table }) => [uniqueIndex("admin_preferences_user_key_idx").on(table.userId, table.key)]);
|
|
337
|
+
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/server/modules/admin-preferences/collections/saved-views.collection.ts
|
|
340
|
+
/**
|
|
341
|
+
* Admin Saved Views Collection
|
|
342
|
+
*
|
|
343
|
+
* Stores user-specific view configurations for collection lists.
|
|
344
|
+
* Each view can contain:
|
|
345
|
+
* - Filter rules (field/operator/value combinations)
|
|
346
|
+
* - Sort configuration (field/direction)
|
|
347
|
+
* - Visible columns selection
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* import { adminModule } from "@questpie/admin/server";
|
|
352
|
+
*
|
|
353
|
+
* const cms = q({ name: "my-app" })
|
|
354
|
+
* .use(starterModule)
|
|
355
|
+
* .use(adminModule)
|
|
356
|
+
* .collections({ ... })
|
|
357
|
+
* .build({ ... });
|
|
358
|
+
*
|
|
359
|
+
* // Access saved views
|
|
360
|
+
* const views = await cms.api.collections.adminSavedViews.find({
|
|
361
|
+
* where: { collectionName: "posts", userId: currentUser.id }
|
|
362
|
+
* });
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
const savedViewsCollection = q.collection("admin_saved_views").fields({
|
|
366
|
+
userId: varchar("user_id", { length: 255 }).notNull(),
|
|
367
|
+
collectionName: varchar("collection_name", { length: 255 }).notNull(),
|
|
368
|
+
name: varchar("name", { length: 255 }).notNull(),
|
|
369
|
+
configuration: jsonb("configuration").notNull().$type(),
|
|
370
|
+
isDefault: boolean("is_default").default(false).notNull()
|
|
371
|
+
}).options({ timestamps: true });
|
|
372
|
+
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/server/modules/admin/functions/locales.ts
|
|
375
|
+
/**
|
|
376
|
+
* Content Locales Functions
|
|
377
|
+
*
|
|
378
|
+
* Functions for retrieving available content locales from the CMS configuration.
|
|
379
|
+
* Used by the admin panel to populate locale switchers and validate locale selection.
|
|
380
|
+
*/
|
|
381
|
+
/**
|
|
382
|
+
* Helper to get typed CMS app from handler context.
|
|
383
|
+
*/
|
|
384
|
+
function getApp$1(ctx) {
|
|
385
|
+
return ctx.app;
|
|
386
|
+
}
|
|
387
|
+
const getContentLocalesSchema = z.object({}).optional();
|
|
388
|
+
const getContentLocalesOutputSchema = z.object({
|
|
389
|
+
locales: z.array(z.object({
|
|
390
|
+
code: z.string(),
|
|
391
|
+
label: z.string().optional(),
|
|
392
|
+
fallback: z.boolean().optional(),
|
|
393
|
+
flagCountryCode: z.string().optional()
|
|
394
|
+
})),
|
|
395
|
+
defaultLocale: z.string(),
|
|
396
|
+
fallbacks: z.record(z.string(), z.string()).optional()
|
|
397
|
+
});
|
|
398
|
+
/**
|
|
399
|
+
* Get available content locales from CMS configuration.
|
|
400
|
+
*
|
|
401
|
+
* Returns the list of available locales for content localization,
|
|
402
|
+
* the default locale, and any fallback mappings.
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* const result = await client.rpc.getContentLocales({});
|
|
407
|
+
* // {
|
|
408
|
+
* // locales: [
|
|
409
|
+
* // { code: "en", label: "English", fallback: true },
|
|
410
|
+
* // { code: "sk", label: "Slovenčina" },
|
|
411
|
+
* // ],
|
|
412
|
+
* // defaultLocale: "en",
|
|
413
|
+
* // fallbacks: { "en-GB": "en" },
|
|
414
|
+
* // }
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
const getContentLocales = fn({
|
|
418
|
+
type: "query",
|
|
419
|
+
schema: getContentLocalesSchema,
|
|
420
|
+
outputSchema: getContentLocalesOutputSchema,
|
|
421
|
+
handler: async (ctx) => {
|
|
422
|
+
const localeConfig = getApp$1(ctx).config.locale;
|
|
423
|
+
if (!localeConfig) return {
|
|
424
|
+
locales: [{
|
|
425
|
+
code: "en",
|
|
426
|
+
label: "English",
|
|
427
|
+
fallback: true
|
|
428
|
+
}],
|
|
429
|
+
defaultLocale: "en"
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
locales: (typeof localeConfig.locales === "function" ? await localeConfig.locales() : localeConfig.locales).map((l) => ({
|
|
433
|
+
code: l.code,
|
|
434
|
+
label: l.label,
|
|
435
|
+
fallback: l.fallback,
|
|
436
|
+
flagCountryCode: l.flagCountryCode
|
|
437
|
+
})),
|
|
438
|
+
defaultLocale: localeConfig.defaultLocale,
|
|
439
|
+
fallbacks: localeConfig.fallbacks
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
/**
|
|
444
|
+
* Bundle of locale-related functions.
|
|
445
|
+
*/
|
|
446
|
+
const localeFunctions = { getContentLocales };
|
|
447
|
+
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/server/modules/admin/functions/preview.ts
|
|
450
|
+
/**
|
|
451
|
+
* Preview Functions - Server-side
|
|
452
|
+
*
|
|
453
|
+
* RPC functions for draft mode preview.
|
|
454
|
+
* Handles token minting for secure, shareable preview links.
|
|
455
|
+
*
|
|
456
|
+
* Browser-safe utilities (isDraftMode, createDraftModeCookie, etc.) are in @questpie/admin/shared
|
|
457
|
+
*/
|
|
458
|
+
function base64UrlEncode(input) {
|
|
459
|
+
return (Buffer.isBuffer(input) ? input : Buffer.from(input)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
460
|
+
}
|
|
461
|
+
function base64UrlDecode(input) {
|
|
462
|
+
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
463
|
+
const padding = base64.length % 4;
|
|
464
|
+
if (padding) base64 += "=".repeat(4 - padding);
|
|
465
|
+
return Buffer.from(base64, "base64").toString("utf8");
|
|
466
|
+
}
|
|
467
|
+
const mintPreviewTokenSchema = z.object({
|
|
468
|
+
path: z.string().min(1, "Path is required"),
|
|
469
|
+
ttlMs: z.number().positive().optional()
|
|
470
|
+
});
|
|
471
|
+
const mintPreviewTokenOutputSchema = z.object({
|
|
472
|
+
token: z.string(),
|
|
473
|
+
expiresAt: z.number()
|
|
474
|
+
});
|
|
475
|
+
const verifyPreviewTokenSchema = z.object({ token: z.string() });
|
|
476
|
+
const verifyPreviewTokenOutputSchema = z.object({
|
|
477
|
+
valid: z.boolean(),
|
|
478
|
+
path: z.string().optional(),
|
|
479
|
+
error: z.string().optional()
|
|
480
|
+
});
|
|
481
|
+
const DEFAULT_TTL_MS = 3600 * 1e3;
|
|
482
|
+
/**
|
|
483
|
+
* Create preview-related RPC functions.
|
|
484
|
+
*
|
|
485
|
+
* @param secret - Secret key for signing tokens
|
|
486
|
+
* @returns Object with preview functions
|
|
487
|
+
*/
|
|
488
|
+
function createPreviewFunctions(secret) {
|
|
489
|
+
const signPayload = (payload) => {
|
|
490
|
+
return base64UrlEncode(createHmac("sha256", secret).update(payload).digest());
|
|
491
|
+
};
|
|
492
|
+
return {
|
|
493
|
+
mintPreviewToken: fn({
|
|
494
|
+
type: "mutation",
|
|
495
|
+
schema: mintPreviewTokenSchema,
|
|
496
|
+
outputSchema: mintPreviewTokenOutputSchema,
|
|
497
|
+
handler: async ({ input, session }) => {
|
|
498
|
+
if (!session) throw new Error("Unauthorized: Admin session required");
|
|
499
|
+
const { path, ttlMs = DEFAULT_TTL_MS } = input;
|
|
500
|
+
const expiresAt = Date.now() + ttlMs;
|
|
501
|
+
const payload = {
|
|
502
|
+
path,
|
|
503
|
+
exp: expiresAt
|
|
504
|
+
};
|
|
505
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
506
|
+
return {
|
|
507
|
+
token: `${encodedPayload}.${signPayload(encodedPayload)}`,
|
|
508
|
+
expiresAt
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}),
|
|
512
|
+
verifyPreviewToken: fn({
|
|
513
|
+
type: "query",
|
|
514
|
+
schema: verifyPreviewTokenSchema,
|
|
515
|
+
outputSchema: verifyPreviewTokenOutputSchema,
|
|
516
|
+
handler: async ({ input }) => {
|
|
517
|
+
const { token } = input;
|
|
518
|
+
const [encodedPayload, signature] = token.split(".");
|
|
519
|
+
if (!encodedPayload || !signature) return {
|
|
520
|
+
valid: false,
|
|
521
|
+
error: "Invalid token format"
|
|
522
|
+
};
|
|
523
|
+
const expectedSignature = signPayload(encodedPayload);
|
|
524
|
+
const signatureBuffer = Uint8Array.from(Buffer.from(signature));
|
|
525
|
+
const expectedBuffer = Uint8Array.from(Buffer.from(expectedSignature));
|
|
526
|
+
if (signatureBuffer.length !== expectedBuffer.length) return {
|
|
527
|
+
valid: false,
|
|
528
|
+
error: "Invalid signature"
|
|
529
|
+
};
|
|
530
|
+
if (!timingSafeEqual(signatureBuffer, expectedBuffer)) return {
|
|
531
|
+
valid: false,
|
|
532
|
+
error: "Invalid signature"
|
|
533
|
+
};
|
|
534
|
+
try {
|
|
535
|
+
const payload = JSON.parse(base64UrlDecode(encodedPayload));
|
|
536
|
+
if (!payload?.exp || typeof payload.exp !== "number") return {
|
|
537
|
+
valid: false,
|
|
538
|
+
error: "Invalid payload"
|
|
539
|
+
};
|
|
540
|
+
if (payload.exp < Date.now()) return {
|
|
541
|
+
valid: false,
|
|
542
|
+
error: "Token expired"
|
|
543
|
+
};
|
|
544
|
+
if (!payload.path || typeof payload.path !== "string") return {
|
|
545
|
+
valid: false,
|
|
546
|
+
error: "Invalid path"
|
|
547
|
+
};
|
|
548
|
+
return {
|
|
549
|
+
valid: true,
|
|
550
|
+
path: payload.path
|
|
551
|
+
};
|
|
552
|
+
} catch {
|
|
553
|
+
return {
|
|
554
|
+
valid: false,
|
|
555
|
+
error: "Invalid payload"
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Verify a preview token without RPC.
|
|
564
|
+
* Used directly in route handlers where RPC is not available.
|
|
565
|
+
*
|
|
566
|
+
* @param token - The preview token to verify
|
|
567
|
+
* @param secret - The secret used to sign the token
|
|
568
|
+
* @returns The payload if valid, null otherwise
|
|
569
|
+
*/
|
|
570
|
+
function verifyPreviewTokenDirect(token, secret) {
|
|
571
|
+
const [encodedPayload, signature] = token.split(".");
|
|
572
|
+
if (!encodedPayload || !signature) return null;
|
|
573
|
+
const expectedSignature = base64UrlEncode(createHmac("sha256", secret).update(encodedPayload).digest());
|
|
574
|
+
const signatureBuffer = Uint8Array.from(Buffer.from(signature));
|
|
575
|
+
const expectedBuffer = Uint8Array.from(Buffer.from(expectedSignature));
|
|
576
|
+
if (signatureBuffer.length !== expectedBuffer.length) return null;
|
|
577
|
+
if (!timingSafeEqual(signatureBuffer, expectedBuffer)) return null;
|
|
578
|
+
try {
|
|
579
|
+
const payload = JSON.parse(base64UrlDecode(encodedPayload));
|
|
580
|
+
if (!payload?.exp || typeof payload.exp !== "number") return null;
|
|
581
|
+
if (payload.exp < Date.now()) return null;
|
|
582
|
+
if (!payload.path || typeof payload.path !== "string") return null;
|
|
583
|
+
return payload;
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Create a preview token verifier with bound secret.
|
|
590
|
+
* Use this in route handlers to avoid passing secret repeatedly.
|
|
591
|
+
*
|
|
592
|
+
* @param secret - The secret used to sign tokens (optional, uses env if not provided)
|
|
593
|
+
* @returns A verify function that only needs the token
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* ```ts
|
|
597
|
+
* // Create once at module level
|
|
598
|
+
* const verifyPreviewToken = createPreviewTokenVerifier();
|
|
599
|
+
*
|
|
600
|
+
* // Use in route handler
|
|
601
|
+
* const payload = verifyPreviewToken(token);
|
|
602
|
+
* if (!payload) {
|
|
603
|
+
* return new Response("Invalid token", { status: 401 });
|
|
604
|
+
* }
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
function createPreviewTokenVerifier(secret) {
|
|
608
|
+
const resolvedSecret = secret ?? getPreviewSecret();
|
|
609
|
+
return (token) => {
|
|
610
|
+
return verifyPreviewTokenDirect(token, resolvedSecret);
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Default preview functions bundle with env-based secret.
|
|
615
|
+
* Used by adminModule to register preview RPC functions.
|
|
616
|
+
*/
|
|
617
|
+
const previewFunctions = createPreviewFunctions(getPreviewSecret());
|
|
618
|
+
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/server/modules/admin/functions/setup.ts
|
|
621
|
+
/**
|
|
622
|
+
* Setup Functions
|
|
623
|
+
*
|
|
624
|
+
* Built-in functions for bootstrapping the first admin user.
|
|
625
|
+
* Solves the chicken-and-egg problem where invitation-based systems
|
|
626
|
+
* need an existing admin to create the first invitation.
|
|
627
|
+
*/
|
|
628
|
+
/**
|
|
629
|
+
* Helper to get typed CMS app from handler context.
|
|
630
|
+
* Used internally for better IDE support without affecting the public API.
|
|
631
|
+
*/
|
|
632
|
+
function getApp(ctx) {
|
|
633
|
+
return ctx.app;
|
|
634
|
+
}
|
|
635
|
+
const isSetupRequiredSchema = z.object({});
|
|
636
|
+
const isSetupRequiredOutputSchema = z.object({ required: z.boolean() });
|
|
637
|
+
const createFirstAdminSchema = z.object({
|
|
638
|
+
email: z.string().email("Invalid email address"),
|
|
639
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
640
|
+
name: z.string().min(2, "Name must be at least 2 characters")
|
|
641
|
+
});
|
|
642
|
+
const createFirstAdminOutputSchema = z.object({
|
|
643
|
+
success: z.boolean(),
|
|
644
|
+
user: z.object({
|
|
645
|
+
id: z.string(),
|
|
646
|
+
email: z.string(),
|
|
647
|
+
name: z.string()
|
|
648
|
+
}).optional(),
|
|
649
|
+
error: z.string().optional()
|
|
650
|
+
});
|
|
651
|
+
/**
|
|
652
|
+
* Check if setup is required (no users exist in the system).
|
|
653
|
+
*
|
|
654
|
+
* @example
|
|
655
|
+
* ```ts
|
|
656
|
+
* const result = await client.rpc.isSetupRequired({});
|
|
657
|
+
* if (result.required) {
|
|
658
|
+
* // Redirect to setup page
|
|
659
|
+
* }
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
const isSetupRequired = fn({
|
|
663
|
+
type: "query",
|
|
664
|
+
schema: isSetupRequiredSchema,
|
|
665
|
+
outputSchema: isSetupRequiredOutputSchema,
|
|
666
|
+
handler: async (ctx) => {
|
|
667
|
+
const app = getApp(ctx);
|
|
668
|
+
const userCollection = app.getCollectionConfig("user");
|
|
669
|
+
return { required: (await app.db.select({ count: sql`count(*)::int` }).from(userCollection.table))[0].count === 0 };
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
/**
|
|
673
|
+
* Create the first admin user in the system.
|
|
674
|
+
* This function only works when no users exist (setup mode).
|
|
675
|
+
*
|
|
676
|
+
* Security: Once any user exists, this function will refuse to create more users.
|
|
677
|
+
* This prevents unauthorized admin creation after initial setup.
|
|
678
|
+
*
|
|
679
|
+
* @example
|
|
680
|
+
* ```ts
|
|
681
|
+
* const result = await client.rpc.createFirstAdmin({
|
|
682
|
+
* email: "admin@example.com",
|
|
683
|
+
* password: "securepassword123",
|
|
684
|
+
* name: "Admin User",
|
|
685
|
+
* });
|
|
686
|
+
*
|
|
687
|
+
* if (result.success) {
|
|
688
|
+
* // Redirect to login page
|
|
689
|
+
* } else {
|
|
690
|
+
* console.error(result.error);
|
|
691
|
+
* }
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
const createFirstAdmin = fn({
|
|
695
|
+
type: "mutation",
|
|
696
|
+
schema: createFirstAdminSchema,
|
|
697
|
+
outputSchema: createFirstAdminOutputSchema,
|
|
698
|
+
handler: async (ctx) => {
|
|
699
|
+
const app = getApp(ctx);
|
|
700
|
+
const input = ctx.input;
|
|
701
|
+
const userCollection = app.getCollectionConfig("user");
|
|
702
|
+
if ((await app.db.select({ count: sql`count(*)::int` }).from(userCollection.table))[0].count > 0) return {
|
|
703
|
+
success: false,
|
|
704
|
+
error: "Setup already completed - users exist in the system"
|
|
705
|
+
};
|
|
706
|
+
try {
|
|
707
|
+
const signUpResult = await app.auth.api.signUpEmail({ body: {
|
|
708
|
+
email: input.email,
|
|
709
|
+
password: input.password,
|
|
710
|
+
name: input.name
|
|
711
|
+
} });
|
|
712
|
+
if (!signUpResult.user) return {
|
|
713
|
+
success: false,
|
|
714
|
+
error: "Failed to create user account"
|
|
715
|
+
};
|
|
716
|
+
await app.db.update(userCollection.table).set({
|
|
717
|
+
role: "admin",
|
|
718
|
+
emailVerified: true
|
|
719
|
+
}).where(eq(userCollection.table.id, signUpResult.user.id));
|
|
720
|
+
return {
|
|
721
|
+
success: true,
|
|
722
|
+
user: {
|
|
723
|
+
id: signUpResult.user.id,
|
|
724
|
+
email: signUpResult.user.email,
|
|
725
|
+
name: signUpResult.user.name
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
} catch (error) {
|
|
729
|
+
return {
|
|
730
|
+
success: false,
|
|
731
|
+
error: error instanceof Error ? error.message : "An unexpected error occurred"
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
/**
|
|
737
|
+
* Bundle of setup-related functions.
|
|
738
|
+
*/
|
|
739
|
+
const setupFunctions = {
|
|
740
|
+
isSetupRequired,
|
|
741
|
+
createFirstAdmin
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/server/modules/admin/index.ts
|
|
746
|
+
/**
|
|
747
|
+
* Admin Module
|
|
748
|
+
*
|
|
749
|
+
* Complete backend module for running the QuestPie admin panel.
|
|
750
|
+
* This is the main entry point for setting up the admin backend.
|
|
751
|
+
*
|
|
752
|
+
* Includes:
|
|
753
|
+
* - Auth collections (users, sessions, accounts, verifications, apikeys)
|
|
754
|
+
* - Assets collection with file upload support
|
|
755
|
+
* - Admin saved views collection (named view configurations)
|
|
756
|
+
* - Admin preferences collection (user-specific view state)
|
|
757
|
+
* - Setup functions for bootstrapping first admin
|
|
758
|
+
* - Core auth options (Better Auth configuration)
|
|
759
|
+
*
|
|
760
|
+
* @example
|
|
761
|
+
* ```ts
|
|
762
|
+
* import { q } from "questpie";
|
|
763
|
+
* import { adminModule } from "@questpie/admin/server";
|
|
764
|
+
*
|
|
765
|
+
* const cms = q({ name: "my-app" })
|
|
766
|
+
* .use(adminModule)
|
|
767
|
+
* .collections({
|
|
768
|
+
* posts: postsCollection,
|
|
769
|
+
* })
|
|
770
|
+
* .build({
|
|
771
|
+
* db: { url: process.env.DATABASE_URL },
|
|
772
|
+
* storage: { driver: s3Driver(...) },
|
|
773
|
+
* });
|
|
774
|
+
* ```
|
|
775
|
+
*/
|
|
776
|
+
/**
|
|
777
|
+
* Admin Module - the complete backend for QuestPie admin panel.
|
|
778
|
+
*
|
|
779
|
+
* This module provides everything needed to run the admin panel:
|
|
780
|
+
* - User authentication (Better Auth) - from starterModule
|
|
781
|
+
* - File uploads (assets) - from starterModule
|
|
782
|
+
* - Saved views for collection filters (named configurations)
|
|
783
|
+
* - User preferences (view state synced across devices)
|
|
784
|
+
* - Setup flow for first admin creation
|
|
785
|
+
*
|
|
786
|
+
* @example
|
|
787
|
+
* ```ts
|
|
788
|
+
* import { q } from "questpie";
|
|
789
|
+
* import { adminModule } from "@questpie/admin/server";
|
|
790
|
+
*
|
|
791
|
+
* const cms = q({ name: "my-app" })
|
|
792
|
+
* .use(adminModule)
|
|
793
|
+
* .collections({
|
|
794
|
+
* posts: postsCollection,
|
|
795
|
+
* })
|
|
796
|
+
* .build({
|
|
797
|
+
* db: { url: process.env.DATABASE_URL },
|
|
798
|
+
* });
|
|
799
|
+
* ```
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* ```ts
|
|
803
|
+
* // Extend assets collection with custom fields
|
|
804
|
+
* import { q, collection, varchar } from "questpie";
|
|
805
|
+
* import { adminModule } from "@questpie/admin/server";
|
|
806
|
+
*
|
|
807
|
+
* const cms = q({ name: "my-app" })
|
|
808
|
+
* .use(adminModule)
|
|
809
|
+
* .collections({
|
|
810
|
+
* // Override assets with additional fields
|
|
811
|
+
* assets: adminModule.state.collections.assets.merge(
|
|
812
|
+
* collection("assets").fields({
|
|
813
|
+
* folder: varchar("folder", { length: 255 }),
|
|
814
|
+
* tags: varchar("tags", { length: 1000 }),
|
|
815
|
+
* })
|
|
816
|
+
* ),
|
|
817
|
+
* })
|
|
818
|
+
* .build({ ... });
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
const adminModule = q({ name: "questpie-admin" }).use(starterModule).collections({
|
|
822
|
+
admin_saved_views: savedViewsCollection,
|
|
823
|
+
admin_preferences: adminPreferencesCollection
|
|
824
|
+
}).functions({
|
|
825
|
+
...setupFunctions,
|
|
826
|
+
...localeFunctions,
|
|
827
|
+
...previewFunctions
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
//#endregion
|
|
831
|
+
export { adminModule, createFirstAdmin, createNextAuthMiddleware, createPreviewFunctions, createPreviewTokenVerifier, createTanStackAuthGuard, createTanStackSessionLoader, getAdminSession, getNextAdminSession, isAdminUser, isSetupRequired, requireAdminAuth, savedViewsCollection, setupFunctions, verifyPreviewTokenDirect };
|
|
832
|
+
//# sourceMappingURL=server.mjs.map
|