create-nextblock 0.12.9 → 0.12.11
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/docker-template/.dockerignore +23 -23
- package/docker-template/.env.docker.example +69 -69
- package/docker-template/docker/db/init/99-jwt.sql +6 -6
- package/docker-template/docker/db/init/99-roles.sql +25 -25
- package/docker-template/docker/kong/kong.yml +112 -112
- package/docker-template/scripts/docker-setup.mjs +310 -310
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +1 -1
- package/project.json +19 -19
- package/templates/nextblock-template/.browserslistrc +11 -11
- package/templates/nextblock-template/.dockerignore +23 -23
- package/templates/nextblock-template/README.md +34 -34
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -141
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
- package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +91 -91
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +123 -123
- package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/consent.ts +57 -57
- package/templates/nextblock-template/app/actions/interactions.test.ts +301 -301
- package/templates/nextblock-template/app/actions/interactions.ts +373 -373
- package/templates/nextblock-template/app/actions/postActions.ts +146 -146
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +964 -964
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
- package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
- package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
- package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5597 -5597
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
- package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
- package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
- package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
- package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +29 -29
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +244 -244
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
- package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
- package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
- package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
- package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +1522 -1522
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
- package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
- package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
- package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
- package/templates/nextblock-template/app/cms/import-export/ContentTransferControls.tsx +431 -391
- package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -408
- package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/layout.tsx +73 -73
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +149 -149
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +101 -101
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/page.tsx +18 -17
- package/templates/nextblock-template/app/cms/posts/page.tsx +19 -18
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
- package/templates/nextblock-template/app/cms/products/page.tsx +39 -27
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
- package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
- package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
- package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
- package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
- package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
- package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
- package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +129 -129
- package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +497 -497
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +410 -410
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +246 -246
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +80 -80
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +64 -64
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -181
- package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -60
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -129
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +16 -16
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -20
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
- package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +52 -52
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +148 -148
- package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -44
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -65
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +284 -284
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +522 -522
- package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/actions.ts +222 -222
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +44 -44
- package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +253 -253
- package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +73 -73
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-data.ts +37 -37
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/account-types.ts +11 -11
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/[id]/CustomerOrderDetailPageClient.tsx +79 -79
- package/templates/nextblock-template/app/profile/orders/[id]/page.tsx +32 -32
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/page.tsx +42 -42
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
- package/templates/nextblock-template/app/setup/layout.tsx +13 -13
- package/templates/nextblock-template/app/setup/page.tsx +111 -111
- package/templates/nextblock-template/app/sitemap.ts +130 -130
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GitHubLoginButton.tsx +15 -15
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -419
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
- package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
- package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +464 -464
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
- package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/LanguageContext.tsx +16 -16
- package/templates/nextblock-template/context/language-rest-client.ts +32 -32
- package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
- package/templates/nextblock-template/docker/kong/kong.yml +112 -112
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
- package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +1319 -1319
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
- package/templates/nextblock-template/docs/README.md +39 -39
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12505 -12505
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/app-secrets.ts +39 -39
- package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
- package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
- package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
- package/templates/nextblock-template/lib/auth/twoFactor.ts +208 -208
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/README.md +13 -13
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +6 -6
- package/templates/nextblock-template/lib/blocks/blockTypes.ts +18 -18
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
- package/templates/nextblock-template/lib/cms-transfer/csv.test.ts +100 -77
- package/templates/nextblock-template/lib/cms-transfer/csv.ts +403 -399
- package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
- package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
- package/templates/nextblock-template/lib/config/email-settings.ts +217 -217
- package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
- package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
- package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
- package/templates/nextblock-template/lib/custom-block-relation-registry.ts +256 -256
- package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
- package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
- package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
- package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
- package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
- package/templates/nextblock-template/lib/email/branding.ts +76 -76
- package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
- package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
- package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
- package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
- package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
- package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
- package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
- package/templates/nextblock-template/lib/privacy/types.ts +69 -69
- package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
- package/templates/nextblock-template/lib/promotions/server.ts +741 -741
- package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
- package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
- package/templates/nextblock-template/lib/search/server.ts +585 -585
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/setup/actions.ts +460 -460
- package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -37
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
- package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
- package/templates/nextblock-template/lib/setup/types.ts +18 -18
- package/templates/nextblock-template/lib/site-url.ts +48 -48
- package/templates/nextblock-template/lib/storage/provider.ts +66 -66
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
- package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
- package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
- package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
- package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
- package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
- package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/types/jsdom.d.ts +6 -6
- package/tsconfig.base.json +3 -3
- package/templates/nextblock-template/tsconfig.tsbuildinfo +0 -1
|
@@ -1,522 +1,522 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useRef, useState, useTransition } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
|
-
import {
|
|
6
|
-
Alert,
|
|
7
|
-
AlertDescription,
|
|
8
|
-
Badge,
|
|
9
|
-
Button,
|
|
10
|
-
Card,
|
|
11
|
-
CardContent,
|
|
12
|
-
CardDescription,
|
|
13
|
-
CardHeader,
|
|
14
|
-
CardTitle,
|
|
15
|
-
Checkbox,
|
|
16
|
-
Input,
|
|
17
|
-
Label,
|
|
18
|
-
Separator,
|
|
19
|
-
Spinner,
|
|
20
|
-
} from '@nextblock-cms/ui';
|
|
21
|
-
import { Message } from '../../../../../components/form-message';
|
|
22
|
-
import {
|
|
23
|
-
MAX_TRUSTED_DEVICE_DAYS,
|
|
24
|
-
MIN_TRUSTED_DEVICE_DAYS,
|
|
25
|
-
} from '../../../../../lib/privacy/types';
|
|
26
|
-
import {
|
|
27
|
-
disableMfa,
|
|
28
|
-
revokeTrustedDeviceAction,
|
|
29
|
-
sendEmailEnrollmentCode,
|
|
30
|
-
startTotpEnrollment,
|
|
31
|
-
updateAutoAcceptSignups,
|
|
32
|
-
updateGlobalSecuritySettings,
|
|
33
|
-
verifyEmailEnrollment,
|
|
34
|
-
verifyTotpEnrollment,
|
|
35
|
-
type SecurityPanelData,
|
|
36
|
-
} from '../actions';
|
|
37
|
-
|
|
38
|
-
type EnrollMode = 'idle' | 'totp' | 'email';
|
|
39
|
-
|
|
40
|
-
function formatDate(value: string): string {
|
|
41
|
-
try {
|
|
42
|
-
return new Date(value).toLocaleDateString(undefined, {
|
|
43
|
-
year: 'numeric',
|
|
44
|
-
month: 'short',
|
|
45
|
-
day: 'numeric',
|
|
46
|
-
});
|
|
47
|
-
} catch {
|
|
48
|
-
return value;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
53
|
-
const router = useRouter();
|
|
54
|
-
const [isPending, startTransition] = useTransition();
|
|
55
|
-
const [message, setMessage] = useState<Message | null>(null);
|
|
56
|
-
|
|
57
|
-
const [mode, setMode] = useState<EnrollMode>('idle');
|
|
58
|
-
const [totp, setTotp] = useState<{ factorId: string; qrCode: string; secret: string } | null>(
|
|
59
|
-
null,
|
|
60
|
-
);
|
|
61
|
-
const [emailSent, setEmailSent] = useState(false);
|
|
62
|
-
const [code, setCode] = useState('');
|
|
63
|
-
|
|
64
|
-
const run = (fn: () => Promise<unknown>, after?: () => void) => {
|
|
65
|
-
setMessage(null);
|
|
66
|
-
startTransition(async () => {
|
|
67
|
-
try {
|
|
68
|
-
const result = (await fn()) as { message?: string } | undefined;
|
|
69
|
-
if (result?.message) setMessage({ success: result.message });
|
|
70
|
-
after?.();
|
|
71
|
-
router.refresh();
|
|
72
|
-
} catch (error) {
|
|
73
|
-
setMessage({
|
|
74
|
-
error: error instanceof Error ? error.message : 'Something went wrong.',
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const resetEnrollment = () => {
|
|
81
|
-
setMode('idle');
|
|
82
|
-
setTotp(null);
|
|
83
|
-
setEmailSent(false);
|
|
84
|
-
setCode('');
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// --- Enrollment handlers ------------------------------------------------------
|
|
88
|
-
|
|
89
|
-
const beginTotp = () => {
|
|
90
|
-
setMessage(null);
|
|
91
|
-
setMode('totp');
|
|
92
|
-
setEmailSent(false);
|
|
93
|
-
startTransition(async () => {
|
|
94
|
-
const result = await startTotpEnrollment();
|
|
95
|
-
if (result.ok) {
|
|
96
|
-
setTotp({ factorId: result.factorId, qrCode: result.qrCode, secret: result.secret });
|
|
97
|
-
} else {
|
|
98
|
-
setMessage({ error: result.error });
|
|
99
|
-
setMode('idle');
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const submitTotpCode = () => {
|
|
105
|
-
if (!totp) return;
|
|
106
|
-
const formData = new FormData();
|
|
107
|
-
formData.append('factorId', totp.factorId);
|
|
108
|
-
formData.append('code', code);
|
|
109
|
-
run(() => verifyTotpEnrollment(formData), resetEnrollment);
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const beginEmail = () => {
|
|
113
|
-
setMode('email');
|
|
114
|
-
setTotp(null);
|
|
115
|
-
run(async () => {
|
|
116
|
-
const result = await sendEmailEnrollmentCode();
|
|
117
|
-
setEmailSent(true);
|
|
118
|
-
return result;
|
|
119
|
-
});
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const submitEmailCode = () => {
|
|
123
|
-
const formData = new FormData();
|
|
124
|
-
formData.append('code', code);
|
|
125
|
-
run(() => verifyEmailEnrollment(formData), resetEnrollment);
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const messageAlert = message && (
|
|
129
|
-
<Alert
|
|
130
|
-
variant={'success' in message ? 'success' : 'destructive'}
|
|
131
|
-
className="py-2 px-4"
|
|
132
|
-
>
|
|
133
|
-
<AlertDescription>
|
|
134
|
-
{'success' in message
|
|
135
|
-
? message.success
|
|
136
|
-
: 'error' in message
|
|
137
|
-
? message.error
|
|
138
|
-
: ''}
|
|
139
|
-
</AlertDescription>
|
|
140
|
-
</Alert>
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<>
|
|
145
|
-
{/* Status + per-user MFA */}
|
|
146
|
-
<Card>
|
|
147
|
-
<CardHeader>
|
|
148
|
-
<CardTitle className="flex items-center gap-2">
|
|
149
|
-
Two-Factor Authentication
|
|
150
|
-
{data.mfaEnabled ? (
|
|
151
|
-
<Badge variant="default">
|
|
152
|
-
Enabled · {data.mfaType === 'totp' ? 'Authenticator app' : 'Email code'}
|
|
153
|
-
</Badge>
|
|
154
|
-
) : (
|
|
155
|
-
<Badge variant="secondary">Off</Badge>
|
|
156
|
-
)}
|
|
157
|
-
</CardTitle>
|
|
158
|
-
<CardDescription>
|
|
159
|
-
Add a second step when you sign in to the CMS. You can use an authenticator
|
|
160
|
-
app (TOTP) or a one-time code sent to <strong>{data.email}</strong>.
|
|
161
|
-
</CardDescription>
|
|
162
|
-
</CardHeader>
|
|
163
|
-
<CardContent className="space-y-6">
|
|
164
|
-
{messageAlert}
|
|
165
|
-
|
|
166
|
-
{data.mfaEnabled ? (
|
|
167
|
-
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
168
|
-
<div className="text-sm">
|
|
169
|
-
<p className="font-medium">
|
|
170
|
-
{data.mfaType === 'totp'
|
|
171
|
-
? 'Authenticator app is protecting your account.'
|
|
172
|
-
: 'Email verification is protecting your account.'}
|
|
173
|
-
</p>
|
|
174
|
-
<p className="text-slate-500">
|
|
175
|
-
Disabling 2FA also forgets all of your trusted devices.
|
|
176
|
-
</p>
|
|
177
|
-
</div>
|
|
178
|
-
<Button
|
|
179
|
-
variant="destructive"
|
|
180
|
-
disabled={isPending}
|
|
181
|
-
onClick={() => run(() => disableMfa(), resetEnrollment)}
|
|
182
|
-
>
|
|
183
|
-
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Disable 2FA'}
|
|
184
|
-
</Button>
|
|
185
|
-
</div>
|
|
186
|
-
) : (
|
|
187
|
-
<div className="space-y-4">
|
|
188
|
-
{/* Method chooser */}
|
|
189
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
190
|
-
<button
|
|
191
|
-
type="button"
|
|
192
|
-
onClick={beginTotp}
|
|
193
|
-
className={`rounded-lg border p-4 text-left transition hover:border-primary ${
|
|
194
|
-
mode === 'totp' ? 'border-primary ring-1 ring-primary' : ''
|
|
195
|
-
}`}
|
|
196
|
-
>
|
|
197
|
-
<p className="font-medium text-sm">Authenticator App (TOTP)</p>
|
|
198
|
-
<p className="text-xs text-slate-500">
|
|
199
|
-
Scan a QR code with Google Authenticator, 1Password, Authy, etc.
|
|
200
|
-
</p>
|
|
201
|
-
</button>
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
onClick={beginEmail}
|
|
205
|
-
className={`rounded-lg border p-4 text-left transition hover:border-primary ${
|
|
206
|
-
mode === 'email' ? 'border-primary ring-1 ring-primary' : ''
|
|
207
|
-
}`}
|
|
208
|
-
>
|
|
209
|
-
<p className="font-medium text-sm">Secure Email Code</p>
|
|
210
|
-
<p className="text-xs text-slate-500">
|
|
211
|
-
Receive a 6-digit code at your account email each time you sign in.
|
|
212
|
-
</p>
|
|
213
|
-
</button>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
{/* TOTP enrollment */}
|
|
217
|
-
{mode === 'totp' && (
|
|
218
|
-
<div className="rounded-lg border p-4 space-y-4">
|
|
219
|
-
{totp ? (
|
|
220
|
-
<>
|
|
221
|
-
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
|
222
|
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
223
|
-
<img
|
|
224
|
-
src={totp.qrCode}
|
|
225
|
-
alt="Authenticator setup QR code"
|
|
226
|
-
className="h-44 w-44 rounded bg-white p-2"
|
|
227
|
-
/>
|
|
228
|
-
<div className="text-sm space-y-2">
|
|
229
|
-
<p>1. Scan this QR code with your authenticator app.</p>
|
|
230
|
-
<p>
|
|
231
|
-
2. Or enter this key manually:
|
|
232
|
-
<br />
|
|
233
|
-
<code className="break-all text-xs bg-muted px-1 py-0.5 rounded">
|
|
234
|
-
{totp.secret}
|
|
235
|
-
</code>
|
|
236
|
-
</p>
|
|
237
|
-
<p>3. Enter the current 6-digit code below.</p>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
<div className="flex items-end gap-3">
|
|
241
|
-
<div className="space-y-1">
|
|
242
|
-
<Label htmlFor="totp_code">6-digit code</Label>
|
|
243
|
-
<Input
|
|
244
|
-
id="totp_code"
|
|
245
|
-
inputMode="numeric"
|
|
246
|
-
autoComplete="one-time-code"
|
|
247
|
-
maxLength={6}
|
|
248
|
-
value={code}
|
|
249
|
-
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
250
|
-
className="w-32 tracking-widest"
|
|
251
|
-
placeholder="000000"
|
|
252
|
-
/>
|
|
253
|
-
</div>
|
|
254
|
-
<Button onClick={submitTotpCode} disabled={isPending || code.length !== 6}>
|
|
255
|
-
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Verify & enable'}
|
|
256
|
-
</Button>
|
|
257
|
-
<Button variant="ghost" onClick={resetEnrollment} disabled={isPending}>
|
|
258
|
-
Cancel
|
|
259
|
-
</Button>
|
|
260
|
-
</div>
|
|
261
|
-
</>
|
|
262
|
-
) : (
|
|
263
|
-
<div className="flex items-center gap-2 text-sm text-slate-500">
|
|
264
|
-
<Spinner className="h-4 w-4 animate-spin" /> Generating your QR code…
|
|
265
|
-
</div>
|
|
266
|
-
)}
|
|
267
|
-
</div>
|
|
268
|
-
)}
|
|
269
|
-
|
|
270
|
-
{/* Email enrollment */}
|
|
271
|
-
{mode === 'email' && (
|
|
272
|
-
<div className="rounded-lg border p-4 space-y-4">
|
|
273
|
-
<p className="text-sm">
|
|
274
|
-
{emailSent
|
|
275
|
-
? `Enter the 6-digit code we sent to ${data.email}.`
|
|
276
|
-
: 'Sending a verification code to your email…'}
|
|
277
|
-
</p>
|
|
278
|
-
<div className="flex items-end gap-3">
|
|
279
|
-
<div className="space-y-1">
|
|
280
|
-
<Label htmlFor="email_code">6-digit code</Label>
|
|
281
|
-
<Input
|
|
282
|
-
id="email_code"
|
|
283
|
-
inputMode="numeric"
|
|
284
|
-
autoComplete="one-time-code"
|
|
285
|
-
maxLength={6}
|
|
286
|
-
value={code}
|
|
287
|
-
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
288
|
-
className="w-32 tracking-widest"
|
|
289
|
-
placeholder="000000"
|
|
290
|
-
disabled={!emailSent}
|
|
291
|
-
/>
|
|
292
|
-
</div>
|
|
293
|
-
<Button
|
|
294
|
-
onClick={submitEmailCode}
|
|
295
|
-
disabled={isPending || code.length !== 6 || !emailSent}
|
|
296
|
-
>
|
|
297
|
-
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Verify & enable'}
|
|
298
|
-
</Button>
|
|
299
|
-
<Button
|
|
300
|
-
variant="ghost"
|
|
301
|
-
onClick={beginEmail}
|
|
302
|
-
disabled={isPending}
|
|
303
|
-
title="Send a new code"
|
|
304
|
-
>
|
|
305
|
-
Resend
|
|
306
|
-
</Button>
|
|
307
|
-
<Button variant="ghost" onClick={resetEnrollment} disabled={isPending}>
|
|
308
|
-
Cancel
|
|
309
|
-
</Button>
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
)}
|
|
313
|
-
</div>
|
|
314
|
-
)}
|
|
315
|
-
</CardContent>
|
|
316
|
-
</Card>
|
|
317
|
-
|
|
318
|
-
{/* Trusted devices */}
|
|
319
|
-
<Card>
|
|
320
|
-
<CardHeader>
|
|
321
|
-
<CardTitle>Trusted Devices</CardTitle>
|
|
322
|
-
<CardDescription>
|
|
323
|
-
Devices where you chose “Remember this device” skip 2FA until the
|
|
324
|
-
trust expires. Revoke any you no longer recognize.
|
|
325
|
-
</CardDescription>
|
|
326
|
-
</CardHeader>
|
|
327
|
-
<CardContent>
|
|
328
|
-
{data.trustedDevices.length === 0 ? (
|
|
329
|
-
<p className="text-sm text-slate-500">No trusted devices.</p>
|
|
330
|
-
) : (
|
|
331
|
-
<ul className="divide-y rounded-lg border">
|
|
332
|
-
{data.trustedDevices.map((device) => (
|
|
333
|
-
<li
|
|
334
|
-
key={device.id}
|
|
335
|
-
className="flex items-center justify-between gap-4 p-3 text-sm"
|
|
336
|
-
>
|
|
337
|
-
<div className="min-w-0">
|
|
338
|
-
<p className="truncate font-medium">
|
|
339
|
-
{device.browser_metadata || 'Unknown device'}
|
|
340
|
-
</p>
|
|
341
|
-
<p className="text-xs text-slate-500">
|
|
342
|
-
Trusted {formatDate(device.created_at)} · expires{' '}
|
|
343
|
-
{formatDate(device.expires_at)}
|
|
344
|
-
</p>
|
|
345
|
-
</div>
|
|
346
|
-
<Button
|
|
347
|
-
variant="outline"
|
|
348
|
-
size="sm"
|
|
349
|
-
disabled={isPending}
|
|
350
|
-
onClick={() => {
|
|
351
|
-
const formData = new FormData();
|
|
352
|
-
formData.append('id', device.id);
|
|
353
|
-
run(() => revokeTrustedDeviceAction(formData));
|
|
354
|
-
}}
|
|
355
|
-
>
|
|
356
|
-
Revoke
|
|
357
|
-
</Button>
|
|
358
|
-
</li>
|
|
359
|
-
))}
|
|
360
|
-
</ul>
|
|
361
|
-
)}
|
|
362
|
-
</CardContent>
|
|
363
|
-
</Card>
|
|
364
|
-
|
|
365
|
-
{/* Admin global policy */}
|
|
366
|
-
{data.isAdmin && (
|
|
367
|
-
<AdminPolicyCard
|
|
368
|
-
initial={data.globalSettings}
|
|
369
|
-
isPending={isPending}
|
|
370
|
-
onSave={(formData) => run(() => updateGlobalSecuritySettings(formData))}
|
|
371
|
-
/>
|
|
372
|
-
)}
|
|
373
|
-
|
|
374
|
-
{/* Sign-up policy (admin only) */}
|
|
375
|
-
{data.isAdmin && (
|
|
376
|
-
<SignupPolicyCard
|
|
377
|
-
initial={data.autoAcceptSignups}
|
|
378
|
-
isPending={isPending}
|
|
379
|
-
onSave={(formData) => run(() => updateAutoAcceptSignups(formData))}
|
|
380
|
-
/>
|
|
381
|
-
)}
|
|
382
|
-
</>
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function SignupPolicyCard({
|
|
387
|
-
initial,
|
|
388
|
-
isPending,
|
|
389
|
-
onSave,
|
|
390
|
-
}: {
|
|
391
|
-
initial: boolean;
|
|
392
|
-
isPending: boolean;
|
|
393
|
-
onSave: (formData: FormData) => void;
|
|
394
|
-
}) {
|
|
395
|
-
const [enabled, setEnabled] = useState(initial);
|
|
396
|
-
|
|
397
|
-
return (
|
|
398
|
-
<Card>
|
|
399
|
-
<CardHeader>
|
|
400
|
-
<CardTitle>Sign-up Policy (Admin)</CardTitle>
|
|
401
|
-
<CardDescription>
|
|
402
|
-
Controls how new public registrations are handled across the whole site.
|
|
403
|
-
</CardDescription>
|
|
404
|
-
</CardHeader>
|
|
405
|
-
<CardContent>
|
|
406
|
-
<form
|
|
407
|
-
onSubmit={(e) => {
|
|
408
|
-
e.preventDefault();
|
|
409
|
-
const formData = new FormData();
|
|
410
|
-
formData.append('auto_accept_signups', String(enabled));
|
|
411
|
-
onSave(formData);
|
|
412
|
-
}}
|
|
413
|
-
className="space-y-6"
|
|
414
|
-
>
|
|
415
|
-
<div className="flex items-start gap-3">
|
|
416
|
-
<Checkbox
|
|
417
|
-
id="auto_accept_signups"
|
|
418
|
-
checked={enabled}
|
|
419
|
-
onCheckedChange={(checked) => setEnabled(checked === true)}
|
|
420
|
-
className="mt-1"
|
|
421
|
-
/>
|
|
422
|
-
<div className="space-y-1">
|
|
423
|
-
<Label htmlFor="auto_accept_signups">
|
|
424
|
-
Auto-approve registrations (skip outbound email verification)
|
|
425
|
-
</Label>
|
|
426
|
-
<p className="text-xs text-slate-500">
|
|
427
|
-
New accounts become active immediately, even without SMTP configured. Convenient
|
|
428
|
-
for local / self-hosted use; leave off for public production sites.
|
|
429
|
-
</p>
|
|
430
|
-
</div>
|
|
431
|
-
</div>
|
|
432
|
-
|
|
433
|
-
<Separator />
|
|
434
|
-
|
|
435
|
-
<Button type="submit" disabled={isPending}>
|
|
436
|
-
{isPending ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
437
|
-
Save Policy
|
|
438
|
-
</Button>
|
|
439
|
-
</form>
|
|
440
|
-
</CardContent>
|
|
441
|
-
</Card>
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function AdminPolicyCard({
|
|
446
|
-
initial,
|
|
447
|
-
isPending,
|
|
448
|
-
onSave,
|
|
449
|
-
}: {
|
|
450
|
-
initial: SecurityPanelData['globalSettings'];
|
|
451
|
-
isPending: boolean;
|
|
452
|
-
onSave: (formData: FormData) => void;
|
|
453
|
-
}) {
|
|
454
|
-
const [days, setDays] = useState(String(initial.trusted_device_days));
|
|
455
|
-
const [enforce, setEnforce] = useState(initial.enforce_staff_2fa);
|
|
456
|
-
const formRef = useRef<HTMLFormElement>(null);
|
|
457
|
-
|
|
458
|
-
return (
|
|
459
|
-
<Card>
|
|
460
|
-
<CardHeader>
|
|
461
|
-
<CardTitle>Organization Policy (Admin)</CardTitle>
|
|
462
|
-
<CardDescription>
|
|
463
|
-
Applies to every staff member. Trusted-device trust is server-validated and
|
|
464
|
-
revocable at any time from this page.
|
|
465
|
-
</CardDescription>
|
|
466
|
-
</CardHeader>
|
|
467
|
-
<CardContent>
|
|
468
|
-
<form
|
|
469
|
-
ref={formRef}
|
|
470
|
-
onSubmit={(e) => {
|
|
471
|
-
e.preventDefault();
|
|
472
|
-
const formData = new FormData();
|
|
473
|
-
formData.append('trusted_device_days', days);
|
|
474
|
-
formData.append('enforce_staff_2fa', String(enforce));
|
|
475
|
-
onSave(formData);
|
|
476
|
-
}}
|
|
477
|
-
className="space-y-6"
|
|
478
|
-
>
|
|
479
|
-
<div className="space-y-2">
|
|
480
|
-
<Label htmlFor="trusted_device_days">Remember-device duration (days)</Label>
|
|
481
|
-
<Input
|
|
482
|
-
id="trusted_device_days"
|
|
483
|
-
type="number"
|
|
484
|
-
min={MIN_TRUSTED_DEVICE_DAYS}
|
|
485
|
-
max={MAX_TRUSTED_DEVICE_DAYS}
|
|
486
|
-
value={days}
|
|
487
|
-
onChange={(e) => setDays(e.target.value)}
|
|
488
|
-
className="w-40"
|
|
489
|
-
/>
|
|
490
|
-
<p className="text-xs text-slate-500">
|
|
491
|
-
How long a “Remember this device” choice lasts before 2FA is
|
|
492
|
-
required again. Default 30 days, up to {MAX_TRUSTED_DEVICE_DAYS} (10 years).
|
|
493
|
-
</p>
|
|
494
|
-
</div>
|
|
495
|
-
|
|
496
|
-
<div className="flex items-start gap-3">
|
|
497
|
-
<Checkbox
|
|
498
|
-
id="enforce_staff_2fa"
|
|
499
|
-
checked={enforce}
|
|
500
|
-
onCheckedChange={(checked) => setEnforce(checked === true)}
|
|
501
|
-
className="mt-1"
|
|
502
|
-
/>
|
|
503
|
-
<div className="space-y-1">
|
|
504
|
-
<Label htmlFor="enforce_staff_2fa">Encourage staff to enable 2FA</Label>
|
|
505
|
-
<p className="text-xs text-slate-500">
|
|
506
|
-
Shows a reminder banner across the CMS to ADMIN/WRITER accounts that
|
|
507
|
-
haven’t set up a second factor. On by default.
|
|
508
|
-
</p>
|
|
509
|
-
</div>
|
|
510
|
-
</div>
|
|
511
|
-
|
|
512
|
-
<Separator />
|
|
513
|
-
|
|
514
|
-
<Button type="submit" disabled={isPending}>
|
|
515
|
-
{isPending ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
516
|
-
Save Policy
|
|
517
|
-
</Button>
|
|
518
|
-
</form>
|
|
519
|
-
</CardContent>
|
|
520
|
-
</Card>
|
|
521
|
-
);
|
|
522
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useTransition } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import {
|
|
6
|
+
Alert,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
Badge,
|
|
9
|
+
Button,
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
Checkbox,
|
|
16
|
+
Input,
|
|
17
|
+
Label,
|
|
18
|
+
Separator,
|
|
19
|
+
Spinner,
|
|
20
|
+
} from '@nextblock-cms/ui';
|
|
21
|
+
import { Message } from '../../../../../components/form-message';
|
|
22
|
+
import {
|
|
23
|
+
MAX_TRUSTED_DEVICE_DAYS,
|
|
24
|
+
MIN_TRUSTED_DEVICE_DAYS,
|
|
25
|
+
} from '../../../../../lib/privacy/types';
|
|
26
|
+
import {
|
|
27
|
+
disableMfa,
|
|
28
|
+
revokeTrustedDeviceAction,
|
|
29
|
+
sendEmailEnrollmentCode,
|
|
30
|
+
startTotpEnrollment,
|
|
31
|
+
updateAutoAcceptSignups,
|
|
32
|
+
updateGlobalSecuritySettings,
|
|
33
|
+
verifyEmailEnrollment,
|
|
34
|
+
verifyTotpEnrollment,
|
|
35
|
+
type SecurityPanelData,
|
|
36
|
+
} from '../actions';
|
|
37
|
+
|
|
38
|
+
type EnrollMode = 'idle' | 'totp' | 'email';
|
|
39
|
+
|
|
40
|
+
function formatDate(value: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return new Date(value).toLocaleDateString(undefined, {
|
|
43
|
+
year: 'numeric',
|
|
44
|
+
month: 'short',
|
|
45
|
+
day: 'numeric',
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
53
|
+
const router = useRouter();
|
|
54
|
+
const [isPending, startTransition] = useTransition();
|
|
55
|
+
const [message, setMessage] = useState<Message | null>(null);
|
|
56
|
+
|
|
57
|
+
const [mode, setMode] = useState<EnrollMode>('idle');
|
|
58
|
+
const [totp, setTotp] = useState<{ factorId: string; qrCode: string; secret: string } | null>(
|
|
59
|
+
null,
|
|
60
|
+
);
|
|
61
|
+
const [emailSent, setEmailSent] = useState(false);
|
|
62
|
+
const [code, setCode] = useState('');
|
|
63
|
+
|
|
64
|
+
const run = (fn: () => Promise<unknown>, after?: () => void) => {
|
|
65
|
+
setMessage(null);
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const result = (await fn()) as { message?: string } | undefined;
|
|
69
|
+
if (result?.message) setMessage({ success: result.message });
|
|
70
|
+
after?.();
|
|
71
|
+
router.refresh();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
setMessage({
|
|
74
|
+
error: error instanceof Error ? error.message : 'Something went wrong.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const resetEnrollment = () => {
|
|
81
|
+
setMode('idle');
|
|
82
|
+
setTotp(null);
|
|
83
|
+
setEmailSent(false);
|
|
84
|
+
setCode('');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// --- Enrollment handlers ------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
const beginTotp = () => {
|
|
90
|
+
setMessage(null);
|
|
91
|
+
setMode('totp');
|
|
92
|
+
setEmailSent(false);
|
|
93
|
+
startTransition(async () => {
|
|
94
|
+
const result = await startTotpEnrollment();
|
|
95
|
+
if (result.ok) {
|
|
96
|
+
setTotp({ factorId: result.factorId, qrCode: result.qrCode, secret: result.secret });
|
|
97
|
+
} else {
|
|
98
|
+
setMessage({ error: result.error });
|
|
99
|
+
setMode('idle');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const submitTotpCode = () => {
|
|
105
|
+
if (!totp) return;
|
|
106
|
+
const formData = new FormData();
|
|
107
|
+
formData.append('factorId', totp.factorId);
|
|
108
|
+
formData.append('code', code);
|
|
109
|
+
run(() => verifyTotpEnrollment(formData), resetEnrollment);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const beginEmail = () => {
|
|
113
|
+
setMode('email');
|
|
114
|
+
setTotp(null);
|
|
115
|
+
run(async () => {
|
|
116
|
+
const result = await sendEmailEnrollmentCode();
|
|
117
|
+
setEmailSent(true);
|
|
118
|
+
return result;
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const submitEmailCode = () => {
|
|
123
|
+
const formData = new FormData();
|
|
124
|
+
formData.append('code', code);
|
|
125
|
+
run(() => verifyEmailEnrollment(formData), resetEnrollment);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const messageAlert = message && (
|
|
129
|
+
<Alert
|
|
130
|
+
variant={'success' in message ? 'success' : 'destructive'}
|
|
131
|
+
className="py-2 px-4"
|
|
132
|
+
>
|
|
133
|
+
<AlertDescription>
|
|
134
|
+
{'success' in message
|
|
135
|
+
? message.success
|
|
136
|
+
: 'error' in message
|
|
137
|
+
? message.error
|
|
138
|
+
: ''}
|
|
139
|
+
</AlertDescription>
|
|
140
|
+
</Alert>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
{/* Status + per-user MFA */}
|
|
146
|
+
<Card>
|
|
147
|
+
<CardHeader>
|
|
148
|
+
<CardTitle className="flex items-center gap-2">
|
|
149
|
+
Two-Factor Authentication
|
|
150
|
+
{data.mfaEnabled ? (
|
|
151
|
+
<Badge variant="default">
|
|
152
|
+
Enabled · {data.mfaType === 'totp' ? 'Authenticator app' : 'Email code'}
|
|
153
|
+
</Badge>
|
|
154
|
+
) : (
|
|
155
|
+
<Badge variant="secondary">Off</Badge>
|
|
156
|
+
)}
|
|
157
|
+
</CardTitle>
|
|
158
|
+
<CardDescription>
|
|
159
|
+
Add a second step when you sign in to the CMS. You can use an authenticator
|
|
160
|
+
app (TOTP) or a one-time code sent to <strong>{data.email}</strong>.
|
|
161
|
+
</CardDescription>
|
|
162
|
+
</CardHeader>
|
|
163
|
+
<CardContent className="space-y-6">
|
|
164
|
+
{messageAlert}
|
|
165
|
+
|
|
166
|
+
{data.mfaEnabled ? (
|
|
167
|
+
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
168
|
+
<div className="text-sm">
|
|
169
|
+
<p className="font-medium">
|
|
170
|
+
{data.mfaType === 'totp'
|
|
171
|
+
? 'Authenticator app is protecting your account.'
|
|
172
|
+
: 'Email verification is protecting your account.'}
|
|
173
|
+
</p>
|
|
174
|
+
<p className="text-slate-500">
|
|
175
|
+
Disabling 2FA also forgets all of your trusted devices.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
<Button
|
|
179
|
+
variant="destructive"
|
|
180
|
+
disabled={isPending}
|
|
181
|
+
onClick={() => run(() => disableMfa(), resetEnrollment)}
|
|
182
|
+
>
|
|
183
|
+
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Disable 2FA'}
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="space-y-4">
|
|
188
|
+
{/* Method chooser */}
|
|
189
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={beginTotp}
|
|
193
|
+
className={`rounded-lg border p-4 text-left transition hover:border-primary ${
|
|
194
|
+
mode === 'totp' ? 'border-primary ring-1 ring-primary' : ''
|
|
195
|
+
}`}
|
|
196
|
+
>
|
|
197
|
+
<p className="font-medium text-sm">Authenticator App (TOTP)</p>
|
|
198
|
+
<p className="text-xs text-slate-500">
|
|
199
|
+
Scan a QR code with Google Authenticator, 1Password, Authy, etc.
|
|
200
|
+
</p>
|
|
201
|
+
</button>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={beginEmail}
|
|
205
|
+
className={`rounded-lg border p-4 text-left transition hover:border-primary ${
|
|
206
|
+
mode === 'email' ? 'border-primary ring-1 ring-primary' : ''
|
|
207
|
+
}`}
|
|
208
|
+
>
|
|
209
|
+
<p className="font-medium text-sm">Secure Email Code</p>
|
|
210
|
+
<p className="text-xs text-slate-500">
|
|
211
|
+
Receive a 6-digit code at your account email each time you sign in.
|
|
212
|
+
</p>
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* TOTP enrollment */}
|
|
217
|
+
{mode === 'totp' && (
|
|
218
|
+
<div className="rounded-lg border p-4 space-y-4">
|
|
219
|
+
{totp ? (
|
|
220
|
+
<>
|
|
221
|
+
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
|
222
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
223
|
+
<img
|
|
224
|
+
src={totp.qrCode}
|
|
225
|
+
alt="Authenticator setup QR code"
|
|
226
|
+
className="h-44 w-44 rounded bg-white p-2"
|
|
227
|
+
/>
|
|
228
|
+
<div className="text-sm space-y-2">
|
|
229
|
+
<p>1. Scan this QR code with your authenticator app.</p>
|
|
230
|
+
<p>
|
|
231
|
+
2. Or enter this key manually:
|
|
232
|
+
<br />
|
|
233
|
+
<code className="break-all text-xs bg-muted px-1 py-0.5 rounded">
|
|
234
|
+
{totp.secret}
|
|
235
|
+
</code>
|
|
236
|
+
</p>
|
|
237
|
+
<p>3. Enter the current 6-digit code below.</p>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="flex items-end gap-3">
|
|
241
|
+
<div className="space-y-1">
|
|
242
|
+
<Label htmlFor="totp_code">6-digit code</Label>
|
|
243
|
+
<Input
|
|
244
|
+
id="totp_code"
|
|
245
|
+
inputMode="numeric"
|
|
246
|
+
autoComplete="one-time-code"
|
|
247
|
+
maxLength={6}
|
|
248
|
+
value={code}
|
|
249
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
250
|
+
className="w-32 tracking-widest"
|
|
251
|
+
placeholder="000000"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<Button onClick={submitTotpCode} disabled={isPending || code.length !== 6}>
|
|
255
|
+
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Verify & enable'}
|
|
256
|
+
</Button>
|
|
257
|
+
<Button variant="ghost" onClick={resetEnrollment} disabled={isPending}>
|
|
258
|
+
Cancel
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</>
|
|
262
|
+
) : (
|
|
263
|
+
<div className="flex items-center gap-2 text-sm text-slate-500">
|
|
264
|
+
<Spinner className="h-4 w-4 animate-spin" /> Generating your QR code…
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{/* Email enrollment */}
|
|
271
|
+
{mode === 'email' && (
|
|
272
|
+
<div className="rounded-lg border p-4 space-y-4">
|
|
273
|
+
<p className="text-sm">
|
|
274
|
+
{emailSent
|
|
275
|
+
? `Enter the 6-digit code we sent to ${data.email}.`
|
|
276
|
+
: 'Sending a verification code to your email…'}
|
|
277
|
+
</p>
|
|
278
|
+
<div className="flex items-end gap-3">
|
|
279
|
+
<div className="space-y-1">
|
|
280
|
+
<Label htmlFor="email_code">6-digit code</Label>
|
|
281
|
+
<Input
|
|
282
|
+
id="email_code"
|
|
283
|
+
inputMode="numeric"
|
|
284
|
+
autoComplete="one-time-code"
|
|
285
|
+
maxLength={6}
|
|
286
|
+
value={code}
|
|
287
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
288
|
+
className="w-32 tracking-widest"
|
|
289
|
+
placeholder="000000"
|
|
290
|
+
disabled={!emailSent}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
<Button
|
|
294
|
+
onClick={submitEmailCode}
|
|
295
|
+
disabled={isPending || code.length !== 6 || !emailSent}
|
|
296
|
+
>
|
|
297
|
+
{isPending ? <Spinner className="h-4 w-4 animate-spin" /> : 'Verify & enable'}
|
|
298
|
+
</Button>
|
|
299
|
+
<Button
|
|
300
|
+
variant="ghost"
|
|
301
|
+
onClick={beginEmail}
|
|
302
|
+
disabled={isPending}
|
|
303
|
+
title="Send a new code"
|
|
304
|
+
>
|
|
305
|
+
Resend
|
|
306
|
+
</Button>
|
|
307
|
+
<Button variant="ghost" onClick={resetEnrollment} disabled={isPending}>
|
|
308
|
+
Cancel
|
|
309
|
+
</Button>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</CardContent>
|
|
316
|
+
</Card>
|
|
317
|
+
|
|
318
|
+
{/* Trusted devices */}
|
|
319
|
+
<Card>
|
|
320
|
+
<CardHeader>
|
|
321
|
+
<CardTitle>Trusted Devices</CardTitle>
|
|
322
|
+
<CardDescription>
|
|
323
|
+
Devices where you chose “Remember this device” skip 2FA until the
|
|
324
|
+
trust expires. Revoke any you no longer recognize.
|
|
325
|
+
</CardDescription>
|
|
326
|
+
</CardHeader>
|
|
327
|
+
<CardContent>
|
|
328
|
+
{data.trustedDevices.length === 0 ? (
|
|
329
|
+
<p className="text-sm text-slate-500">No trusted devices.</p>
|
|
330
|
+
) : (
|
|
331
|
+
<ul className="divide-y rounded-lg border">
|
|
332
|
+
{data.trustedDevices.map((device) => (
|
|
333
|
+
<li
|
|
334
|
+
key={device.id}
|
|
335
|
+
className="flex items-center justify-between gap-4 p-3 text-sm"
|
|
336
|
+
>
|
|
337
|
+
<div className="min-w-0">
|
|
338
|
+
<p className="truncate font-medium">
|
|
339
|
+
{device.browser_metadata || 'Unknown device'}
|
|
340
|
+
</p>
|
|
341
|
+
<p className="text-xs text-slate-500">
|
|
342
|
+
Trusted {formatDate(device.created_at)} · expires{' '}
|
|
343
|
+
{formatDate(device.expires_at)}
|
|
344
|
+
</p>
|
|
345
|
+
</div>
|
|
346
|
+
<Button
|
|
347
|
+
variant="outline"
|
|
348
|
+
size="sm"
|
|
349
|
+
disabled={isPending}
|
|
350
|
+
onClick={() => {
|
|
351
|
+
const formData = new FormData();
|
|
352
|
+
formData.append('id', device.id);
|
|
353
|
+
run(() => revokeTrustedDeviceAction(formData));
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
Revoke
|
|
357
|
+
</Button>
|
|
358
|
+
</li>
|
|
359
|
+
))}
|
|
360
|
+
</ul>
|
|
361
|
+
)}
|
|
362
|
+
</CardContent>
|
|
363
|
+
</Card>
|
|
364
|
+
|
|
365
|
+
{/* Admin global policy */}
|
|
366
|
+
{data.isAdmin && (
|
|
367
|
+
<AdminPolicyCard
|
|
368
|
+
initial={data.globalSettings}
|
|
369
|
+
isPending={isPending}
|
|
370
|
+
onSave={(formData) => run(() => updateGlobalSecuritySettings(formData))}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
{/* Sign-up policy (admin only) */}
|
|
375
|
+
{data.isAdmin && (
|
|
376
|
+
<SignupPolicyCard
|
|
377
|
+
initial={data.autoAcceptSignups}
|
|
378
|
+
isPending={isPending}
|
|
379
|
+
onSave={(formData) => run(() => updateAutoAcceptSignups(formData))}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
</>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function SignupPolicyCard({
|
|
387
|
+
initial,
|
|
388
|
+
isPending,
|
|
389
|
+
onSave,
|
|
390
|
+
}: {
|
|
391
|
+
initial: boolean;
|
|
392
|
+
isPending: boolean;
|
|
393
|
+
onSave: (formData: FormData) => void;
|
|
394
|
+
}) {
|
|
395
|
+
const [enabled, setEnabled] = useState(initial);
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<Card>
|
|
399
|
+
<CardHeader>
|
|
400
|
+
<CardTitle>Sign-up Policy (Admin)</CardTitle>
|
|
401
|
+
<CardDescription>
|
|
402
|
+
Controls how new public registrations are handled across the whole site.
|
|
403
|
+
</CardDescription>
|
|
404
|
+
</CardHeader>
|
|
405
|
+
<CardContent>
|
|
406
|
+
<form
|
|
407
|
+
onSubmit={(e) => {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
const formData = new FormData();
|
|
410
|
+
formData.append('auto_accept_signups', String(enabled));
|
|
411
|
+
onSave(formData);
|
|
412
|
+
}}
|
|
413
|
+
className="space-y-6"
|
|
414
|
+
>
|
|
415
|
+
<div className="flex items-start gap-3">
|
|
416
|
+
<Checkbox
|
|
417
|
+
id="auto_accept_signups"
|
|
418
|
+
checked={enabled}
|
|
419
|
+
onCheckedChange={(checked) => setEnabled(checked === true)}
|
|
420
|
+
className="mt-1"
|
|
421
|
+
/>
|
|
422
|
+
<div className="space-y-1">
|
|
423
|
+
<Label htmlFor="auto_accept_signups">
|
|
424
|
+
Auto-approve registrations (skip outbound email verification)
|
|
425
|
+
</Label>
|
|
426
|
+
<p className="text-xs text-slate-500">
|
|
427
|
+
New accounts become active immediately, even without SMTP configured. Convenient
|
|
428
|
+
for local / self-hosted use; leave off for public production sites.
|
|
429
|
+
</p>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<Separator />
|
|
434
|
+
|
|
435
|
+
<Button type="submit" disabled={isPending}>
|
|
436
|
+
{isPending ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
437
|
+
Save Policy
|
|
438
|
+
</Button>
|
|
439
|
+
</form>
|
|
440
|
+
</CardContent>
|
|
441
|
+
</Card>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function AdminPolicyCard({
|
|
446
|
+
initial,
|
|
447
|
+
isPending,
|
|
448
|
+
onSave,
|
|
449
|
+
}: {
|
|
450
|
+
initial: SecurityPanelData['globalSettings'];
|
|
451
|
+
isPending: boolean;
|
|
452
|
+
onSave: (formData: FormData) => void;
|
|
453
|
+
}) {
|
|
454
|
+
const [days, setDays] = useState(String(initial.trusted_device_days));
|
|
455
|
+
const [enforce, setEnforce] = useState(initial.enforce_staff_2fa);
|
|
456
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
<Card>
|
|
460
|
+
<CardHeader>
|
|
461
|
+
<CardTitle>Organization Policy (Admin)</CardTitle>
|
|
462
|
+
<CardDescription>
|
|
463
|
+
Applies to every staff member. Trusted-device trust is server-validated and
|
|
464
|
+
revocable at any time from this page.
|
|
465
|
+
</CardDescription>
|
|
466
|
+
</CardHeader>
|
|
467
|
+
<CardContent>
|
|
468
|
+
<form
|
|
469
|
+
ref={formRef}
|
|
470
|
+
onSubmit={(e) => {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
const formData = new FormData();
|
|
473
|
+
formData.append('trusted_device_days', days);
|
|
474
|
+
formData.append('enforce_staff_2fa', String(enforce));
|
|
475
|
+
onSave(formData);
|
|
476
|
+
}}
|
|
477
|
+
className="space-y-6"
|
|
478
|
+
>
|
|
479
|
+
<div className="space-y-2">
|
|
480
|
+
<Label htmlFor="trusted_device_days">Remember-device duration (days)</Label>
|
|
481
|
+
<Input
|
|
482
|
+
id="trusted_device_days"
|
|
483
|
+
type="number"
|
|
484
|
+
min={MIN_TRUSTED_DEVICE_DAYS}
|
|
485
|
+
max={MAX_TRUSTED_DEVICE_DAYS}
|
|
486
|
+
value={days}
|
|
487
|
+
onChange={(e) => setDays(e.target.value)}
|
|
488
|
+
className="w-40"
|
|
489
|
+
/>
|
|
490
|
+
<p className="text-xs text-slate-500">
|
|
491
|
+
How long a “Remember this device” choice lasts before 2FA is
|
|
492
|
+
required again. Default 30 days, up to {MAX_TRUSTED_DEVICE_DAYS} (10 years).
|
|
493
|
+
</p>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div className="flex items-start gap-3">
|
|
497
|
+
<Checkbox
|
|
498
|
+
id="enforce_staff_2fa"
|
|
499
|
+
checked={enforce}
|
|
500
|
+
onCheckedChange={(checked) => setEnforce(checked === true)}
|
|
501
|
+
className="mt-1"
|
|
502
|
+
/>
|
|
503
|
+
<div className="space-y-1">
|
|
504
|
+
<Label htmlFor="enforce_staff_2fa">Encourage staff to enable 2FA</Label>
|
|
505
|
+
<p className="text-xs text-slate-500">
|
|
506
|
+
Shows a reminder banner across the CMS to ADMIN/WRITER accounts that
|
|
507
|
+
haven’t set up a second factor. On by default.
|
|
508
|
+
</p>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<Separator />
|
|
513
|
+
|
|
514
|
+
<Button type="submit" disabled={isPending}>
|
|
515
|
+
{isPending ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
516
|
+
Save Policy
|
|
517
|
+
</Button>
|
|
518
|
+
</form>
|
|
519
|
+
</CardContent>
|
|
520
|
+
</Card>
|
|
521
|
+
);
|
|
522
|
+
}
|