create-nextblock 0.15.10 → 0.16.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.
Files changed (266) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -0,0 +1,448 @@
1
+ // app/cms/settings/seo/actions.ts
2
+ 'use server';
3
+
4
+ import { revalidatePath } from 'next/cache';
5
+
6
+ import {
7
+ normalizeRedirectPath,
8
+ normalizeRobotsSettings,
9
+ validateRedirectRule,
10
+ type RedirectRule,
11
+ type RobotsSettings,
12
+ } from '@nextblock-cms/utils/seo';
13
+
14
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
15
+ import { isSchemaMissingError, CMS_REDIRECTS_TABLE } from '../../../../lib/seo/redirect-store';
16
+ import { z } from '../../../../lib/zod-config';
17
+ import { toRedirectRules, type CmsRedirect, type RedirectInput } from './mappers';
18
+ import { requireAdminSupabaseClient } from './require-admin';
19
+
20
+ const SEO_SETTINGS_PATH = '/cms/settings/seo';
21
+
22
+ /** The `site_settings` row robots directives live in, seeded by migration 00000000000030. */
23
+ const ROBOTS_SETTINGS_KEY = 'seo_robots_settings';
24
+
25
+ /** Every column the admin list needs; the proxy deliberately selects fewer. */
26
+ const REDIRECT_ADMIN_COLUMNS =
27
+ 'id, source_path, destination_path, status_code, is_active, created_at, updated_at';
28
+
29
+ /**
30
+ * Which caches a change here has to reach.
31
+ *
32
+ * The neighbouring settings actions invalidate a *tagged* public read — `updateTag`
33
+ * in logos/languages/extra-translations, `revalidateTag(tag, 'max')` in
34
+ * site-scripts and themes — because each of those feeds a cached component in the
35
+ * public layout. Nothing on this screen has such a tag, and inventing one would only
36
+ * name a cache that no reader consults: `/robots.txt` is a plain route handler
37
+ * addressed by path, and redirects are read by `proxy.ts`, which keeps its own
38
+ * module-level TTL cache that Next's tag system cannot see at all. So path
39
+ * revalidation is the matching call here, not a shortfall.
40
+ *
41
+ * The practical consequence, worth knowing before someone reports it as a bug: a
42
+ * redirect saved here becomes live once the proxy's own cache window lapses, not
43
+ * instantly. Robots changes are instant, because `/robots.txt` really is a Next
44
+ * route and `revalidateRobotsFile` below evicts it.
45
+ */
46
+ function revalidateSeoSettings() {
47
+ revalidatePath(SEO_SETTINGS_PATH);
48
+ }
49
+
50
+ /** Additionally evicts the public file, so the operator's change is visible at once. */
51
+ function revalidateRobotsFile() {
52
+ revalidateSeoSettings();
53
+ revalidatePath('/robots.txt');
54
+ }
55
+
56
+ /**
57
+ * The admin gate, converted from a throw into a value.
58
+ *
59
+ * Every mutation below is invoked directly from a client component, and Next replaces
60
+ * the message of an uncaught Server Action error with a generic string in production
61
+ * builds — so "you do not have permission" would reach the operator as "An error
62
+ * occurred in the Server Components render", which tells them nothing and invites a
63
+ * support ticket. The shared helper throws because a Server Component wants it to;
64
+ * this wrapper is where that throw becomes `SettingsActionResult` data.
65
+ */
66
+ async function requireAdminOrMessage(): Promise<
67
+ { error: null; supabase: Awaited<ReturnType<typeof requireAdminSupabaseClient>>['supabase'] } | { error: string; supabase: null }
68
+ > {
69
+ try {
70
+ const { supabase } = await requireAdminSupabaseClient();
71
+
72
+ return { error: null, supabase };
73
+ } catch (error) {
74
+ return {
75
+ error: error instanceof Error ? error.message : 'You do not have permission to manage SEO settings.',
76
+ supabase: null,
77
+ };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Turns a PostgREST failure into something an operator can act on.
83
+ *
84
+ * The two cases worth naming are the ones that will actually happen. A missing table
85
+ * means the install has pulled this code but not yet run its migrations — a steady
86
+ * state that can last days, and one whose raw message ("Could not find the table
87
+ * 'public.cms_redirects' in the schema cache") reads like a bug in NextBlock rather
88
+ * than a step the operator has not taken. A unique violation means the source path is
89
+ * already claimed, which the application already checks; seeing it here means two
90
+ * admins saved the same path at the same instant, and the database won the race.
91
+ */
92
+ function describeDatabaseError(
93
+ error: { code?: string | null; message?: string | null } | null,
94
+ fallback: string
95
+ ): string {
96
+ if (!error) {
97
+ return fallback;
98
+ }
99
+
100
+ if (isSchemaMissingError(error)) {
101
+ return 'The redirects table does not exist yet. Run `npm run db:migrate` to apply the pending migrations, then reload this page.';
102
+ }
103
+
104
+ if (error.code === '23505') {
105
+ return 'Another redirect already handles that source path. Reload the page to see it.';
106
+ }
107
+
108
+ return `${fallback} ${error.message ?? ''}`.trim();
109
+ }
110
+
111
+ /**
112
+ * The rule set every validation runs against, including paused rules.
113
+ *
114
+ * Paused rules count deliberately: `validateRedirectRule` uses this list for both the
115
+ * duplicate check and the loop walk, and excluding a paused rule would let an operator
116
+ * save a second rule for the same source, then re-enable the first and end up with two
117
+ * rules fighting over one path.
118
+ */
119
+ async function loadRedirectRules(
120
+ supabase: NonNullable<Awaited<ReturnType<typeof requireAdminOrMessage>>['supabase']>
121
+ ): Promise<RedirectRule[]> {
122
+ const { data } = await supabase.from(CMS_REDIRECTS_TABLE).select(REDIRECT_ADMIN_COLUMNS);
123
+
124
+ return toRedirectRules((data ?? []) as CmsRedirect[]);
125
+ }
126
+
127
+ /**
128
+ * Shape and bounds only.
129
+ *
130
+ * The interesting rules — blank source, a full URL in the source box, a self-redirect,
131
+ * a duplicate, a cycle — belong to `validateRedirectRule` in the pure engine, which the
132
+ * browser runs too, so the operator sees the same sentence before and after saving.
133
+ * Zod's job here is narrower: reject a payload that is not the right *kind* of thing,
134
+ * which is what stops a hand-rolled fetch to this action from writing a 500-kilobyte
135
+ * path or a status code the table's CHECK constraint would reject as an opaque error.
136
+ */
137
+ const redirectInputSchema = z.object({
138
+ destinationPath: z.string().max(2048),
139
+ isActive: z.boolean(),
140
+ sourcePath: z.string().max(2048),
141
+ statusCode: z.union([z.literal(301), z.literal(302)]),
142
+ });
143
+
144
+ const redirectIdSchema = z.string().uuid();
145
+
146
+ /**
147
+ * The robots payload, bounded so a malicious or buggy client cannot park an unbounded
148
+ * blob in a row that is read on every `/robots.txt` request. Everything semantic is
149
+ * left to `normalizeRobotsSettings`, which never throws and always returns a complete
150
+ * object — the property that keeps a garbage settings row from turning robots.txt into
151
+ * a 500, which some crawlers read as "the whole site is disallowed".
152
+ */
153
+ const robotsSettingsSchema = z.object({
154
+ customRules: z.string().max(20000),
155
+ isIndexingEnabled: z.boolean(),
156
+ sitemapEnabled: z.boolean(),
157
+ userAgentRules: z
158
+ .array(
159
+ z.object({
160
+ allow: z.array(z.string().max(2048)).max(200),
161
+ disallow: z.array(z.string().max(2048)).max(200),
162
+ userAgent: z.string().max(200),
163
+ })
164
+ )
165
+ .max(50),
166
+ });
167
+
168
+ /**
169
+ * Every redirect rule, active or not, ordered the way an operator reads them.
170
+ *
171
+ * Ordered by `source_path` rather than by creation date because this list is used as a
172
+ * lookup ("is /old-pricing already handled?") far more often than as a history, and an
173
+ * alphabetical list answers that question by eye.
174
+ *
175
+ * Throws for a non-admin — it is called only by the Server Component below, which is
176
+ * itself gated, so a throw here lands on the error boundary rather than in front of a
177
+ * user. A *read* failure is different and does not throw: an install that has not run
178
+ * its migrations yet would otherwise get an error page instead of the screen that
179
+ * explains what to do, so the failure is logged and reported as an empty list.
180
+ */
181
+ export async function getRedirects(): Promise<CmsRedirect[]> {
182
+ const { supabase } = await requireAdminSupabaseClient();
183
+
184
+ const { data, error } = await supabase
185
+ .from(CMS_REDIRECTS_TABLE)
186
+ .select(REDIRECT_ADMIN_COLUMNS)
187
+ .order('source_path');
188
+
189
+ if (error) {
190
+ console.error('SEO settings: could not read redirects —', describeDatabaseError(error, 'Read failed.'));
191
+ return [];
192
+ }
193
+
194
+ return (data ?? []) as CmsRedirect[];
195
+ }
196
+
197
+ /**
198
+ * The stored robots directives, in canonical form.
199
+ *
200
+ * The raw jsonb goes through `normalizeRobotsSettings` on the way out, which is what
201
+ * makes a missing row, a row left as SQL `null`, and a row an older release wrote in a
202
+ * different shape all resolve to the same complete object instead of throwing. The
203
+ * migration seeds this key, but an install that predates it — or one whose row was
204
+ * edited by hand — must still be able to open this screen.
205
+ */
206
+ export async function getRobotsSettings(): Promise<RobotsSettings> {
207
+ const { supabase } = await requireAdminSupabaseClient();
208
+
209
+ const { data, error } = await supabase
210
+ .from('site_settings')
211
+ .select('value')
212
+ .eq('key', ROBOTS_SETTINGS_KEY)
213
+ .maybeSingle();
214
+
215
+ if (error) {
216
+ console.error('SEO settings: could not read robots settings —', error.message);
217
+ }
218
+
219
+ return normalizeRobotsSettings((data as { value?: unknown } | null)?.value);
220
+ }
221
+
222
+ export async function createRedirect(input: RedirectInput): Promise<SettingsActionResult> {
223
+ const { error: authError, supabase } = await requireAdminOrMessage();
224
+ if (authError !== null) {
225
+ return { ok: false, error: authError };
226
+ }
227
+
228
+ const parsed = redirectInputSchema.safeParse(input);
229
+ if (!parsed.success) {
230
+ return { ok: false, error: 'That redirect could not be read. Check the paths and try again.' };
231
+ }
232
+
233
+ // Normalising before validating is what makes the admin screen and the proxy agree:
234
+ // both compare `normalizeRedirectPath` output, so a rule that looks unique here
235
+ // cannot turn out to collide at request time.
236
+ //
237
+ // The destination goes through the same function, and must. It was previously only
238
+ // trimmed, on the assumption that normalising would corrupt an off-site https://
239
+ // target — but `normalizeRedirectPath` passes an absolute URL through untouched
240
+ // apart from folding its scheme to lowercase, and that fold is precisely what is
241
+ // needed here: the table's CHECK is `destination_path ~ '^https://'`, a
242
+ // case-SENSITIVE match, so a destination typed as `HTTPS://example.com` passes
243
+ // validation and is then rejected by Postgres with a raw constraint-violation
244
+ // message the operator cannot act on.
245
+ const sourcePath = normalizeRedirectPath(parsed.data.sourcePath);
246
+ const destinationPath = normalizeRedirectPath(parsed.data.destinationPath);
247
+
248
+ const existingRules = await loadRedirectRules(supabase);
249
+ const validation = validateRedirectRule({ destinationPath, sourcePath }, existingRules);
250
+ if (!validation.ok) {
251
+ // Surfaced verbatim: every string in the engine is written to be read by the
252
+ // operator who typed the offending value, and paraphrasing it here would make the
253
+ // browser's live warning and the server's refusal disagree about the same rule.
254
+ return { ok: false, error: validation.error };
255
+ }
256
+
257
+ const { error } = await supabase.from(CMS_REDIRECTS_TABLE).insert({
258
+ destination_path: destinationPath,
259
+ is_active: parsed.data.isActive,
260
+ source_path: sourcePath,
261
+ status_code: parsed.data.statusCode,
262
+ });
263
+
264
+ if (error) {
265
+ return { ok: false, error: describeDatabaseError(error, 'Failed to create the redirect.') };
266
+ }
267
+
268
+ revalidateSeoSettings();
269
+ return { ok: true, message: `Redirect saved. ${sourcePath} now points to ${destinationPath}.` };
270
+ }
271
+
272
+ export async function updateRedirect(
273
+ id: string,
274
+ input: RedirectInput
275
+ ): Promise<SettingsActionResult> {
276
+ const { error: authError, supabase } = await requireAdminOrMessage();
277
+ if (authError !== null) {
278
+ return { ok: false, error: authError };
279
+ }
280
+
281
+ const parsedId = redirectIdSchema.safeParse(id);
282
+ if (!parsedId.success) {
283
+ return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
284
+ }
285
+
286
+ const parsed = redirectInputSchema.safeParse(input);
287
+ if (!parsed.success) {
288
+ return { ok: false, error: 'That redirect could not be read. Check the paths and try again.' };
289
+ }
290
+
291
+ // Both sides normalised, for the reasons spelled out in `createRedirect` — an
292
+ // edit that only changed a destination's scheme casing would otherwise fail the
293
+ // table CHECK with a raw Postgres error.
294
+ const sourcePath = normalizeRedirectPath(parsed.data.sourcePath);
295
+ const destinationPath = normalizeRedirectPath(parsed.data.destinationPath);
296
+
297
+ const existingRules = await loadRedirectRules(supabase);
298
+ // Passing the id is how editing in place works: the rule being edited is excluded
299
+ // from both the duplicate check and the loop walk, so a rule is allowed to keep its
300
+ // own source path.
301
+ const validation = validateRedirectRule(
302
+ { destinationPath, id: parsedId.data, sourcePath },
303
+ existingRules
304
+ );
305
+ if (!validation.ok) {
306
+ return { ok: false, error: validation.error };
307
+ }
308
+
309
+ const { error } = await supabase
310
+ .from(CMS_REDIRECTS_TABLE)
311
+ .update({
312
+ destination_path: destinationPath,
313
+ is_active: parsed.data.isActive,
314
+ source_path: sourcePath,
315
+ status_code: parsed.data.statusCode,
316
+ })
317
+ .eq('id', parsedId.data);
318
+
319
+ if (error) {
320
+ return { ok: false, error: describeDatabaseError(error, 'Failed to update the redirect.') };
321
+ }
322
+
323
+ revalidateSeoSettings();
324
+ return { ok: true, message: 'Redirect updated.' };
325
+ }
326
+
327
+ export async function deleteRedirect(id: string): Promise<SettingsActionResult> {
328
+ const { error: authError, supabase } = await requireAdminOrMessage();
329
+ if (authError !== null) {
330
+ return { ok: false, error: authError };
331
+ }
332
+
333
+ const parsedId = redirectIdSchema.safeParse(id);
334
+ if (!parsedId.success) {
335
+ return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
336
+ }
337
+
338
+ const { error } = await supabase.from(CMS_REDIRECTS_TABLE).delete().eq('id', parsedId.data);
339
+
340
+ if (error) {
341
+ return { ok: false, error: describeDatabaseError(error, 'Failed to delete the redirect.') };
342
+ }
343
+
344
+ revalidateSeoSettings();
345
+ return {
346
+ ok: true,
347
+ message: 'Redirect deleted. Visitors asking for that path will get a 404 again.',
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Pauses or resumes one rule.
353
+ *
354
+ * Re-validating on the way *back on* is not redundant. The duplicate and loop checks
355
+ * deliberately count paused rules, so a rule that was valid when it was saved is still
356
+ * valid when it is resumed — but rows that predate this screen (an import, a direct SQL
357
+ * insert) were never checked at all, and resuming one is exactly when a conflict would
358
+ * first reach a visitor. Switching a rule *off* is never blocked: turning something off
359
+ * cannot make the site worse, and refusing to would leave an operator stuck with a rule
360
+ * they can neither fix nor stop.
361
+ */
362
+ export async function toggleRedirect(id: string, isActive: boolean): Promise<SettingsActionResult> {
363
+ const { error: authError, supabase } = await requireAdminOrMessage();
364
+ if (authError !== null) {
365
+ return { ok: false, error: authError };
366
+ }
367
+
368
+ const parsedId = redirectIdSchema.safeParse(id);
369
+ if (!parsedId.success) {
370
+ return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
371
+ }
372
+
373
+ if (isActive) {
374
+ const existingRules = await loadRedirectRules(supabase);
375
+ const target = existingRules.find((rule) => rule.id === parsedId.data);
376
+
377
+ if (!target) {
378
+ return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
379
+ }
380
+
381
+ const validation = validateRedirectRule(
382
+ { destinationPath: target.destinationPath, id: target.id, sourcePath: target.sourcePath },
383
+ existingRules
384
+ );
385
+ if (!validation.ok) {
386
+ return { ok: false, error: validation.error };
387
+ }
388
+ }
389
+
390
+ const { error } = await supabase
391
+ .from(CMS_REDIRECTS_TABLE)
392
+ .update({ is_active: isActive })
393
+ .eq('id', parsedId.data);
394
+
395
+ if (error) {
396
+ return { ok: false, error: describeDatabaseError(error, 'Failed to change the redirect.') };
397
+ }
398
+
399
+ revalidateSeoSettings();
400
+ return { ok: true, message: isActive ? 'Redirect enabled.' : 'Redirect paused.' };
401
+ }
402
+
403
+ /**
404
+ * Replaces the stored robots directives wholesale.
405
+ *
406
+ * The payload is normalised before it is written, not only after it is read, so the row
407
+ * on disk is already canonical: paths gain their leading slash, blank entries are
408
+ * dropped, and a rule with no user agent — which would render a `User-agent:` line
409
+ * applying to nobody and silently orphan every directive under it — is removed. Writing
410
+ * the canonical form means the file an operator previewed is the file that gets served,
411
+ * and it means anything else reading this row later (an export, an MCP tool, a future
412
+ * `app/robots.ts`) does not have to repeat the cleanup.
413
+ */
414
+ export async function saveRobotsSettings(settings: RobotsSettings): Promise<SettingsActionResult> {
415
+ const { error: authError, supabase } = await requireAdminOrMessage();
416
+ if (authError !== null) {
417
+ return { ok: false, error: authError };
418
+ }
419
+
420
+ const parsed = robotsSettingsSchema.safeParse(settings);
421
+ if (!parsed.success) {
422
+ return {
423
+ ok: false,
424
+ error: 'Those robots settings could not be read. Shorten any very long rule list and try again.',
425
+ };
426
+ }
427
+
428
+ const normalized = normalizeRobotsSettings(parsed.data);
429
+
430
+ const { error } = await supabase
431
+ .from('site_settings')
432
+ .upsert({ key: ROBOTS_SETTINGS_KEY, value: normalized });
433
+
434
+ if (error) {
435
+ return {
436
+ ok: false,
437
+ error: describeDatabaseError(error, 'Failed to save the robots settings.'),
438
+ };
439
+ }
440
+
441
+ revalidateRobotsFile();
442
+ return {
443
+ ok: true,
444
+ message: normalized.isIndexingEnabled
445
+ ? 'Robots settings saved.'
446
+ : 'Robots settings saved. Search engines are now asked not to index this site.',
447
+ };
448
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The snake_case ↔ camelCase boundary for managed redirects.
3
+ *
4
+ * The database speaks `source_path` / `is_active`; the pure SEO engine in
5
+ * `@nextblock-cms/utils/seo` speaks `sourcePath` / `isActive`. Exactly one place is
6
+ * allowed to know both vocabularies, and this is it — every other module on this
7
+ * screen sees one shape or the other and never has to guess which.
8
+ *
9
+ * These helpers are pure and live outside `actions.ts` deliberately: that file is
10
+ * `'use server'`, where every export must be an async server action, so a synchronous
11
+ * mapper cannot be exported from there. Keeping them here also means the client
12
+ * components can use the identical mapping the actions use, which is what lets the
13
+ * browser run the real `validateRedirectRule` against the real rule set before a
14
+ * request is ever made.
15
+ */
16
+
17
+ import type { RedirectRule, RedirectStatusCode } from '@nextblock-cms/utils/seo';
18
+
19
+ import { mapRedirectRow, type CmsRedirectRow } from '../../../../lib/seo/redirect-store';
20
+
21
+ /**
22
+ * A row of `public.cms_redirects` as this screen sees it.
23
+ *
24
+ * WHY IT IS AN ALIAS AND NOT A SECOND HAND-WRITTEN INTERFACE. The row shape was
25
+ * already transcribed from migration 00000000000030's DDL in `lib/seo/redirect-store.ts`,
26
+ * for the proxy's benefit, and for the same reason it had to be hand-written there:
27
+ * `libs/db`'s generated `Database` type comes from `npm run db:types` against a live
28
+ * project, that migration has not been applied yet, and so
29
+ * `Database['public']['Tables']['cms_redirects']` does not compile today. Restating
30
+ * the seven columns here would give the repo two hand-written descriptions of one
31
+ * table that could drift apart in a rename; aliasing gives it one. When the migration
32
+ * lands and the types are regenerated, there is a single interface to delete.
33
+ */
34
+ export type CmsRedirect = CmsRedirectRow;
35
+
36
+ /**
37
+ * What the add/edit form collects, in the engine's vocabulary.
38
+ *
39
+ * `statusCode` is `RedirectStatusCode` rather than `number` so a widened value cannot
40
+ * reach the database and trip the table's CHECK constraint as an opaque Postgres
41
+ * error; the server re-validates it with Zod regardless, because a type says nothing
42
+ * about what actually arrived over the wire.
43
+ */
44
+ export interface RedirectInput {
45
+ destinationPath: string;
46
+ isActive: boolean;
47
+ sourcePath: string;
48
+ statusCode: RedirectStatusCode;
49
+ }
50
+
51
+ /**
52
+ * Maps stored rows onto the engine's rule shape, dropping any row too malformed to
53
+ * be a rule at all.
54
+ *
55
+ * Reuses `mapRedirectRow`, the proxy's mapper, so the admin screen's idea of the
56
+ * current rule set is byte-for-byte the proxy's idea of it. That agreement is what
57
+ * makes client-side validation trustworthy: a duplicate or a loop the admin screen
58
+ * refuses is a duplicate or a loop the proxy would actually have hit.
59
+ *
60
+ * Unlike the proxy's query this one is given inactive rows too, and that is
61
+ * intentional — `validateRedirectRule` counts a paused rule when checking for
62
+ * duplicates and loops, so re-enabling one cannot introduce a conflict that
63
+ * validation already waved through.
64
+ */
65
+ export function toRedirectRules(rows: CmsRedirect[]): RedirectRule[] {
66
+ const rules: RedirectRule[] = [];
67
+
68
+ for (const row of rows) {
69
+ const rule = mapRedirectRow(row);
70
+ if (rule !== null) {
71
+ rules.push(rule);
72
+ }
73
+ }
74
+
75
+ return rules;
76
+ }
77
+
78
+ /**
79
+ * Maps one stored row onto the form's input shape, for the inline editor.
80
+ *
81
+ * Falls back to 301 for an out-of-range `status_code` for the same reason
82
+ * `mapRedirectRow` does: the column is constrained to 301/302, so the fallback never
83
+ * fires in practice, and defaulting to permanent keeps a rule the operator can see in
84
+ * the list from silently becoming un-editable.
85
+ */
86
+ export function toRedirectInput(row: CmsRedirect): RedirectInput {
87
+ return {
88
+ destinationPath: row.destination_path,
89
+ isActive: row.is_active,
90
+ sourcePath: row.source_path,
91
+ statusCode: row.status_code === 302 ? 302 : 301,
92
+ };
93
+ }
@@ -0,0 +1,46 @@
1
+ // app/cms/settings/seo/page.tsx
2
+ import { hasResolvedSiteUrl, resolveSiteUrl } from '../../../../lib/site-url';
3
+ import { getRedirects, getRobotsSettings } from './actions';
4
+ import { requireAdminSupabaseClient } from './require-admin';
5
+ import { SeoSettingsClient } from './SeoSettingsClient';
6
+
7
+ /**
8
+ * The SEO operations screen: which URLs moved, and what crawlers are allowed to do.
9
+ *
10
+ * ADMIN-only, and gated here as well as in every action and in RLS. The gate is
11
+ * repeated rather than delegated because the three layers protect different things:
12
+ * this one stops a WRITER from ever seeing the screen (a redirect list is a map of a
13
+ * site's history, and the robots block is a map of what it is hiding), the actions
14
+ * stop a hand-rolled request that never loaded the page, and RLS stops everything
15
+ * else. `requireAdminSupabaseClient` throws, which is the right behaviour for a
16
+ * Server Component: an unauthorised visitor gets the error boundary instead of a
17
+ * half-rendered admin screen.
18
+ *
19
+ * The two reads run concurrently because they are independent — one hits
20
+ * `cms_redirects`, the other one row of `site_settings` — and running them in series
21
+ * would add a whole round trip to a screen that is already behind an auth check.
22
+ */
23
+ export default async function SeoSettingsPage() {
24
+ await requireAdminSupabaseClient();
25
+
26
+ const [redirects, robotsSettings] = await Promise.all([getRedirects(), getRobotsSettings()]);
27
+
28
+ // Resolved on the server, where the deployment's env actually lives, and handed to
29
+ // the preview as data. The client component must never try to work this out for
30
+ // itself: `NEXT_PUBLIC_URL` is inlined at build time and the Vercel fallbacks are
31
+ // server-only, so a browser-side guess would quietly show a different sitemap URL
32
+ // from the one crawlers are given.
33
+ const siteUrl = resolveSiteUrl();
34
+
35
+ return (
36
+ <div className="mx-auto max-w-5xl space-y-6">
37
+ <SeoSettingsClient
38
+ initialRedirects={redirects}
39
+ initialRobotsSettings={robotsSettings}
40
+ isSandbox={process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'}
41
+ isSiteUrlConfigured={hasResolvedSiteUrl()}
42
+ sitemapUrl={`${siteUrl}/sitemap.xml`}
43
+ />
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,47 @@
1
+ import { createClient } from '@nextblock-cms/db/server';
2
+
3
+ /**
4
+ * Admin gate shared by the SEO settings server actions and by the page itself.
5
+ *
6
+ * Lives outside `actions.ts` because that file is `'use server'`, where every export
7
+ * must itself be a valid server action — a helper that returns a Supabase client
8
+ * cannot be exported from there. Keeping one copy matters more than the file count:
9
+ * this is the check standing between a WRITER and the redirect table, and a redirect
10
+ * rule is an open-redirect surface. Anyone who can write one can point every visitor
11
+ * of `/checkout` at a phishing clone of the site, with the real domain in the
12
+ * referrer, without touching a single page or post. The same reasoning is why
13
+ * migration 00000000000030 grants writes on `cms_redirects` to ADMIN only and leaves
14
+ * WRITER out entirely — the RLS policy is the boundary, and this is the friendly
15
+ * error that keeps an operator from meeting it as an unexplained empty list.
16
+ *
17
+ * It throws rather than returning a result because its two kinds of caller want
18
+ * opposite things. A Server Component wants the throw: an unauthorised visitor gets
19
+ * the error boundary instead of a half-rendered admin screen. A server action called
20
+ * from a client component wants a message it can render, so those callers catch the
21
+ * throw and return it as data — see `requireAdminOrMessage` in `actions.ts` and the
22
+ * note on `SettingsActionResult` about Next replacing uncaught Server Action error
23
+ * messages with a generic string in production.
24
+ */
25
+ export async function requireAdminSupabaseClient() {
26
+ const supabase = createClient();
27
+ const {
28
+ data: { user },
29
+ error: userError,
30
+ } = await supabase.auth.getUser();
31
+
32
+ if (userError || !user) {
33
+ throw new Error('You must be logged in to manage SEO settings.');
34
+ }
35
+
36
+ const { data: profile, error: profileError } = await supabase
37
+ .from('profiles')
38
+ .select('role')
39
+ .eq('id', user.id)
40
+ .single();
41
+
42
+ if (profileError || !profile || profile.role !== 'ADMIN') {
43
+ throw new Error('You do not have permission to manage SEO settings.');
44
+ }
45
+
46
+ return { supabase, userId: user.id };
47
+ }