create-vexcms 0.0.3
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/dist/index.d.ts +1 -0
- package/dist/index.js +2409 -0
- package/package.json +37 -0
- package/templates/base-nextjs/.prettierignore +26 -0
- package/templates/base-nextjs/.prettierrc +7 -0
- package/templates/base-nextjs/README.md +256 -0
- package/templates/base-nextjs/_gitignore +2 -0
- package/templates/base-nextjs/components.json +24 -0
- package/templates/base-nextjs/convex/_generated/api.d.ts +125 -0
- package/templates/base-nextjs/convex/_generated/api.js +23 -0
- package/templates/base-nextjs/convex/_generated/dataModel.d.ts +60 -0
- package/templates/base-nextjs/convex/_generated/server.d.ts +143 -0
- package/templates/base-nextjs/convex/_generated/server.js +93 -0
- package/templates/base-nextjs/convex/auth/adapter/index.ts +230 -0
- package/templates/base-nextjs/convex/auth/adapter/utils.ts +547 -0
- package/templates/base-nextjs/convex/auth/api.ts +8 -0
- package/templates/base-nextjs/convex/auth/config.ts +10 -0
- package/templates/base-nextjs/convex/auth/db.ts +303 -0
- package/templates/base-nextjs/convex/auth/index.ts +14 -0
- package/templates/base-nextjs/convex/auth/options.ts +63 -0
- package/templates/base-nextjs/convex/auth/plugins/index.ts +20 -0
- package/templates/base-nextjs/convex/auth/sessions.ts +60 -0
- package/templates/base-nextjs/convex/auth.config.ts +7 -0
- package/templates/base-nextjs/convex/convex.config.ts +5 -0
- package/templates/base-nextjs/convex/http.ts +28 -0
- package/templates/base-nextjs/convex/schema.ts +3 -0
- package/templates/base-nextjs/convex/vex/auth.ts +38 -0
- package/templates/base-nextjs/convex/vex/collections.ts +321 -0
- package/templates/base-nextjs/convex/vex/firstUser.ts +134 -0
- package/templates/base-nextjs/convex/vex/helpers.ts +33 -0
- package/templates/base-nextjs/convex/vex/impersonation.ts +51 -0
- package/templates/base-nextjs/convex/vex/media.ts +246 -0
- package/templates/base-nextjs/convex/vex/migrate.ts +74 -0
- package/templates/base-nextjs/convex/vex/model/collections.ts +196 -0
- package/templates/base-nextjs/convex/vex/model/media.ts +87 -0
- package/templates/base-nextjs/convex/vex/model/versions.ts +254 -0
- package/templates/base-nextjs/convex/vex/previewSnapshot.ts +53 -0
- package/templates/base-nextjs/convex/vex/versions.ts +588 -0
- package/templates/base-nextjs/eslint.config.mjs +164 -0
- package/templates/base-nextjs/next.config.ts +10 -0
- package/templates/base-nextjs/package.json +67 -0
- package/templates/base-nextjs/postcss.config.mjs +7 -0
- package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
- package/templates/base-nextjs/public/file.svg +1 -0
- package/templates/base-nextjs/public/globe.svg +1 -0
- package/templates/base-nextjs/public/next.svg +1 -0
- package/templates/base-nextjs/public/vercel.svg +1 -0
- package/templates/base-nextjs/public/window.svg +1 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/page.tsx +9 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +23 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/default.tsx +3 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/page.tsx +3 -0
- package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/page.tsx +9 -0
- package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +11 -0
- package/templates/base-nextjs/src/app/(frontend)/layout.tsx +14 -0
- package/templates/base-nextjs/src/app/(frontend)/page.tsx +116 -0
- package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +33 -0
- package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +38 -0
- package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +101 -0
- package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +15 -0
- package/templates/base-nextjs/src/app/admin/layout.tsx +33 -0
- package/templates/base-nextjs/src/app/api/auth/[...all]/route.ts +6 -0
- package/templates/base-nextjs/src/app/favicon.ico +0 -0
- package/templates/base-nextjs/src/app/globals.css +130 -0
- package/templates/base-nextjs/src/app/layout.tsx +30 -0
- package/templates/base-nextjs/src/auth/client.tsx +41 -0
- package/templates/base-nextjs/src/auth/permissions.ts +102 -0
- package/templates/base-nextjs/src/auth/server.ts +16 -0
- package/templates/base-nextjs/src/auth/serverUtils.ts +64 -0
- package/templates/base-nextjs/src/auth/types.ts +3 -0
- package/templates/base-nextjs/src/components/component-example.tsx +474 -0
- package/templates/base-nextjs/src/components/example.tsx +52 -0
- package/templates/base-nextjs/src/components/providers/client.tsx +9 -0
- package/templates/base-nextjs/src/components/providers/convex.tsx +32 -0
- package/templates/base-nextjs/src/components/providers/server.tsx +15 -0
- package/templates/base-nextjs/src/components/providers/theme.tsx +11 -0
- package/templates/base-nextjs/src/components/ui/alert-dialog.tsx +162 -0
- package/templates/base-nextjs/src/components/ui/badge.tsx +52 -0
- package/templates/base-nextjs/src/components/ui/button.tsx +60 -0
- package/templates/base-nextjs/src/components/ui/card.tsx +92 -0
- package/templates/base-nextjs/src/components/ui/combobox.tsx +271 -0
- package/templates/base-nextjs/src/components/ui/dialog.tsx +135 -0
- package/templates/base-nextjs/src/components/ui/dropdown-menu.tsx +246 -0
- package/templates/base-nextjs/src/components/ui/field.tsx +224 -0
- package/templates/base-nextjs/src/components/ui/input-group.tsx +146 -0
- package/templates/base-nextjs/src/components/ui/input.tsx +20 -0
- package/templates/base-nextjs/src/components/ui/label.tsx +20 -0
- package/templates/base-nextjs/src/components/ui/select.tsx +189 -0
- package/templates/base-nextjs/src/components/ui/separator.tsx +21 -0
- package/templates/base-nextjs/src/components/ui/textarea.tsx +18 -0
- package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +67 -0
- package/templates/base-nextjs/src/db/constants/auth.ts +8 -0
- package/templates/base-nextjs/src/db/constants/index.ts +44 -0
- package/templates/base-nextjs/src/db/types.ts +6 -0
- package/templates/base-nextjs/src/env.mjs +48 -0
- package/templates/base-nextjs/src/lib/utils.ts +6 -0
- package/templates/base-nextjs/src/proxy.ts +23 -0
- package/templates/base-nextjs/src/vexcms/access.ts +28 -0
- package/templates/base-nextjs/src/vexcms/auth.ts +4 -0
- package/templates/base-nextjs/src/vexcms/collections/index.ts +1 -0
- package/templates/base-nextjs/src/vexcms/collections/media.ts +15 -0
- package/templates/base-nextjs/src/vexcms/collections/users.ts +46 -0
- package/templates/base-nextjs/tsconfig.json +60 -0
- package/templates/base-nextjs/vex.config.ts +23 -0
- package/templates/marketing-site/convex/pages.ts +46 -0
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +58 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +69 -0
- package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +71 -0
- package/templates/marketing-site/src/db/constants/index.ts +52 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +28 -0
- package/templates/marketing-site/src/vexcms/collections/headers.ts +35 -0
- package/templates/marketing-site/src/vexcms/collections/index.ts +7 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +40 -0
- package/templates/marketing-site/src/vexcms/collections/site_settings.ts +31 -0
- package/templates/marketing-site/src/vexcms/collections/themes.ts +37 -0
- package/templates/marketing-site/vex.config.ts +32 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { DataModel } from "@convex/_generated/dataModel"
|
|
2
|
+
import type { GenericQueryCtx, TableNamesInDataModel } from "convex/server"
|
|
3
|
+
|
|
4
|
+
import { mutation, query } from "@convex/_generated/server"
|
|
5
|
+
import { findCollectionBySlug, hasPermission } from "@vexcms/core"
|
|
6
|
+
import { paginationOptsValidator } from "convex/server"
|
|
7
|
+
import { ConvexError, v } from "convex/values"
|
|
8
|
+
|
|
9
|
+
import { TABLE_SLUG_USERS } from "~/db/constants"
|
|
10
|
+
|
|
11
|
+
import config from "../../vex.config"
|
|
12
|
+
import { access } from "../../src/vexcms/access"
|
|
13
|
+
import * as Collections from "./model/collections"
|
|
14
|
+
|
|
15
|
+
function requireCollection(slug: string) {
|
|
16
|
+
const match = findCollectionBySlug({ slug, config })
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new ConvexError(`Collection not found: ${slug}`)
|
|
19
|
+
}
|
|
20
|
+
return match
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Try to get the current user. Returns null if not authenticated.
|
|
25
|
+
* Use this for queries where auth is optional (read filtering).
|
|
26
|
+
*/
|
|
27
|
+
async function getUser(ctx: GenericQueryCtx<DataModel>) {
|
|
28
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
29
|
+
if (!identity?.email) return null
|
|
30
|
+
|
|
31
|
+
const user = await ctx.db
|
|
32
|
+
.query(TABLE_SLUG_USERS)
|
|
33
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
34
|
+
.first()
|
|
35
|
+
|
|
36
|
+
if (!user) return null
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
user: user as Record<string, unknown>,
|
|
40
|
+
roles: (user.role as string[]) ?? [],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the current user, throwing if not authenticated.
|
|
46
|
+
* Use this for mutations where auth is required.
|
|
47
|
+
*/
|
|
48
|
+
async function requireUser(ctx: GenericQueryCtx<DataModel>) {
|
|
49
|
+
const result = await getUser(ctx)
|
|
50
|
+
if (!result) {
|
|
51
|
+
throw new ConvexError("Not authenticated")
|
|
52
|
+
}
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check permission and throw ConvexError if denied.
|
|
58
|
+
* Error message includes which roles would grant the permission.
|
|
59
|
+
*/
|
|
60
|
+
function checkPermission(props: Parameters<typeof hasPermission>[0]) {
|
|
61
|
+
const result = hasPermission(props)
|
|
62
|
+
const denied =
|
|
63
|
+
result === false ||
|
|
64
|
+
(typeof result === "object" && !Object.values(result).some(Boolean))
|
|
65
|
+
|
|
66
|
+
if (denied) {
|
|
67
|
+
const grantingRoles: string[] = []
|
|
68
|
+
if (props.access) {
|
|
69
|
+
for (const role of props.access.roles) {
|
|
70
|
+
const check = hasPermission({ ...props, userRoles: [role] })
|
|
71
|
+
const allowed =
|
|
72
|
+
check === true ||
|
|
73
|
+
(typeof check === "object" && Object.values(check).some(Boolean))
|
|
74
|
+
if (allowed) grantingRoles.push(role)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rolesHint =
|
|
79
|
+
grantingRoles.length > 0
|
|
80
|
+
? ` Requires one of: ${grantingRoles.join(", ")}`
|
|
81
|
+
: ""
|
|
82
|
+
throw new ConvexError(
|
|
83
|
+
`Access denied: "${props.action}" on "${props.resource}".${rolesHint}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const listDocuments = query({
|
|
91
|
+
args: {
|
|
92
|
+
collectionSlug: v.string(),
|
|
93
|
+
order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
|
|
94
|
+
paginationOpts: paginationOptsValidator,
|
|
95
|
+
},
|
|
96
|
+
handler: async (ctx, args) => {
|
|
97
|
+
const result = await Collections.listDocuments<DataModel>({
|
|
98
|
+
args: {
|
|
99
|
+
collectionSlug: args.collectionSlug as TableNamesInDataModel<DataModel>,
|
|
100
|
+
order: args.order,
|
|
101
|
+
paginationOpts: args.paginationOpts,
|
|
102
|
+
},
|
|
103
|
+
ctx,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Filter by read permission if user is authenticated
|
|
107
|
+
const auth = await getUser(ctx)
|
|
108
|
+
if (!auth) return result
|
|
109
|
+
|
|
110
|
+
const filteredPage = result.page.filter((doc: Record<string, unknown>) => {
|
|
111
|
+
const readAllowed = hasPermission({
|
|
112
|
+
access,
|
|
113
|
+
user: auth.user,
|
|
114
|
+
userRoles: auth.roles,
|
|
115
|
+
resource: args.collectionSlug,
|
|
116
|
+
action: "read",
|
|
117
|
+
data: doc,
|
|
118
|
+
})
|
|
119
|
+
return readAllowed === true
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return { ...result, page: filteredPage }
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
export const countDocuments = query({
|
|
127
|
+
args: { collectionSlug: v.string() },
|
|
128
|
+
handler: async (ctx, { collectionSlug }) => {
|
|
129
|
+
return await Collections.countDocuments<DataModel>({
|
|
130
|
+
args: { collectionSlug: collectionSlug as TableNamesInDataModel<DataModel> },
|
|
131
|
+
ctx,
|
|
132
|
+
})
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const getDocument = query({
|
|
137
|
+
args: {
|
|
138
|
+
collectionSlug: v.string(),
|
|
139
|
+
documentId: v.string(),
|
|
140
|
+
preview: v.optional(v.boolean()),
|
|
141
|
+
},
|
|
142
|
+
handler: async (ctx, { collectionSlug, documentId, preview }) => {
|
|
143
|
+
const doc = await Collections.getDocument<DataModel>({
|
|
144
|
+
args: {
|
|
145
|
+
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
146
|
+
documentId,
|
|
147
|
+
preview,
|
|
148
|
+
},
|
|
149
|
+
ctx,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (doc) {
|
|
153
|
+
const auth = await getUser(ctx)
|
|
154
|
+
if (auth) {
|
|
155
|
+
const readAllowed = hasPermission({
|
|
156
|
+
access,
|
|
157
|
+
user: auth.user,
|
|
158
|
+
userRoles: auth.roles,
|
|
159
|
+
resource: collectionSlug,
|
|
160
|
+
action: "read",
|
|
161
|
+
data: doc as Record<string, unknown>,
|
|
162
|
+
})
|
|
163
|
+
if (readAllowed !== true) return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return doc
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
export const updateDocument = mutation({
|
|
172
|
+
args: {
|
|
173
|
+
collectionSlug: v.string(),
|
|
174
|
+
documentId: v.string(),
|
|
175
|
+
fields: v.any(),
|
|
176
|
+
},
|
|
177
|
+
handler: async (ctx, { collectionSlug, documentId, fields }) => {
|
|
178
|
+
const match = requireCollection(collectionSlug)
|
|
179
|
+
const { user, roles } = await requireUser(ctx)
|
|
180
|
+
|
|
181
|
+
const fieldNames = Object.keys(fields as Record<string, unknown>)
|
|
182
|
+
checkPermission({
|
|
183
|
+
access,
|
|
184
|
+
user,
|
|
185
|
+
userRoles: roles,
|
|
186
|
+
resource: collectionSlug,
|
|
187
|
+
action: "update",
|
|
188
|
+
fields: fieldNames,
|
|
189
|
+
data: fields as Record<string, unknown>,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return await Collections.updateDocument<DataModel>({
|
|
193
|
+
args: {
|
|
194
|
+
collectionFields: match.fields,
|
|
195
|
+
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
196
|
+
documentId,
|
|
197
|
+
fields: fields as Record<string, unknown>,
|
|
198
|
+
},
|
|
199
|
+
ctx,
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
export const createDocument = mutation({
|
|
205
|
+
args: {
|
|
206
|
+
collectionSlug: v.string(),
|
|
207
|
+
fields: v.any(),
|
|
208
|
+
},
|
|
209
|
+
handler: async (ctx, { collectionSlug, fields }) => {
|
|
210
|
+
const match = requireCollection(collectionSlug)
|
|
211
|
+
const { user, roles } = await requireUser(ctx)
|
|
212
|
+
|
|
213
|
+
const fieldNames = Object.keys(fields as Record<string, unknown>)
|
|
214
|
+
checkPermission({
|
|
215
|
+
access,
|
|
216
|
+
user,
|
|
217
|
+
userRoles: roles,
|
|
218
|
+
resource: collectionSlug,
|
|
219
|
+
action: "create",
|
|
220
|
+
fields: fieldNames,
|
|
221
|
+
data: fields as Record<string, unknown>,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
return await Collections.createDocument<DataModel>({
|
|
225
|
+
args: {
|
|
226
|
+
collectionFields: match.fields,
|
|
227
|
+
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
228
|
+
fields: fields as Record<string, unknown>,
|
|
229
|
+
kind: match.kind,
|
|
230
|
+
},
|
|
231
|
+
ctx,
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
export const deleteDocument = mutation({
|
|
237
|
+
args: {
|
|
238
|
+
collectionSlug: v.string(),
|
|
239
|
+
documentId: v.string(),
|
|
240
|
+
},
|
|
241
|
+
handler: async (ctx, { collectionSlug, documentId }) => {
|
|
242
|
+
const match = requireCollection(collectionSlug)
|
|
243
|
+
const { user, roles } = await requireUser(ctx)
|
|
244
|
+
|
|
245
|
+
checkPermission({
|
|
246
|
+
access,
|
|
247
|
+
user,
|
|
248
|
+
userRoles: roles,
|
|
249
|
+
resource: collectionSlug,
|
|
250
|
+
action: "delete",
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
await Collections.deleteDocument<DataModel>({
|
|
254
|
+
args: {
|
|
255
|
+
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
256
|
+
documentId,
|
|
257
|
+
kind: match.kind,
|
|
258
|
+
},
|
|
259
|
+
ctx,
|
|
260
|
+
})
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
export const bulkDeleteDocuments = mutation({
|
|
265
|
+
args: {
|
|
266
|
+
collectionSlug: v.string(),
|
|
267
|
+
documentIds: v.array(v.string()),
|
|
268
|
+
},
|
|
269
|
+
handler: async (ctx, { collectionSlug, documentIds }) => {
|
|
270
|
+
const { user, roles } = await requireUser(ctx)
|
|
271
|
+
|
|
272
|
+
checkPermission({
|
|
273
|
+
access,
|
|
274
|
+
user,
|
|
275
|
+
userRoles: roles,
|
|
276
|
+
resource: collectionSlug,
|
|
277
|
+
action: "delete",
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
return await Collections.bulkDeleteDocuments<DataModel>({
|
|
281
|
+
args: { documentIds },
|
|
282
|
+
ctx,
|
|
283
|
+
})
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
export const searchDocuments = query({
|
|
288
|
+
args: {
|
|
289
|
+
collectionSlug: v.string(),
|
|
290
|
+
query: v.string(),
|
|
291
|
+
searchField: v.string(),
|
|
292
|
+
searchIndexName: v.string(),
|
|
293
|
+
},
|
|
294
|
+
handler: async (ctx, { collectionSlug, query: searchQuery, searchField, searchIndexName }) => {
|
|
295
|
+
const results = await Collections.searchDocuments<DataModel>({
|
|
296
|
+
args: {
|
|
297
|
+
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
298
|
+
query: searchQuery,
|
|
299
|
+
searchField,
|
|
300
|
+
searchIndexName,
|
|
301
|
+
},
|
|
302
|
+
ctx,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Filter by read permission if user is authenticated
|
|
306
|
+
const auth = await getUser(ctx)
|
|
307
|
+
if (!auth) return results
|
|
308
|
+
|
|
309
|
+
return (results as Record<string, unknown>[]).filter((doc) => {
|
|
310
|
+
const readAllowed = hasPermission({
|
|
311
|
+
access,
|
|
312
|
+
user: auth.user,
|
|
313
|
+
userRoles: auth.roles,
|
|
314
|
+
resource: collectionSlug,
|
|
315
|
+
action: "read",
|
|
316
|
+
data: doc,
|
|
317
|
+
})
|
|
318
|
+
return readAllowed === true
|
|
319
|
+
})
|
|
320
|
+
},
|
|
321
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ConvexError } from "convex/values"
|
|
2
|
+
|
|
3
|
+
import { mutation, query } from "../_generated/server"
|
|
4
|
+
|
|
5
|
+
import { TABLE_SLUG_USERS } from "~/db/constants"
|
|
6
|
+
import { USER_ROLES } from "~/db/constants/auth"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if the admin panel has been bootstrapped (at least one admin exists).
|
|
10
|
+
* Used by the landing page to determine whether to show "Sign Up" or "Sign In".
|
|
11
|
+
*/
|
|
12
|
+
export const isBootstrapped = query({
|
|
13
|
+
args: {},
|
|
14
|
+
handler: async (ctx) => {
|
|
15
|
+
const allUsers = await ctx.db.query(TABLE_SLUG_USERS).collect()
|
|
16
|
+
|
|
17
|
+
return allUsers.some((user) => {
|
|
18
|
+
const roles = user.role ?? []
|
|
19
|
+
return roles.includes(USER_ROLES.admin)
|
|
20
|
+
})
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Promote the current user to admin if no admin exists yet.
|
|
26
|
+
* Called after the first user signs up.
|
|
27
|
+
*
|
|
28
|
+
* Convex mutations are serialized, so two simultaneous signups
|
|
29
|
+
* cannot both become admin — the second will see the first's promotion.
|
|
30
|
+
*/
|
|
31
|
+
export const promoteFirstAdmin = mutation({
|
|
32
|
+
args: {},
|
|
33
|
+
handler: async (ctx) => {
|
|
34
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
35
|
+
if (!identity?.email) {
|
|
36
|
+
throw new ConvexError("Not authenticated")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Find the current user by email
|
|
40
|
+
const currentUser = await ctx.db
|
|
41
|
+
.query(TABLE_SLUG_USERS)
|
|
42
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
43
|
+
.first()
|
|
44
|
+
|
|
45
|
+
if (!currentUser) {
|
|
46
|
+
throw new ConvexError("User not found")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if any user already has admin role
|
|
50
|
+
const allUsers = await ctx.db.query(TABLE_SLUG_USERS).collect()
|
|
51
|
+
const hasAdmin = allUsers.some((user) => {
|
|
52
|
+
const roles = user.role ?? []
|
|
53
|
+
return roles.includes(USER_ROLES.admin)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (hasAdmin) {
|
|
57
|
+
return { promoted: false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Promote this user to admin
|
|
61
|
+
const currentRoles = currentUser.role ?? []
|
|
62
|
+
if (!currentRoles.includes(USER_ROLES.admin)) {
|
|
63
|
+
await ctx.db.patch(currentUser._id, {
|
|
64
|
+
role: [...currentRoles, USER_ROLES.admin],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { promoted: true }
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the onboarding completion status for a user.
|
|
74
|
+
*/
|
|
75
|
+
export const getOnboardingStatus = query({
|
|
76
|
+
args: {},
|
|
77
|
+
handler: async (ctx) => {
|
|
78
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
79
|
+
if (!identity?.email) return { complete: false }
|
|
80
|
+
|
|
81
|
+
const user = await ctx.db
|
|
82
|
+
.query(TABLE_SLUG_USERS)
|
|
83
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
84
|
+
.first()
|
|
85
|
+
|
|
86
|
+
if (!user) return { complete: false }
|
|
87
|
+
|
|
88
|
+
return { complete: !!(user as any).vex_onboarding_complete }
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mark the onboarding tour as complete for the current user.
|
|
94
|
+
*/
|
|
95
|
+
export const completeOnboarding = mutation({
|
|
96
|
+
args: {},
|
|
97
|
+
handler: async (ctx) => {
|
|
98
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
99
|
+
if (!identity?.email) return
|
|
100
|
+
|
|
101
|
+
const user = await ctx.db
|
|
102
|
+
.query(TABLE_SLUG_USERS)
|
|
103
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
104
|
+
.first()
|
|
105
|
+
|
|
106
|
+
if (!user) return
|
|
107
|
+
|
|
108
|
+
await ctx.db.patch(user._id, {
|
|
109
|
+
vex_onboarding_complete: true,
|
|
110
|
+
} as any)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reset the onboarding tour for the current user (restart tour).
|
|
116
|
+
*/
|
|
117
|
+
export const resetOnboarding = mutation({
|
|
118
|
+
args: {},
|
|
119
|
+
handler: async (ctx) => {
|
|
120
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
121
|
+
if (!identity?.email) return
|
|
122
|
+
|
|
123
|
+
const user = await ctx.db
|
|
124
|
+
.query(TABLE_SLUG_USERS)
|
|
125
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
126
|
+
.first()
|
|
127
|
+
|
|
128
|
+
if (!user) return
|
|
129
|
+
|
|
130
|
+
await ctx.db.patch(user._id, {
|
|
131
|
+
vex_onboarding_complete: false,
|
|
132
|
+
} as any)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createVexQuery } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { query } from "../_generated/server"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typed vexQuery builder — use this instead of `query` for queries
|
|
7
|
+
* that need draft/snapshot support (live preview, versioning).
|
|
8
|
+
*
|
|
9
|
+
* Automatically adds `_vexDrafts` arg and provides `ctx.drafts`
|
|
10
|
+
* with full return type inference from your DataModel.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { vexQuery } from "./vex/helpers"
|
|
15
|
+
* import { getPreviewSnapshot } from "@vexcms/core"
|
|
16
|
+
*
|
|
17
|
+
* export const getBySlug = vexQuery({
|
|
18
|
+
* args: { slug: v.string() },
|
|
19
|
+
* handler: async (ctx, args) => {
|
|
20
|
+
* const doc = await ctx.db.query("pages")
|
|
21
|
+
* .withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
22
|
+
* .first()
|
|
23
|
+
* if (!doc) return null
|
|
24
|
+
* if (ctx.drafts === "snapshot") {
|
|
25
|
+
* const snapshot = await getPreviewSnapshot({ ctx, collection: "pages", documentId: doc._id })
|
|
26
|
+
* if (snapshot) return { ...doc, ...snapshot }
|
|
27
|
+
* }
|
|
28
|
+
* return doc
|
|
29
|
+
* },
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const vexQuery = createVexQuery(query)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DataModel } from "@convex/_generated/dataModel"
|
|
2
|
+
import type { GenericQueryCtx } from "convex/server"
|
|
3
|
+
|
|
4
|
+
import { query } from "@convex/_generated/server"
|
|
5
|
+
|
|
6
|
+
import { TABLE_SLUG_USERS } from "~/db/constants"
|
|
7
|
+
|
|
8
|
+
import { access } from "../../src/vexcms/access"
|
|
9
|
+
|
|
10
|
+
async function getUser(ctx: GenericQueryCtx<DataModel>) {
|
|
11
|
+
const identity = await ctx.auth.getUserIdentity()
|
|
12
|
+
if (!identity?.email) return null
|
|
13
|
+
|
|
14
|
+
const user = await ctx.db
|
|
15
|
+
.query(TABLE_SLUG_USERS)
|
|
16
|
+
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
17
|
+
.first()
|
|
18
|
+
|
|
19
|
+
return user ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List users available for impersonation.
|
|
24
|
+
* Only accessible by users with adminRoles.
|
|
25
|
+
*/
|
|
26
|
+
export const listImpersonatableUsers = query({
|
|
27
|
+
args: {},
|
|
28
|
+
handler: async (ctx) => {
|
|
29
|
+
const currentUser = await getUser(ctx)
|
|
30
|
+
if (!currentUser) return []
|
|
31
|
+
|
|
32
|
+
const roles = (currentUser.role as string[]) ?? []
|
|
33
|
+
|
|
34
|
+
// Check if user has an adminRole
|
|
35
|
+
const isAdmin = roles.some((r) => access.adminRoles.includes(r))
|
|
36
|
+
if (!isAdmin) return []
|
|
37
|
+
|
|
38
|
+
// Fetch all users except the current one
|
|
39
|
+
const allUsers = await ctx.db.query(TABLE_SLUG_USERS).collect()
|
|
40
|
+
|
|
41
|
+
return allUsers
|
|
42
|
+
.filter((u) => u._id !== currentUser._id)
|
|
43
|
+
.map((u) => ({
|
|
44
|
+
id: u._id as string,
|
|
45
|
+
name: u.name as string,
|
|
46
|
+
email: u.email as string,
|
|
47
|
+
avatar: (u.image as string) ?? undefined,
|
|
48
|
+
roles: (u.role as string[]) ?? [],
|
|
49
|
+
}))
|
|
50
|
+
},
|
|
51
|
+
})
|