create-vexcms 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +48 -14
- package/dist/index.js +1714 -1504
- package/package.json +2 -2
- package/templates/base-nextjs/README.md +18 -5
- package/templates/base-nextjs/convex/auth/index.ts +2 -2
- package/templates/base-nextjs/convex/auth/options.ts +27 -26
- package/templates/base-nextjs/convex/auth/schema.ts +51 -0
- package/templates/base-nextjs/convex/auth/sessions.ts +3 -3
- package/templates/base-nextjs/convex/tsconfig.json +2 -2
- package/templates/base-nextjs/convex/vex/firstUser.ts +2 -2
- package/templates/base-nextjs/convex/vex/globals.ts +3 -2
- package/templates/base-nextjs/convex/vex/media.ts +3 -2
- package/templates/base-nextjs/convex/vex.schema.ts +1 -0
- package/templates/base-nextjs/convex/vex.ts +17 -4
- package/templates/base-nextjs/next.config.ts +52 -6
- package/templates/base-nextjs/package.json +18 -7
- package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +27 -15
- package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +2 -2
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +9 -14
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -6
- package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
- package/templates/base-nextjs/src/app/globals.css +10 -9
- package/templates/base-nextjs/src/app/layout.tsx +6 -0
- package/templates/base-nextjs/src/auth/access.ts +14 -2
- package/templates/base-nextjs/src/auth/serverUtils.ts +20 -6
- package/templates/base-nextjs/src/components/WelcomePage.tsx +54 -43
- package/templates/base-nextjs/src/components/component-example.tsx +1 -1
- package/templates/base-nextjs/src/components/providers/server.tsx +16 -8
- package/templates/base-nextjs/src/vex.config.server.ts +30 -0
- package/templates/base-nextjs/src/vex.config.ts +14 -11
- package/templates/base-nextjs/src/vex.types.ts +2 -1
- package/templates/base-nextjs/src/vexcms/collections/images.ts +1 -1
- package/templates/base-nextjs/src/vexcms/collections/users.ts +3 -1
- package/templates/marketing-site/convex/pages.ts +20 -1
- package/templates/marketing-site/convex/schema.ts +4 -4
- package/templates/marketing-site/convex/seed.ts +515 -70
- package/templates/marketing-site/convex/siteSettings.ts +1 -1
- package/templates/marketing-site/convex/theme.ts +3 -3
- package/templates/marketing-site/convex/vex.schema.ts +75 -38
- package/templates/marketing-site/public/favicon.svg +14 -0
- package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +64 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +63 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +50 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +33 -0
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +10 -24
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
- package/templates/marketing-site/src/app/globals.css +413 -0
- package/templates/marketing-site/src/app/layout.tsx +85 -0
- package/templates/marketing-site/src/app/robots.ts +13 -0
- package/templates/marketing-site/src/app/sitemap.ts +18 -0
- package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
- package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
- package/templates/marketing-site/src/components/CodePane.tsx +98 -0
- package/templates/marketing-site/src/components/Container.tsx +25 -0
- package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
- package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
- package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
- package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
- package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +8 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +8 -6
- package/templates/marketing-site/src/components/ThemeLive.tsx +1 -2
- package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -3
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
- package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
- package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
- package/templates/marketing-site/src/lib/highlight.ts +134 -0
- package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
- package/templates/marketing-site/src/lib/metadata.ts +61 -33
- package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
- package/templates/marketing-site/src/lib/vex.ts +18 -0
- package/templates/marketing-site/src/proxy.ts +179 -0
- package/templates/marketing-site/src/vex.config.server.ts +27 -0
- package/templates/marketing-site/src/vex.config.ts +27 -9
- package/templates/marketing-site/src/vex.types.ts +102 -76
- package/templates/marketing-site/src/vexcms/api.ts +2 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +40 -24
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -3
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +63 -50
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +1 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +43 -58
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +6 -3
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +90 -53
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +10 -3
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +205 -138
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +27 -12
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +174 -119
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +2 -1
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +59 -51
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +26 -5
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +256 -73
- package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +27 -11
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +3 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +23 -1
- package/templates/marketing-site/src/vexcms/collections/themeColors.ts +14 -14
- package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
- package/dist/index.mjs +0 -30976
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -53
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +0 -25
- package/templates/marketing-site/src/app/(frontend)/page.tsx +0 -21
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import type { Metadata } from "next"
|
|
2
2
|
|
|
3
|
-
import { fetchQuery } from "convex/nextjs"
|
|
4
|
-
|
|
5
3
|
import { api } from "@convex/_generated/api"
|
|
6
|
-
import {
|
|
4
|
+
import { vexMetadata } from "@vexcms/next/seo"
|
|
5
|
+
|
|
6
|
+
import { env } from "~/env.mjs"
|
|
7
|
+
import { vex } from "~/lib/vex"
|
|
7
8
|
|
|
8
9
|
const TITLE_SUFFIX = " | Vex CMS"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Returns the first candidate that is a non-blank string.
|
|
13
|
+
*
|
|
14
|
+
* Optional text fields in a vexcms collection seed as `""` rather than being
|
|
15
|
+
* absent, so `??` chains cannot express "fall back to the site default" — an
|
|
16
|
+
* empty override would win. Mirrors `apps/www/src/lib/metadata.ts`'s helper
|
|
17
|
+
* of the same name.
|
|
14
18
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
})) as { url?: string; error?: string }
|
|
21
|
-
return result.url
|
|
22
|
-
} catch {
|
|
23
|
-
return undefined
|
|
19
|
+
function firstNonBlank(...candidates: unknown[]): string | undefined {
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
22
|
+
return candidate
|
|
23
|
+
}
|
|
24
24
|
}
|
|
25
|
+
return undefined
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -29,32 +30,32 @@ async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
|
|
|
29
30
|
*
|
|
30
31
|
* Fetches site settings and, when a slug is given, the matching page
|
|
31
32
|
* document, then merges them — page-level `metaTitle`/`metaDescription`/
|
|
32
|
-
* `ogImage` win over the site's defaults from `siteSettings
|
|
33
|
+
* `ogImage` win over the site's defaults from `siteSettings` — and delegates
|
|
34
|
+
* the `Metadata` shape (title, description, `metadataBase`, canonical,
|
|
35
|
+
* conditional OG image) to `vexMetadata` (`@vexcms/next/seo`).
|
|
33
36
|
*
|
|
34
37
|
* @param props.slug - Optional page slug to fetch per-page SEO overrides
|
|
35
38
|
*/
|
|
36
39
|
export async function generatePageMetadata(props: { slug?: string }): Promise<Metadata> {
|
|
37
40
|
try {
|
|
38
|
-
const settings = (await
|
|
41
|
+
const settings = (await vex.query(api.siteSettings.get)) as null | Record<string, unknown>
|
|
39
42
|
if (!settings) {
|
|
40
43
|
return { title: "Untitled" }
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
let pageData: Record<string, unknown> | undefined
|
|
44
47
|
if (props.slug) {
|
|
45
|
-
const pages = (await
|
|
48
|
+
const pages = (await vex.query(api.pages.getBySlug, { slug: props.slug })) as
|
|
46
49
|
| Record<string, unknown>[]
|
|
47
50
|
| undefined
|
|
48
51
|
pageData = pages?.[0]
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
const pageTitle = (pageData?.metaTitle
|
|
52
|
-
const siteName = settings.name
|
|
53
|
-
const title =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
(settings.metaDescription as string | undefined) ??
|
|
57
|
-
(settings.description as string | undefined)
|
|
54
|
+
const pageTitle = firstNonBlank(pageData?.metaTitle, pageData?.title)
|
|
55
|
+
const siteName = firstNonBlank(settings.name)
|
|
56
|
+
const title =
|
|
57
|
+
(firstNonBlank(pageTitle, settings.metaTitle, siteName) ?? "Untitled") + TITLE_SUFFIX
|
|
58
|
+
const description = firstNonBlank(pageData?.metaDescription, settings.metaDescription, settings.description)
|
|
58
59
|
|
|
59
60
|
// `upload()` fields always store an array of media ids — the first entry
|
|
60
61
|
// is the selection. Page-level ogImage wins over the site default.
|
|
@@ -63,16 +64,26 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
|
|
|
63
64
|
const ogImageId = pageOgImageId ?? siteOgImageId
|
|
64
65
|
const ogImageUrl = ogImageId ? await resolveMediaUrl(ogImageId) : undefined
|
|
65
66
|
|
|
66
|
-
const twitterHandle = settings.twitterHandle
|
|
67
|
+
const twitterHandle = firstNonBlank(settings.twitterHandle)
|
|
68
|
+
const canonicalPath = props.slug && props.slug !== "home" ? `/${props.slug}` : "/"
|
|
67
69
|
|
|
68
|
-
const metadata
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const metadata = vexMetadata({
|
|
71
|
+
title,
|
|
72
|
+
description,
|
|
73
|
+
siteUrl: env.NEXT_PUBLIC_SITE_URL,
|
|
74
|
+
path: canonicalPath,
|
|
75
|
+
imageUrl: ogImageUrl,
|
|
76
|
+
})
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
// Discord, Slack and X pick the large-image layout from `twitter:card`, not
|
|
79
|
+
// from `og:image` alone — and Next auto-fills `card: "summary"` (a small
|
|
80
|
+
// thumbnail) whenever `twitter` is omitted. So the card type must be emitted
|
|
81
|
+
// whenever an image resolved, independently of whether a handle exists.
|
|
82
|
+
if (ogImageUrl || twitterHandle) {
|
|
83
|
+
metadata.twitter = {
|
|
84
|
+
card: ogImageUrl ? "summary_large_image" : "summary",
|
|
85
|
+
...(twitterHandle ? { site: twitterHandle } : {}),
|
|
86
|
+
}
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
return metadata
|
|
@@ -81,3 +92,20 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
|
|
|
81
92
|
return { title: "Vex CMS" }
|
|
82
93
|
}
|
|
83
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolves an `upload()` field's stored media id to a public URL via the
|
|
98
|
+
* Convex file storage adapter. Returns `undefined` when the media document
|
|
99
|
+
* no longer exists or the deployment is unreachable.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
|
|
102
|
+
try {
|
|
103
|
+
const result = (await vex.query(api.vex.media.getUrl, {
|
|
104
|
+
adapter: "convex",
|
|
105
|
+
mediaId,
|
|
106
|
+
})) as { error?: string; url?: string }
|
|
107
|
+
return result.url
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a `pages` document's slug to its public path. Shared by `vex.config.ts`'s
|
|
3
|
+
* `routes.map` and `pages.ts`'s `admin.livePreview.url`.
|
|
4
|
+
*/
|
|
5
|
+
export function resolvePagePath(slug: string | undefined): string | undefined {
|
|
6
|
+
if (!slug) return undefined
|
|
7
|
+
return slug === "home" ? "/" : `/${slug}`
|
|
8
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createVexServerClient } from "@vexcms/next/cache"
|
|
2
|
+
|
|
3
|
+
import { env } from "~/env.mjs"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The single shared Convex read client for every server component and route
|
|
7
|
+
* in this project.
|
|
8
|
+
*
|
|
9
|
+
* `createVexServerClient` (`@vexcms/next/cache`) wraps its `query` method in
|
|
10
|
+
* a `React.cache` created fresh on every call — so two files that each call
|
|
11
|
+
* `createVexServerClient()` themselves get two independent caches and never
|
|
12
|
+
* share a round trip, no matter how identical their reads are. Importing
|
|
13
|
+
* this one instance everywhere is what lets `generatePageMetadata`
|
|
14
|
+
* (`~/lib/metadata`) and the page calling it collapse their identical
|
|
15
|
+
* `pages.getBySlug` read into a single Convex call within one request,
|
|
16
|
+
* instead of two.
|
|
17
|
+
*/
|
|
18
|
+
export const vex = createVexServerClient({ url: env.NEXT_PUBLIC_CONVEX_URL })
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { api } from "@convex/_generated/api";
|
|
2
|
+
import {
|
|
3
|
+
LIVE_PREVIEW_COOKIE,
|
|
4
|
+
LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS,
|
|
5
|
+
LIVE_PREVIEW_QUERY_PARAM,
|
|
6
|
+
} from "@vexcms/core";
|
|
7
|
+
import { fetchQuery } from "convex/nextjs";
|
|
8
|
+
import { cookies } from "next/headers";
|
|
9
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
10
|
+
|
|
11
|
+
const SESSION_COOKIES = {
|
|
12
|
+
https: "__Secure-better-auth.session_token",
|
|
13
|
+
http: "better-auth.session_token",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Paths the proxy must never guard, because they are where it sends people.
|
|
18
|
+
* Guarding them turns a Convex outage into an infinite redirect loop.
|
|
19
|
+
*/
|
|
20
|
+
const ALWAYS_ALLOWED: Record<string, true> = { "/": true, "/unauthorized": true };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Session gate for the private surfaces named in `config.matcher`.
|
|
24
|
+
*
|
|
25
|
+
* **This is the marketing-site variant.** The base template guards every route
|
|
26
|
+
* except a small allowlist, which is right for a private app and wrong here:
|
|
27
|
+
* a marketing site's pages are public content served from the CMS, so a
|
|
28
|
+
* fail-closed gate over `/(.*)` sends anonymous visitors from `/features` and
|
|
29
|
+
* `/roadmap` straight to sign-in. The matcher below inverts that — only the
|
|
30
|
+
* admin panel is gated, and the public site is public.
|
|
31
|
+
*
|
|
32
|
+
* The admin panel is not defended by this gate alone: every collection query
|
|
33
|
+
* runs the RBAC rules from `vex.config.ts`, and unauthenticated callers
|
|
34
|
+
* resolve through `anonRole`. The proxy is defence in depth and a redirect
|
|
35
|
+
* convenience, not the access control.
|
|
36
|
+
*
|
|
37
|
+
* Fails **closed**: if the session cannot be verified — no cookie, no matching
|
|
38
|
+
* session, or the Convex lookup throwing — the request is redirected rather than
|
|
39
|
+
* served. A verification error is not evidence of a valid session, and serving
|
|
40
|
+
* the page anyway would drop authentication for every guarded route the moment
|
|
41
|
+
* Convex became unreachable.
|
|
42
|
+
*
|
|
43
|
+
* Two distinct outcomes, deliberately:
|
|
44
|
+
* - **No/invalid session** → `/auth/sign-in`, clearing the stale cookie so the
|
|
45
|
+
* presence check cannot lie on the next request.
|
|
46
|
+
* - **Verification failed** (Convex unreachable, timeout) → `/unauthorized`,
|
|
47
|
+
* leaving cookies intact: the session may well be fine, so signing the user
|
|
48
|
+
* out and discarding it would be wrong.
|
|
49
|
+
*
|
|
50
|
+
* @param request - The incoming request.
|
|
51
|
+
* @returns `NextResponse.next()` when the session is verified, otherwise a
|
|
52
|
+
* redirect to sign-in or to the unauthorized page.
|
|
53
|
+
*/
|
|
54
|
+
export async function proxy(request: NextRequest) {
|
|
55
|
+
if (ALWAYS_ALLOWED[request.nextUrl.pathname]) {
|
|
56
|
+
return NextResponse.next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (request.nextUrl.pathname.startsWith("/admin")) {
|
|
60
|
+
return guardAdminRequest(request);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return grantPreviewIfRequested(request);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Session gate for the admin panel — fails closed, per the docstring above. */
|
|
67
|
+
async function guardAdminRequest(request: NextRequest) {
|
|
68
|
+
const sessionStatus = await resolveSessionStatus();
|
|
69
|
+
|
|
70
|
+
if (sessionStatus === "verification-failed") {
|
|
71
|
+
return redirectToUnauthorized(request);
|
|
72
|
+
}
|
|
73
|
+
if (sessionStatus === "unauthenticated") {
|
|
74
|
+
return redirectToSignIn(request);
|
|
75
|
+
}
|
|
76
|
+
return NextResponse.next();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Grants live-preview mode to a public-route request carrying `?vexLivePreview=1`
|
|
81
|
+
* and a verified admin session, by setting the `vex-live-preview` marker
|
|
82
|
+
* cookie that `LivePreviewProvider` gates on client-side. Fails **open** — an
|
|
83
|
+
* unauthenticated or unverifiable session here renders the page normally
|
|
84
|
+
* rather than redirecting.
|
|
85
|
+
*
|
|
86
|
+
* A cookie rather than a forwarded request header: a header would have to be
|
|
87
|
+
* read with `next/headers` in the site layout, which opts every public route
|
|
88
|
+
* out of static prerendering.
|
|
89
|
+
*/
|
|
90
|
+
async function grantPreviewIfRequested(request: NextRequest) {
|
|
91
|
+
if (request.nextUrl.searchParams.get(LIVE_PREVIEW_QUERY_PARAM) !== "1") {
|
|
92
|
+
return NextResponse.next();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sessionStatus = await resolveSessionStatus();
|
|
96
|
+
if (sessionStatus !== "authenticated") {
|
|
97
|
+
return NextResponse.next();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const response = NextResponse.next();
|
|
101
|
+
response.cookies.set(LIVE_PREVIEW_COOKIE, "1", {
|
|
102
|
+
path: "/",
|
|
103
|
+
maxAge: LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS,
|
|
104
|
+
sameSite: "lax",
|
|
105
|
+
// Read by `LivePreviewProvider` in the browser — the whole point of the
|
|
106
|
+
// cookie. It carries no secret, only the fact that a session verified.
|
|
107
|
+
httpOnly: false,
|
|
108
|
+
});
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reads and verifies the Better Auth session cookie — shared by both branches above. */
|
|
113
|
+
async function resolveSessionStatus(): Promise<
|
|
114
|
+
"authenticated" | "unauthenticated" | "verification-failed"
|
|
115
|
+
> {
|
|
116
|
+
const cookieStore = await cookies();
|
|
117
|
+
const sessionToken =
|
|
118
|
+
cookieStore.get(SESSION_COOKIES.https)?.value ?? cookieStore.get(SESSION_COOKIES.http)?.value;
|
|
119
|
+
if (!sessionToken) return "unauthenticated";
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const session = await fetchQuery(api.auth.sessions.getSessionWithUser, {
|
|
123
|
+
sessionToken: extractToken(sessionToken),
|
|
124
|
+
});
|
|
125
|
+
return session?.user ? "authenticated" : "unauthenticated";
|
|
126
|
+
} catch {
|
|
127
|
+
// Fail closed for the admin branch; grantPreviewIfRequested treats this as
|
|
128
|
+
// "no preview" rather than an error.
|
|
129
|
+
return "verification-failed";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extract the raw token — better-auth stores "<raw_token>.<hmac_signature>" */
|
|
134
|
+
function extractToken(cookieValue: string) {
|
|
135
|
+
return cookieValue.includes(".") ? cookieValue.split(".")[0] : cookieValue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Redirect for a caller with no usable session. Clears the session cookies so
|
|
140
|
+
* the presence check cannot lie on the next request.
|
|
141
|
+
*
|
|
142
|
+
* @param request - The request being rejected.
|
|
143
|
+
* @returns A redirect to sign-in preserving the intended destination.
|
|
144
|
+
*/
|
|
145
|
+
function redirectToSignIn(request: NextRequest) {
|
|
146
|
+
const signInUrl = new URL("/auth/sign-in", request.url);
|
|
147
|
+
// Preserve where the user was headed (path + query)
|
|
148
|
+
signInUrl.searchParams.set("redirectTo", request.nextUrl.pathname + request.nextUrl.search);
|
|
149
|
+
|
|
150
|
+
const response = NextResponse.redirect(signInUrl);
|
|
151
|
+
for (const cookie of Object.values(SESSION_COOKIES)) {
|
|
152
|
+
// Clear the stale cookie so the presence-check never lies again
|
|
153
|
+
response.cookies.delete(cookie);
|
|
154
|
+
}
|
|
155
|
+
return response;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Redirect for a caller whose session could not be *verified* — as opposed to
|
|
160
|
+
* one who has none.
|
|
161
|
+
*
|
|
162
|
+
* Cookies are deliberately left intact: the session is probably valid and the
|
|
163
|
+
* verification path (Convex) is simply unavailable, so discarding it would sign
|
|
164
|
+
* out every user during an outage. `/unauthorized` is exempt from this proxy, so
|
|
165
|
+
* this cannot loop.
|
|
166
|
+
*
|
|
167
|
+
* @param request - The request that could not be verified.
|
|
168
|
+
* @returns A redirect to the unauthorized page.
|
|
169
|
+
*/
|
|
170
|
+
function redirectToUnauthorized(request: NextRequest) {
|
|
171
|
+
return NextResponse.redirect(new URL("/unauthorized", request.url));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const config = {
|
|
175
|
+
// Allowlist, not denylist. The base template denies everything except a few
|
|
176
|
+
// paths; here the public site IS the product, so the gate names the private
|
|
177
|
+
// surfaces explicitly. Anything added under `(vexcms)` must be added here.
|
|
178
|
+
matcher: ["/admin/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
179
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { authOptions } from "@convex/auth/options"
|
|
2
|
+
import { betterAuthAdapter } from "@vexcms/better-auth"
|
|
3
|
+
import { defineServerConfig } from "@vexcms/core"
|
|
4
|
+
import { convexFileStorage } from "@vexcms/file-storage-convex"
|
|
5
|
+
|
|
6
|
+
import vexConfig from "./vex.config"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* VexCMS server-only configuration for the marketing site.
|
|
10
|
+
*
|
|
11
|
+
* Layers the Better Auth adapter and Convex file storage onto the
|
|
12
|
+
* client-safe `vexConfig`. You write this file once at scaffold time and
|
|
13
|
+
* rarely revisit it — day-to-day settings live in `vex.config.ts`.
|
|
14
|
+
*
|
|
15
|
+
* @see ./vex.config for the client-safe half this layers onto
|
|
16
|
+
*/
|
|
17
|
+
const config = defineServerConfig({
|
|
18
|
+
config: vexConfig,
|
|
19
|
+
server: {
|
|
20
|
+
auth: { adapter: betterAuthAdapter({ config: authOptions }) },
|
|
21
|
+
storage: {
|
|
22
|
+
adapters: [convexFileStorage()],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export default config
|
|
@@ -1,35 +1,53 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { betterAuthCollections } from "@vexcms/better-auth/client"
|
|
2
2
|
import { defineConfig } from "@vexcms/core"
|
|
3
|
-
import {
|
|
3
|
+
import { uploadFile } from "@vexcms/file-storage-convex/client"
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { authSchema } from "@convex/auth/schema"
|
|
6
6
|
|
|
7
7
|
import { access } from "~/auth/access"
|
|
8
|
+
import { resolvePagePath } from "~/lib/resolvePagePath"
|
|
8
9
|
import { footers, headers, images, pages, themes, users } from "~/vexcms/collections"
|
|
9
10
|
import { siteSettings } from "~/vexcms/globals"
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* VexCMS configuration for the marketing site.
|
|
13
|
+
* VexCMS client-safe configuration for the marketing site.
|
|
13
14
|
*
|
|
14
15
|
* Replaces `templates/base-nextjs`'s bare config wholesale (overlay copy is
|
|
15
16
|
* file-level, not a merge): carries base's `users`/`images` forward unchanged
|
|
16
|
-
* and adds the four marketing collections plus `siteSettings`.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* and adds the four marketing collections plus `siteSettings`. Imported
|
|
18
|
+
* directly by the browser via `VexConfigProvider`.
|
|
19
|
+
*
|
|
20
|
+
* @see ./vex.config.server for the auth adapter and storage adapter wiring
|
|
19
21
|
*/
|
|
20
22
|
const vexConfig = defineConfig({
|
|
21
23
|
access,
|
|
24
|
+
authCollections: betterAuthCollections(authSchema),
|
|
22
25
|
admin: {
|
|
23
26
|
sidebar: {
|
|
24
27
|
side: "right",
|
|
25
28
|
},
|
|
26
29
|
},
|
|
27
|
-
authAdapter: betterAuthAdapter({ config: authOptions }),
|
|
28
30
|
storage: {
|
|
29
|
-
|
|
31
|
+
clientUploads: { convex: uploadFile },
|
|
30
32
|
},
|
|
31
33
|
collections: [users, pages, headers, footers, themes],
|
|
34
|
+
mediaCollections: [images],
|
|
32
35
|
globals: [siteSettings],
|
|
36
|
+
routes: {
|
|
37
|
+
map: ({ collection, doc }) => {
|
|
38
|
+
if (collection !== pages.slug) return []
|
|
39
|
+
const { slug } = doc
|
|
40
|
+
if (typeof slug !== "string") return []
|
|
41
|
+
const path = resolvePagePath(slug)
|
|
42
|
+
return path === undefined ? [] : [path]
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
livePreview: {
|
|
46
|
+
allowedOrigins: [
|
|
47
|
+
"http://localhost:3000",
|
|
48
|
+
...(process.env.NEXT_PUBLIC_SITE_URL ? [process.env.NEXT_PUBLIC_SITE_URL] : []),
|
|
49
|
+
],
|
|
50
|
+
},
|
|
33
51
|
})
|
|
34
52
|
|
|
35
53
|
export default vexConfig
|