create-vexcms 0.0.19 → 0.1.0-alpha.2
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/LICENSE +202 -0
- package/README.md +93 -83
- package/dist/index.js +246 -81
- package/dist/index.mjs +30976 -0
- package/package.json +35 -15
- package/templates/base-nextjs/README.md +53 -205
- package/templates/base-nextjs/_env.example +7 -9
- package/templates/base-nextjs/_gitignore +41 -1
- package/templates/base-nextjs/convex/_generated/api.d.ts +6 -62
- package/templates/base-nextjs/convex/_generated/server.d.ts +19 -0
- package/templates/base-nextjs/convex/_generated/server.js +8 -0
- package/templates/base-nextjs/convex/auth/api.ts +49 -3
- package/templates/base-nextjs/convex/auth/db.ts +42 -300
- package/templates/base-nextjs/convex/auth/index.ts +11 -7
- package/templates/base-nextjs/convex/auth/options.ts +5 -29
- package/templates/base-nextjs/convex/auth/plugins/index.ts +15 -5
- package/templates/base-nextjs/convex/auth/sessions.ts +9 -15
- package/templates/base-nextjs/convex/http.ts +18 -3
- package/templates/base-nextjs/convex/schema.ts +104 -2
- package/templates/base-nextjs/convex/vex/firstUser.ts +10 -84
- package/templates/base-nextjs/convex/vex/globals.ts +19 -82
- package/templates/base-nextjs/convex/vex/media.ts +19 -246
- package/templates/base-nextjs/convex/vex.schema.ts +138 -0
- package/templates/base-nextjs/convex/vex.ts +19 -0
- package/templates/base-nextjs/convex.json +6 -0
- package/templates/base-nextjs/eslint.config.mjs +1 -21
- package/templates/base-nextjs/next.config.ts +10 -0
- package/templates/base-nextjs/package.json +47 -48
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +17 -5
- package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +2 -4
- package/templates/base-nextjs/src/app/(frontend)/layout.tsx +12 -2
- package/templates/base-nextjs/src/app/(frontend)/page.tsx +2 -113
- package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +40 -0
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +22 -0
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +19 -0
- package/templates/base-nextjs/src/app/globals.css +240 -104
- package/templates/base-nextjs/src/app/layout.tsx +23 -13
- package/templates/base-nextjs/src/app/unauthorized/page.tsx +24 -0
- package/templates/base-nextjs/src/auth/access.ts +46 -0
- package/templates/base-nextjs/src/auth/client.tsx +17 -5
- package/templates/base-nextjs/src/auth/hasPermission.ts +39 -0
- package/templates/base-nextjs/src/auth/serverUtils.ts +15 -22
- package/templates/base-nextjs/src/components/WelcomePage.tsx +116 -0
- package/templates/base-nextjs/src/components/component-example.tsx +56 -464
- package/templates/base-nextjs/src/components/providers/auth.tsx +13 -0
- package/templates/base-nextjs/src/components/providers/client.tsx +6 -1
- package/templates/base-nextjs/src/components/providers/convex.tsx +53 -21
- package/templates/base-nextjs/src/components/providers/server.tsx +10 -7
- package/templates/{marketing-site → base-nextjs}/src/components/ui/accordion.tsx +30 -32
- package/templates/base-nextjs/src/components/ui/badge.tsx +10 -13
- package/templates/base-nextjs/src/components/ui/input.tsx +35 -5
- package/templates/base-nextjs/src/context/AuthContext.tsx +29 -0
- package/templates/base-nextjs/src/db/constants/auth.ts +0 -1
- package/templates/base-nextjs/src/db/constants/index.ts +22 -35
- package/templates/base-nextjs/src/lib/utils.ts +0 -12
- package/templates/base-nextjs/src/proxy.ts +98 -11
- package/templates/base-nextjs/src/vex.config.ts +35 -0
- package/templates/base-nextjs/src/vex.types.ts +293 -0
- package/templates/base-nextjs/src/vexcms/collections/images.ts +15 -0
- package/templates/base-nextjs/src/vexcms/collections/index.ts +2 -1
- package/templates/base-nextjs/src/vexcms/collections/users.ts +7 -35
- package/templates/base-nextjs/tsconfig.json +3 -0
- package/templates/marketing-site/convex/_generated/api.d.ts +81 -0
- package/templates/marketing-site/convex/_generated/api.js +23 -0
- package/templates/marketing-site/convex/_generated/dataModel.d.ts +60 -0
- package/templates/marketing-site/convex/_generated/server.d.ts +162 -0
- package/templates/marketing-site/convex/_generated/server.js +101 -0
- package/templates/marketing-site/convex/footers.ts +11 -7
- package/templates/marketing-site/convex/headers.ts +15 -5
- package/templates/marketing-site/convex/pages.ts +20 -35
- package/templates/marketing-site/convex/schema.ts +119 -0
- package/templates/marketing-site/convex/seed.ts +582 -35
- package/templates/marketing-site/convex/siteSettings.ts +19 -5
- package/templates/marketing-site/convex/theme.ts +55 -61
- package/templates/marketing-site/convex/vex.schema.ts +355 -0
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +27 -160
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +6 -9
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +4 -2
- package/templates/marketing-site/src/app/(frontend)/page.tsx +2 -5
- package/templates/marketing-site/src/components/SiteFooter.tsx +6 -3
- package/templates/marketing-site/src/components/SiteHeader.tsx +6 -3
- package/templates/marketing-site/src/components/ThemeLive.tsx +59 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +45 -89
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +2 -2
- package/templates/marketing-site/src/lib/metadata.ts +51 -72
- package/templates/marketing-site/src/vex.config.ts +35 -0
- package/templates/marketing-site/src/vex.types.ts +634 -0
- package/templates/marketing-site/src/vexcms/api.ts +23 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -3
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +2 -2
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -4
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +2 -2
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +4 -8
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +2 -2
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +7 -11
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +1 -1
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +9 -9
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +4 -5
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +3 -4
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +10 -19
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +4 -9
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +3 -6
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +28 -68
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +13 -43
- package/templates/marketing-site/src/vexcms/blocks/config.ts +7 -22
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +2 -3
- package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -8
- package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -1
- package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -1
- package/templates/marketing-site/src/vexcms/collections/index.ts +2 -1
- package/templates/marketing-site/src/vexcms/collections/pages.ts +12 -21
- package/templates/marketing-site/src/vexcms/collections/themeColors.ts +118 -0
- package/templates/marketing-site/src/vexcms/collections/themes.ts +29 -111
- package/templates/marketing-site/src/vexcms/globals/index.ts +1 -1
- package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +43 -27
- package/templates/base-nextjs/convex/auth/adapter/index.ts +0 -230
- package/templates/base-nextjs/convex/auth/adapter/utils.ts +0 -547
- package/templates/base-nextjs/convex/auth/config.ts +0 -10
- package/templates/base-nextjs/convex/vex/auth.ts +0 -38
- package/templates/base-nextjs/convex/vex/collections.ts +0 -321
- package/templates/base-nextjs/convex/vex/helpers.ts +0 -33
- package/templates/base-nextjs/convex/vex/impersonation.ts +0 -51
- package/templates/base-nextjs/convex/vex/migrate.ts +0 -74
- package/templates/base-nextjs/convex/vex/model/collections.ts +0 -196
- package/templates/base-nextjs/convex/vex/model/media.ts +0 -87
- package/templates/base-nextjs/convex/vex/model/versions.ts +0 -254
- package/templates/base-nextjs/convex/vex/previewSnapshot.ts +0 -53
- package/templates/base-nextjs/convex/vex/versions.ts +0 -648
- package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +0 -33
- package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +0 -38
- package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +0 -101
- package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +0 -15
- package/templates/base-nextjs/src/app/admin/layout.tsx +0 -51
- package/templates/base-nextjs/src/app/favicon.ico +0 -0
- package/templates/base-nextjs/src/components/providers/theme.tsx +0 -11
- package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +0 -67
- package/templates/base-nextjs/src/db/types.ts +0 -6
- package/templates/base-nextjs/src/vexcms/access.ts +0 -44
- package/templates/base-nextjs/src/vexcms/auth.ts +0 -4
- package/templates/base-nextjs/src/vexcms/collections/media.ts +0 -15
- package/templates/base-nextjs/vex.config.ts +0 -25
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +0 -55
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +0 -27
- package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +0 -18
- package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +0 -71
- package/templates/marketing-site/src/components/ThemeInjector.tsx +0 -128
- package/templates/marketing-site/src/components/admin/IconPickerField.tsx +0 -134
- package/templates/marketing-site/src/components/admin/ThemeImport.tsx +0 -174
- package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +0 -33
- package/templates/marketing-site/src/db/constants/index.ts +0 -52
- package/templates/marketing-site/src/lib/colorConvert.ts +0 -171
- package/templates/marketing-site/src/vexcms/access.ts +0 -36
- package/templates/marketing-site/vex.config.ts +0 -32
|
@@ -6,27 +6,23 @@ import { TABLE_SLUG_USERS } from "~/db/constants"
|
|
|
6
6
|
import { USER_ROLES } from "~/db/constants/auth"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Check
|
|
10
|
-
* Used by
|
|
9
|
+
* Check whether the admin panel has been bootstrapped (at least one admin
|
|
10
|
+
* exists). Used by `WelcomePage` to decide between "Sign Up" and "Sign In".
|
|
11
11
|
*/
|
|
12
12
|
export const isBootstrapped = query({
|
|
13
13
|
args: {},
|
|
14
14
|
handler: async (ctx) => {
|
|
15
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
|
-
})
|
|
16
|
+
return allUsers.some((user) => user.roles?.includes(USER_ROLES.admin))
|
|
21
17
|
},
|
|
22
18
|
})
|
|
23
19
|
|
|
24
20
|
/**
|
|
25
|
-
* Promote the current user to admin if no admin exists yet.
|
|
26
|
-
*
|
|
21
|
+
* Promote the current user to admin if no admin exists yet. Called after the
|
|
22
|
+
* first user signs up.
|
|
27
23
|
*
|
|
28
|
-
* Convex mutations are serialized, so two simultaneous signups
|
|
29
|
-
*
|
|
24
|
+
* Convex mutations are serialized, so two simultaneous signups cannot both
|
|
25
|
+
* become admin — the second observes the first's promotion and no-ops.
|
|
30
26
|
*/
|
|
31
27
|
export const promoteFirstAdmin = mutation({
|
|
32
28
|
args: {},
|
|
@@ -36,7 +32,6 @@ export const promoteFirstAdmin = mutation({
|
|
|
36
32
|
throw new ConvexError("Not authenticated")
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
// Find the current user by email
|
|
40
35
|
const currentUser = await ctx.db
|
|
41
36
|
.query(TABLE_SLUG_USERS)
|
|
42
37
|
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
@@ -46,89 +41,20 @@ export const promoteFirstAdmin = mutation({
|
|
|
46
41
|
throw new ConvexError("User not found")
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
// Check if any user already has admin role
|
|
50
44
|
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
|
-
})
|
|
45
|
+
const hasAdmin = allUsers.some((user) => user.roles?.includes(USER_ROLES.admin))
|
|
55
46
|
|
|
56
47
|
if (hasAdmin) {
|
|
57
48
|
return { promoted: false }
|
|
58
49
|
}
|
|
59
50
|
|
|
60
|
-
|
|
61
|
-
const currentRoles = currentUser.role ?? []
|
|
51
|
+
const currentRoles = currentUser.roles ?? []
|
|
62
52
|
if (!currentRoles.includes(USER_ROLES.admin)) {
|
|
63
53
|
await ctx.db.patch(currentUser._id, {
|
|
64
|
-
|
|
54
|
+
roles: [...currentRoles, USER_ROLES.admin],
|
|
65
55
|
})
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
return { promoted: true }
|
|
69
59
|
},
|
|
70
60
|
})
|
|
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
|
-
})
|
|
@@ -1,82 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
globalSlug: v.string(),
|
|
21
|
-
_vexDrafts: v.optional(v.union(v.literal("snapshot"), v.boolean())),
|
|
22
|
-
},
|
|
23
|
-
handler: async (ctx, { globalSlug, _vexDrafts }) => {
|
|
24
|
-
const doc = await ctx.db
|
|
25
|
-
.query(globalSlug as TableNamesInDataModel<DataModel>)
|
|
26
|
-
.first()
|
|
27
|
-
|
|
28
|
-
if (!doc) return null
|
|
29
|
-
|
|
30
|
-
const documentId = doc._id as string
|
|
31
|
-
|
|
32
|
-
// Check if this global has versioning enabled
|
|
33
|
-
const globalDef = config.globals?.find((g) => g.slug === globalSlug)
|
|
34
|
-
const isVersioned = !!globalDef?.versions?.drafts
|
|
35
|
-
|
|
36
|
-
// If draft mode is "snapshot", overlay the preview snapshot
|
|
37
|
-
if (_vexDrafts === "snapshot") {
|
|
38
|
-
const snapshot = await getPreviewSnapshot<DataModel>({
|
|
39
|
-
ctx,
|
|
40
|
-
collection: globalSlug,
|
|
41
|
-
documentId,
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
if (snapshot) {
|
|
45
|
-
return {
|
|
46
|
-
...doc,
|
|
47
|
-
...(snapshot as Record<string, unknown>),
|
|
48
|
-
_id: doc._id,
|
|
49
|
-
_creationTime: doc._creationTime,
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// If versioned and draft-aware, return the latest version's snapshot
|
|
55
|
-
if (isVersioned && _vexDrafts !== false) {
|
|
56
|
-
const latestVersion = await Versions.getLatestVersion<DataModel>({
|
|
57
|
-
ctx,
|
|
58
|
-
collection: globalSlug,
|
|
59
|
-
documentId,
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
if (latestVersion) {
|
|
63
|
-
return {
|
|
64
|
-
_id: doc._id,
|
|
65
|
-
...(latestVersion.snapshot as Record<string, unknown>),
|
|
66
|
-
vex_status: (latestVersion as any).status ?? (doc as any).vex_status ?? "draft",
|
|
67
|
-
vex_version: latestVersion.version,
|
|
68
|
-
vex_publishedAt: (doc as any).vex_publishedAt,
|
|
69
|
-
_creationTime: doc._creationTime,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// No versions yet — treat as published
|
|
74
|
-
const raw = doc as Record<string, unknown>
|
|
75
|
-
if (!raw.vex_status) {
|
|
76
|
-
raw.vex_status = "published"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return doc
|
|
81
|
-
},
|
|
82
|
-
})
|
|
1
|
+
import { createGetAuth } from "@vexcms/better-auth";
|
|
2
|
+
import { globalsApi } from "@vexcms/core/server";
|
|
3
|
+
|
|
4
|
+
import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
|
|
5
|
+
import config from "~/vex.config";
|
|
6
|
+
|
|
7
|
+
import { mutation, query } from "../_generated/server";
|
|
8
|
+
|
|
9
|
+
export const { get, find, upsert } = globalsApi({
|
|
10
|
+
config,
|
|
11
|
+
query,
|
|
12
|
+
mutation,
|
|
13
|
+
getAuth: createGetAuth({
|
|
14
|
+
orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
|
|
15
|
+
userCollectionSlug: TABLE_SLUG_USERS,
|
|
16
|
+
sessionCollectionSlug: TABLE_SLUG_SESSIONS,
|
|
17
|
+
resolveOrgs: true,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
@@ -1,246 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const user = await ctx.db
|
|
21
|
-
.query(TABLE_SLUG_USERS)
|
|
22
|
-
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
|
23
|
-
.first()
|
|
24
|
-
|
|
25
|
-
if (!user) return null
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
user: user as Record<string, unknown>,
|
|
29
|
-
roles: (user.role as string[]) ?? [],
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function requireUser(ctx: GenericQueryCtx<DataModel>) {
|
|
34
|
-
const result = await getUser(ctx)
|
|
35
|
-
if (!result) {
|
|
36
|
-
throw new ConvexError("Not authenticated")
|
|
37
|
-
}
|
|
38
|
-
return result
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function checkPermission(props: Parameters<typeof hasPermission>[0]) {
|
|
42
|
-
const result = hasPermission(props)
|
|
43
|
-
const denied =
|
|
44
|
-
result === false ||
|
|
45
|
-
(typeof result === "object" && !Object.values(result).some(Boolean))
|
|
46
|
-
|
|
47
|
-
if (denied) {
|
|
48
|
-
const grantingRoles: string[] = []
|
|
49
|
-
if (props.access) {
|
|
50
|
-
for (const role of props.access.roles) {
|
|
51
|
-
const check = hasPermission({ ...props, userRoles: [role] })
|
|
52
|
-
const allowed =
|
|
53
|
-
check === true ||
|
|
54
|
-
(typeof check === "object" && Object.values(check).some(Boolean))
|
|
55
|
-
if (allowed) grantingRoles.push(role)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const rolesHint =
|
|
60
|
-
grantingRoles.length > 0
|
|
61
|
-
? ` Requires one of: ${grantingRoles.join(", ")}`
|
|
62
|
-
: ""
|
|
63
|
-
throw new ConvexError(
|
|
64
|
-
`Access denied: "${props.action}" on "${props.resource}".${rolesHint}`,
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return result
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export const generateUploadUrl = mutation({
|
|
72
|
-
args: {},
|
|
73
|
-
handler: async (ctx) => {
|
|
74
|
-
return await ctx.storage.generateUploadUrl()
|
|
75
|
-
},
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
export const createMediaDocument = mutation({
|
|
79
|
-
args: {
|
|
80
|
-
collectionSlug: v.string(),
|
|
81
|
-
fields: v.any(),
|
|
82
|
-
},
|
|
83
|
-
handler: async (ctx, { collectionSlug, fields }) => {
|
|
84
|
-
const match = findCollectionBySlug({ slug: collectionSlug, config })
|
|
85
|
-
if (!match) {
|
|
86
|
-
throw new ConvexError(`Collection not found: ${collectionSlug}`)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const { user, roles } = await requireUser(ctx)
|
|
90
|
-
|
|
91
|
-
checkPermission({
|
|
92
|
-
access,
|
|
93
|
-
user,
|
|
94
|
-
userRoles: roles,
|
|
95
|
-
resource: collectionSlug,
|
|
96
|
-
action: "create",
|
|
97
|
-
data: fields as Record<string, unknown>,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
return await Media.createMediaDocument<DataModel>({
|
|
101
|
-
ctx,
|
|
102
|
-
args: {
|
|
103
|
-
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
104
|
-
fields: fields as Record<string, unknown>,
|
|
105
|
-
collectionFields: match.fields,
|
|
106
|
-
},
|
|
107
|
-
})
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
export const paginatedSearchDocuments = query({
|
|
112
|
-
args: {
|
|
113
|
-
collectionSlug: v.string(),
|
|
114
|
-
searchIndexName: v.string(),
|
|
115
|
-
searchField: v.string(),
|
|
116
|
-
query: v.string(),
|
|
117
|
-
paginationOpts: paginationOptsValidator,
|
|
118
|
-
},
|
|
119
|
-
handler: async (ctx, { collectionSlug, searchIndexName, searchField, query: searchQuery, paginationOpts }) => {
|
|
120
|
-
const result = await Media.paginatedSearchDocuments<DataModel>({
|
|
121
|
-
args: {
|
|
122
|
-
collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
|
|
123
|
-
searchIndexName,
|
|
124
|
-
searchField,
|
|
125
|
-
query: searchQuery,
|
|
126
|
-
paginationOpts,
|
|
127
|
-
},
|
|
128
|
-
ctx,
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
// Filter by read permission if user is authenticated
|
|
132
|
-
const auth = await getUser(ctx)
|
|
133
|
-
if (!auth) return result
|
|
134
|
-
|
|
135
|
-
const filteredPage = result.page.filter((doc: Record<string, unknown>) => {
|
|
136
|
-
const readAllowed = hasPermission({
|
|
137
|
-
access,
|
|
138
|
-
user: auth.user,
|
|
139
|
-
userRoles: auth.roles,
|
|
140
|
-
resource: collectionSlug,
|
|
141
|
-
action: "read",
|
|
142
|
-
data: doc,
|
|
143
|
-
})
|
|
144
|
-
return readAllowed === true
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
return { ...result, page: filteredPage }
|
|
148
|
-
},
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Downloads a file from a URL and stores it in Convex storage.
|
|
153
|
-
* Returns storageId + file metadata for the client to create a media document.
|
|
154
|
-
*/
|
|
155
|
-
export const downloadAndStoreUrl = action({
|
|
156
|
-
args: {
|
|
157
|
-
url: v.string(),
|
|
158
|
-
maxSize: v.number(),
|
|
159
|
-
},
|
|
160
|
-
handler: async (ctx, { url, maxSize }) => {
|
|
161
|
-
// 1. Validate URL
|
|
162
|
-
let parsedUrl: URL
|
|
163
|
-
try {
|
|
164
|
-
parsedUrl = new URL(url)
|
|
165
|
-
} catch {
|
|
166
|
-
throw new ConvexError("Invalid URL")
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// 2. Fetch with timeout
|
|
170
|
-
const controller = new AbortController()
|
|
171
|
-
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
|
172
|
-
|
|
173
|
-
let response: Response
|
|
174
|
-
try {
|
|
175
|
-
response = await fetch(parsedUrl.href, { signal: controller.signal })
|
|
176
|
-
} catch (err: any) {
|
|
177
|
-
clearTimeout(timeoutId)
|
|
178
|
-
if (err.name === "AbortError") {
|
|
179
|
-
throw new ConvexError("URL fetch timed out")
|
|
180
|
-
}
|
|
181
|
-
throw new ConvexError(`Failed to fetch URL: ${err.message}`)
|
|
182
|
-
} finally {
|
|
183
|
-
clearTimeout(timeoutId)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!response.ok) {
|
|
187
|
-
throw new ConvexError(`Failed to fetch URL: ${response.statusText}`)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// 3. Reject HTML
|
|
191
|
-
const contentType = response.headers.get("Content-Type") || ""
|
|
192
|
-
if (contentType.includes("text/html")) {
|
|
193
|
-
throw new ConvexError("URL points to an HTML page, not a file")
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// 4. Read body and check size
|
|
197
|
-
const arrayBuffer = await response.arrayBuffer()
|
|
198
|
-
if (arrayBuffer.byteLength > maxSize) {
|
|
199
|
-
throw new ConvexError(
|
|
200
|
-
`File size (${arrayBuffer.byteLength} bytes) exceeds maximum allowed (${maxSize} bytes)`,
|
|
201
|
-
)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 5. Extract filename
|
|
205
|
-
let filename = "download"
|
|
206
|
-
try {
|
|
207
|
-
const pathname = decodeURIComponent(parsedUrl.pathname)
|
|
208
|
-
const segments = pathname.split("/").filter(Boolean)
|
|
209
|
-
if (segments.length > 0) {
|
|
210
|
-
const lastSegment = segments[segments.length - 1]
|
|
211
|
-
// Strip query-like suffixes that might leak through
|
|
212
|
-
const clean = lastSegment.split("?")[0].split("#")[0]
|
|
213
|
-
if (clean) filename = clean
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
// Keep default filename
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Check Content-Disposition for filename
|
|
220
|
-
const disposition = response.headers.get("Content-Disposition")
|
|
221
|
-
if (disposition) {
|
|
222
|
-
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i)
|
|
223
|
-
if (filenameMatch?.[1]) {
|
|
224
|
-
filename = decodeURIComponent(filenameMatch[1].trim())
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// 6. Extract mimeType
|
|
229
|
-
let mimeType = "application/octet-stream"
|
|
230
|
-
if (contentType) {
|
|
231
|
-
mimeType = contentType.split(";")[0].trim()
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 7. Store in Convex storage
|
|
235
|
-
const blob = new Blob([arrayBuffer], { type: mimeType })
|
|
236
|
-
const storageId = await ctx.storage.store(blob)
|
|
237
|
-
|
|
238
|
-
// 8. Return metadata
|
|
239
|
-
return {
|
|
240
|
-
storageId: storageId as string,
|
|
241
|
-
filename,
|
|
242
|
-
mimeType,
|
|
243
|
-
size: arrayBuffer.byteLength,
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
})
|
|
1
|
+
import { createGetAuth } from "@vexcms/better-auth";
|
|
2
|
+
import { mediaApi } from "@vexcms/core";
|
|
3
|
+
|
|
4
|
+
import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
|
|
5
|
+
import config from "~/vex.config";
|
|
6
|
+
|
|
7
|
+
import { mutation, query } from "../_generated/server";
|
|
8
|
+
|
|
9
|
+
export const { getUrl, generateUploadUrl, createMediaDocument, deleteMedia } = mediaApi({
|
|
10
|
+
config,
|
|
11
|
+
query,
|
|
12
|
+
mutation,
|
|
13
|
+
getAuth: createGetAuth({
|
|
14
|
+
orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
|
|
15
|
+
userCollectionSlug: TABLE_SLUG_USERS,
|
|
16
|
+
sessionCollectionSlug: TABLE_SLUG_SESSIONS,
|
|
17
|
+
resolveOrgs: true,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ⚠️ AUTO-GENERATED BY VEX CMS — DO NOT EDIT ⚠️
|
|
2
|
+
// Run 'vex dev' or 'vex generate' to update this file.
|
|
3
|
+
|
|
4
|
+
import { defineTable } from "convex/server"
|
|
5
|
+
import { v } from "convex/values"
|
|
6
|
+
|
|
7
|
+
export const user = defineTable({
|
|
8
|
+
name: v.string(),
|
|
9
|
+
email: v.string(),
|
|
10
|
+
emailVerified: v.boolean(),
|
|
11
|
+
image: v.optional(v.string()),
|
|
12
|
+
createdAt: v.number(),
|
|
13
|
+
updatedAt: v.number(),
|
|
14
|
+
role: v.optional(v.string()),
|
|
15
|
+
banned: v.optional(v.boolean()),
|
|
16
|
+
banReason: v.optional(v.string()),
|
|
17
|
+
banExpires: v.optional(v.number()),
|
|
18
|
+
isAnonymous: v.optional(v.boolean()),
|
|
19
|
+
userId: v.optional(v.string()),
|
|
20
|
+
roles: v.array(v.string()),
|
|
21
|
+
}).index("by_email", ["email"])
|
|
22
|
+
|
|
23
|
+
export const session = defineTable({
|
|
24
|
+
expiresAt: v.number(),
|
|
25
|
+
token: v.string(),
|
|
26
|
+
createdAt: v.number(),
|
|
27
|
+
updatedAt: v.number(),
|
|
28
|
+
ipAddress: v.optional(v.string()),
|
|
29
|
+
userAgent: v.optional(v.string()),
|
|
30
|
+
userId: v.string(),
|
|
31
|
+
impersonatedBy: v.optional(v.string()),
|
|
32
|
+
activeOrganizationId: v.optional(v.string()),
|
|
33
|
+
})
|
|
34
|
+
.index("by_token", ["token"])
|
|
35
|
+
.index("by_userId", ["userId"])
|
|
36
|
+
|
|
37
|
+
export const account = defineTable({
|
|
38
|
+
accountId: v.string(),
|
|
39
|
+
providerId: v.string(),
|
|
40
|
+
userId: v.string(),
|
|
41
|
+
accessToken: v.optional(v.string()),
|
|
42
|
+
refreshToken: v.optional(v.string()),
|
|
43
|
+
idToken: v.optional(v.string()),
|
|
44
|
+
accessTokenExpiresAt: v.optional(v.number()),
|
|
45
|
+
refreshTokenExpiresAt: v.optional(v.number()),
|
|
46
|
+
scope: v.optional(v.string()),
|
|
47
|
+
password: v.optional(v.string()),
|
|
48
|
+
createdAt: v.number(),
|
|
49
|
+
updatedAt: v.number(),
|
|
50
|
+
}).index("by_userId", ["userId"])
|
|
51
|
+
|
|
52
|
+
export const verification = defineTable({
|
|
53
|
+
identifier: v.string(),
|
|
54
|
+
value: v.string(),
|
|
55
|
+
expiresAt: v.number(),
|
|
56
|
+
createdAt: v.number(),
|
|
57
|
+
updatedAt: v.number(),
|
|
58
|
+
}).index("by_identifier", ["identifier"])
|
|
59
|
+
|
|
60
|
+
export const apikey = defineTable({
|
|
61
|
+
configId: v.string(),
|
|
62
|
+
name: v.optional(v.string()),
|
|
63
|
+
start: v.optional(v.string()),
|
|
64
|
+
referenceId: v.string(),
|
|
65
|
+
prefix: v.optional(v.string()),
|
|
66
|
+
key: v.string(),
|
|
67
|
+
refillInterval: v.optional(v.number()),
|
|
68
|
+
refillAmount: v.optional(v.number()),
|
|
69
|
+
lastRefillAt: v.optional(v.number()),
|
|
70
|
+
enabled: v.optional(v.boolean()),
|
|
71
|
+
rateLimitEnabled: v.optional(v.boolean()),
|
|
72
|
+
rateLimitTimeWindow: v.optional(v.number()),
|
|
73
|
+
rateLimitMax: v.optional(v.number()),
|
|
74
|
+
requestCount: v.optional(v.number()),
|
|
75
|
+
remaining: v.optional(v.number()),
|
|
76
|
+
lastRequest: v.optional(v.number()),
|
|
77
|
+
expiresAt: v.optional(v.number()),
|
|
78
|
+
createdAt: v.number(),
|
|
79
|
+
updatedAt: v.number(),
|
|
80
|
+
permissions: v.optional(v.string()),
|
|
81
|
+
metadata: v.optional(v.string()),
|
|
82
|
+
})
|
|
83
|
+
.index("by_configId", ["configId"])
|
|
84
|
+
.index("by_referenceId", ["referenceId"])
|
|
85
|
+
.index("by_key", ["key"])
|
|
86
|
+
|
|
87
|
+
export const jwks = defineTable({
|
|
88
|
+
publicKey: v.string(),
|
|
89
|
+
privateKey: v.string(),
|
|
90
|
+
createdAt: v.number(),
|
|
91
|
+
expiresAt: v.optional(v.number()),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const organization = defineTable({
|
|
95
|
+
name: v.string(),
|
|
96
|
+
slug: v.string(),
|
|
97
|
+
logo: v.optional(v.string()),
|
|
98
|
+
createdAt: v.number(),
|
|
99
|
+
metadata: v.optional(v.string()),
|
|
100
|
+
}).index("by_slug", ["slug"])
|
|
101
|
+
|
|
102
|
+
export const member = defineTable({
|
|
103
|
+
organizationId: v.string(),
|
|
104
|
+
userId: v.string(),
|
|
105
|
+
role: v.string(),
|
|
106
|
+
createdAt: v.number(),
|
|
107
|
+
})
|
|
108
|
+
.index("by_organizationId", ["organizationId"])
|
|
109
|
+
.index("by_userId", ["userId"])
|
|
110
|
+
|
|
111
|
+
export const invitation = defineTable({
|
|
112
|
+
organizationId: v.string(),
|
|
113
|
+
email: v.string(),
|
|
114
|
+
role: v.optional(v.string()),
|
|
115
|
+
status: v.string(),
|
|
116
|
+
expiresAt: v.number(),
|
|
117
|
+
createdAt: v.number(),
|
|
118
|
+
inviterId: v.string(),
|
|
119
|
+
})
|
|
120
|
+
.index("by_organizationId", ["organizationId"])
|
|
121
|
+
.index("by_email", ["email"])
|
|
122
|
+
|
|
123
|
+
export const images = defineTable({
|
|
124
|
+
filename: v.string(),
|
|
125
|
+
alt: v.string(),
|
|
126
|
+
mimeType: v.string(),
|
|
127
|
+
size: v.number(),
|
|
128
|
+
storageId: v.string(),
|
|
129
|
+
deleted: v.optional(v.boolean()),
|
|
130
|
+
src: v.string(),
|
|
131
|
+
width: v.optional(v.number()),
|
|
132
|
+
height: v.optional(v.number()),
|
|
133
|
+
})
|
|
134
|
+
.index("by_deleted", ["deleted"])
|
|
135
|
+
.searchIndex("search_filename", {
|
|
136
|
+
searchField: "filename",
|
|
137
|
+
filterFields: ["alt"],
|
|
138
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createGetAuth } from "@vexcms/better-auth";
|
|
2
|
+
import { collectionsApi } from "@vexcms/core/server";
|
|
3
|
+
|
|
4
|
+
import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
|
|
5
|
+
import config from "~/vex.config";
|
|
6
|
+
|
|
7
|
+
import { mutation, query } from "./_generated/server";
|
|
8
|
+
|
|
9
|
+
export const { find, get, search, create, update, remove } = collectionsApi({
|
|
10
|
+
config,
|
|
11
|
+
query,
|
|
12
|
+
mutation,
|
|
13
|
+
getAuth: createGetAuth({
|
|
14
|
+
orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
|
|
15
|
+
userCollectionSlug: TABLE_SLUG_USERS,
|
|
16
|
+
sessionCollectionSlug: TABLE_SLUG_SESSIONS,
|
|
17
|
+
resolveOrgs: true,
|
|
18
|
+
}),
|
|
19
|
+
});
|