create-nextblock 0.9.0 → 0.9.6

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 (45) hide show
  1. package/bin/create-nextblock.js +40 -60
  2. package/docker-template/.env.docker.example +5 -4
  3. package/docker-template/Dockerfile +20 -3
  4. package/docker-template/docker-compose.yml +5 -1
  5. package/docker-template/scripts/docker-setup.mjs +19 -4
  6. package/package.json +1 -1
  7. package/templates/nextblock-template/Dockerfile +20 -3
  8. package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
  9. package/templates/nextblock-template/app/actions.ts +58 -8
  10. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
  11. package/templates/nextblock-template/app/api/process-image/route.ts +13 -9
  12. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
  13. package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +19 -13
  14. package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
  15. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
  16. package/templates/nextblock-template/app/layout.tsx +49 -3
  17. package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
  18. package/templates/nextblock-template/app/page.tsx +6 -0
  19. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
  20. package/templates/nextblock-template/app/providers.tsx +1 -1
  21. package/templates/nextblock-template/app/setup/SetupWizard.tsx +773 -0
  22. package/templates/nextblock-template/app/setup/layout.tsx +13 -0
  23. package/templates/nextblock-template/app/setup/page.tsx +103 -0
  24. package/templates/nextblock-template/components/AppShell.tsx +12 -0
  25. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -0
  26. package/templates/nextblock-template/components/header-auth.tsx +24 -62
  27. package/templates/nextblock-template/docker-compose.yml +5 -1
  28. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
  29. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
  30. package/templates/nextblock-template/docs/README.md +2 -0
  31. package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
  32. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
  33. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +9 -1
  34. package/templates/nextblock-template/lib/setup/actions.ts +370 -0
  35. package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
  36. package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
  37. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
  38. package/templates/nextblock-template/lib/setup/schema-apply.ts +392 -0
  39. package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
  40. package/templates/nextblock-template/lib/setup/types.ts +18 -0
  41. package/templates/nextblock-template/next.config.js +13 -0
  42. package/templates/nextblock-template/package.json +1 -1
  43. package/templates/nextblock-template/proxy.ts +143 -49
  44. package/templates/nextblock-template/scripts/docker-setup.mjs +19 -4
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -104,43 +104,9 @@ async function handleCommand(projectDirectory, options) {
104
104
  hostingMode = modeChoice;
105
105
  }
106
106
 
107
- // Prerequisites gate (interactive only) shown BEFORE we ask for a name, scaffold, or
108
- // install, so anyone who isn't ready can cancel without creating anything.
109
- if (!yes && hostingMode === 'cloud') {
110
- clack.note(
111
- [
112
- '1. A Supabase project https://supabase.com/dashboard',
113
- ' • Reference ID — Project Settings > General > "Reference ID"',
114
- ' • Connection string — Connect (top bar) > Direct connection > URI',
115
- ' • anon + service_role keys — Project Settings > API Keys',
116
- ' • Personal Access Token — Account > Access Tokens > Generate new token',
117
- '',
118
- '2. A Cloudflare R2 bucket https://dash.cloudflare.com > R2',
119
- ' • Create a bucket, then enable its Public Development URL (Bucket > Settings > General)',
120
- ' • Create an R2 API token (Object Read & Write); copy the Access Key ID + Secret (shown once)',
121
- '',
122
- '3. SMTP credentials SMTP2GO works very well: https://www.smtp2go.com',
123
- ' • Required so Supabase can email the confirmation link your first admin needs to sign in',
124
- ].join('\n'),
125
- 'Before you continue, have all of the following ready',
126
- );
127
-
128
- const ready = await clack.confirm({
129
- message:
130
- 'Do you have your Supabase, Cloudflare R2, and SMTP details ready?',
131
- initialValue: true,
132
- });
133
- if (clack.isCancel(ready)) {
134
- handleWizardCancel('Setup cancelled.');
135
- }
136
- if (!ready) {
137
- clack.note(
138
- 'No problem — nothing was created. Gather the items above, then run\n`npm create nextblock` again. Full guide: docs/05-DEVELOPER-GUIDE.md',
139
- 'Come back when ready',
140
- );
141
- return;
142
- }
143
- } else if (!yes && hostingMode === 'docker') {
107
+ // Cloud / local configuration moved to the browser First-Boot Setup Wizard (/setup), so the
108
+ // CLI no longer asks for Supabase / R2 / SMTP credentials here. Docker still preflights below.
109
+ if (!yes && hostingMode === 'docker') {
144
110
  clack.note(
145
111
  [
146
112
  'Local Self-Hosted Docker Mode runs everything on your machine — no cloud accounts needed.',
@@ -264,30 +230,13 @@ async function handleCommand(projectDirectory, options) {
264
230
  console.log(chalk.yellow('Skipping dependency installation.'));
265
231
  }
266
232
 
267
- // Run the setup flow after dependencies are installed so package assets are available.
268
- // When it runs, its own "next steps" outro is the final message, so we don't print a second
269
- // closing block here the whole flow completes in this one command.
270
- if (!yes) {
271
- if (hostingMode === 'docker') {
272
- await runDockerSetup(projectDir, projectName);
273
- } else {
274
- await runSetupWizard(projectDir, projectName);
275
- }
233
+ // Run the post-scaffold flow after dependencies are installed so package assets are available.
234
+ // Docker boots the local stack; everything else just materializes Supabase assets and points
235
+ // the user at the browser First-Boot Setup Wizard at /setup (no terminal credential prompts).
236
+ if (!yes && hostingMode === 'docker') {
237
+ await runDockerSetup(projectDir, projectName);
276
238
  } else {
277
- // Non-interactive path: nothing was configured, so point the user at their env file.
278
- console.log(
279
- chalk.green(
280
- `\nSuccess! Your NextBlock™ CMS project "${projectName}" is scaffolded.\n`,
281
- ),
282
- );
283
- console.log(chalk.cyan('Next steps:'));
284
- console.log(chalk.cyan(` 1. cd ${projectName}`));
285
- console.log(
286
- chalk.gray(
287
- ' 2. Add your Supabase / R2 / SMTP values to .env.local (template in .env.example)',
288
- ),
289
- );
290
- console.log(chalk.cyan(' 3. npm run dev'));
239
+ await runCloudScaffold(projectDir, projectName);
291
240
  }
292
241
  } catch (error) {
293
242
  console.error(
@@ -955,6 +904,37 @@ async function runSetupWizard(projectDir, projectName) {
955
904
  // Local Self-Hosted Docker Mode: materialize the supabase migrations out of the installed
956
905
  // @nextblock-cms/db package (the migration-runner container applies them on boot), then hand off
957
906
  // to the project's own zero-dependency Docker setup script (prompts + .env + `docker compose up`).
907
+ async function runCloudScaffold(projectDir, projectName) {
908
+ const projectPath = resolve(projectDir);
909
+
910
+ // Materialize the Supabase assets (migrations + config) so `npm run db:migrate` works later,
911
+ // then hand off entirely to the browser First-Boot Setup Wizard for configuration. No
912
+ // credentials are collected in the terminal.
913
+ try {
914
+ await ensureSupabaseAssets(projectPath, { required: false });
915
+ } catch {
916
+ // Non-fatal: the wizard still works; db:migrate just needs these assets present.
917
+ }
918
+
919
+ console.log(
920
+ chalk.green(
921
+ `\nSuccess! Your NextBlock™ CMS project "${projectName}" is scaffolded.\n`,
922
+ ),
923
+ );
924
+ console.log(chalk.cyan('Next steps:'));
925
+ console.log(chalk.cyan(` 1. cd ${projectName}`));
926
+ console.log(chalk.cyan(' 2. npm run dev'));
927
+ console.log(
928
+ ` 3. Open ${chalk.cyan('/setup')} ${chalk.gray('in your browser (the URL npm run dev prints, e.g. http://localhost:3000/setup)')}`,
929
+ );
930
+ console.log(
931
+ chalk.gray(' Connect Supabase, configure storage / email, and create your administrator.'),
932
+ );
933
+ console.log('');
934
+ console.log(chalk.gray(' Self-hosted Docker instead? npm run docker:setup'));
935
+ console.log(chalk.gray(' One-click cloud deploy: see docs/12-VERCEL-DEPLOYMENT.md'));
936
+ }
937
+
958
938
  async function runDockerSetup(projectDir, projectName) {
959
939
  const projectPath = resolve(projectDir);
960
940
  process.chdir(projectPath);
@@ -17,7 +17,6 @@ REVALIDATE_SECRET_TOKEN=replace-me-generated
17
17
 
18
18
  # --- Supabase wiring (internal container network vs browser) ---------------------------------
19
19
  NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
20
- SUPABASE_INTERNAL_URL=http://kong:8000
21
20
  NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY}
22
21
  SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}
23
22
  API_EXTERNAL_URL=http://localhost:8000
@@ -36,10 +35,12 @@ STORAGE_BUCKET=nextblock
36
35
  R2_ACCOUNT_ID=minio
37
36
  R2_REGION=us-east-1
38
37
  R2_S3_ENDPOINT=http://minio:9000
39
- R2_S3_PUBLIC_ENDPOINT=http://localhost:9000
38
+ # 127.0.0.1 (not localhost) so the browser never sends the app's localhost cookies to MinIO,
39
+ # which would otherwise 400 (MetadataTooLarge) once the Supabase auth cookies grow.
40
+ R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000
40
41
  R2_FORCE_PATH_STYLE=true
41
- NEXT_PUBLIC_R2_BASE_URL=http://localhost:9000/nextblock
42
- NEXT_PUBLIC_R2_PUBLIC_URL=http://localhost:9000/nextblock
42
+ NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:9000/nextblock
43
+ NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:9000/nextblock
43
44
 
44
45
  # --- Cloudflare Turnstile (optional). Press Enter at setup to skip and use the dev sandbox. ---
45
46
  NEXT_PUBLIC_TURNSTILE_SITE_KEY=
@@ -50,19 +50,36 @@ RUN npm run build
50
50
  ###############################################################################
51
51
  FROM node:22-alpine AS runner
52
52
  WORKDIR /app
53
- RUN apk add --no-cache libc6-compat \
53
+ RUN apk add --no-cache libc6-compat socat \
54
54
  && addgroup -g 1001 -S nodejs \
55
55
  && adduser -u 1001 -S nextjs -G nodejs
56
56
  ENV NODE_ENV=production \
57
57
  NEXT_TELEMETRY_DISABLED=1 \
58
58
  PORT=3000 \
59
- HOSTNAME=0.0.0.0
59
+ HOSTNAME=0.0.0.0 \
60
+ # ipv4first so localhost resolves to the socat IPv4 listener; the larger header limit absorbs
61
+ # Supabase auth cookies (on localhost they aren't port-scoped, so they pile onto every request).
62
+ NODE_OPTIONS="--dns-result-order=ipv4first --max-http-header-size=65536"
60
63
 
61
64
  # A single-app project traces to its own root, so server.js sits at the top of .next/standalone.
62
65
  COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
63
66
  COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
64
67
  COPY --from=builder --chown=nextjs:nodejs /app/public ./public
65
68
 
69
+ # Loopback proxy: the browser-facing localhost URLs (Supabase :8000, MinIO :9000) are inlined into
70
+ # the build, but server-side code + next/image run INSIDE this container. Forward those ports to the
71
+ # compose services so SSR + image optimization work, and so the Supabase auth cookie key (derived
72
+ # from the URL host) matches between browser and server. Override targets with LOOPBACK_PROXIES.
73
+ RUN printf '%s\n' \
74
+ '#!/bin/sh' \
75
+ 'set -e' \
76
+ ': "${LOOPBACK_PROXIES:=8000:kong:8000 9000:minio:9000}"' \
77
+ 'for p in $LOOPBACK_PROXIES; do' \
78
+ ' socat "TCP4-LISTEN:${p%%:*},fork,reuseaddr,bind=127.0.0.1" "TCP:${p#*:}" 2>/dev/null &' \
79
+ 'done' \
80
+ 'exec node "$(cat .server-entry 2>/dev/null || echo server.js)"' \
81
+ > /app/docker-entrypoint.sh && chmod +x /app/docker-entrypoint.sh
82
+
66
83
  USER nextjs
67
84
  EXPOSE 3000
68
- CMD ["node", "server.js"]
85
+ ENTRYPOINT ["/app/docker-entrypoint.sh"]
@@ -98,6 +98,9 @@ services:
98
98
  KONG_PLUGINS: request-transformer,cors,key-auth,acl
99
99
  KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
100
100
  KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
101
+ # localhost cookies aren't port-scoped, so the app's Supabase auth cookies are also sent to
102
+ # Kong on every supabase-js request — give nginx room so they don't 431.
103
+ KONG_NGINX_HTTP_LARGE_CLIENT_HEADER_BUFFERS: 8 32k
101
104
  SUPABASE_ANON_KEY: ${ANON_KEY}
102
105
  SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
103
106
  volumes:
@@ -179,8 +182,9 @@ services:
179
182
  NODE_ENV: production
180
183
  PORT: 3000
181
184
  HOSTNAME: 0.0.0.0
185
+ # Browser AND server both use localhost:8000; the in-container loopback proxy (see Dockerfile)
186
+ # forwards it to Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
182
187
  NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000}
183
- SUPABASE_INTERNAL_URL: ${SUPABASE_INTERNAL_URL:-http://kong:8000}
184
188
  NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
185
189
  SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
186
190
  NEXT_PUBLIC_URL: ${NEXT_PUBLIC_URL:-http://localhost:3000}
@@ -168,7 +168,6 @@ async function main() {
168
168
  ANON_KEY: `ANON_KEY=${anonKey}`,
169
169
  SERVICE_ROLE_KEY: `SERVICE_ROLE_KEY=${serviceRoleKey}`,
170
170
  NEXT_PUBLIC_SUPABASE_URL: 'NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000',
171
- SUPABASE_INTERNAL_URL: 'SUPABASE_INTERNAL_URL=http://kong:8000',
172
171
  NEXT_PUBLIC_SUPABASE_ANON_KEY: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${anonKey}`,
173
172
  SUPABASE_SERVICE_ROLE_KEY: `SUPABASE_SERVICE_ROLE_KEY=${serviceRoleKey}`,
174
173
  API_EXTERNAL_URL: 'API_EXTERNAL_URL=http://localhost:8000',
@@ -184,10 +183,14 @@ async function main() {
184
183
  R2_ACCOUNT_ID: 'R2_ACCOUNT_ID=minio',
185
184
  R2_REGION: 'R2_REGION=us-east-1',
186
185
  R2_S3_ENDPOINT: 'R2_S3_ENDPOINT=http://minio:9000',
187
- R2_S3_PUBLIC_ENDPOINT: 'R2_S3_PUBLIC_ENDPOINT=http://localhost:9000',
186
+ // Storage URLs use 127.0.0.1 (NOT localhost) on purpose: on localhost, cookies aren't
187
+ // port-scoped, so the browser would send the app's Supabase auth cookies to MinIO too — and
188
+ // MinIO rejects oversized header sets (MetadataTooLarge), breaking image display once cookies
189
+ // grow. 127.0.0.1 is a different cookie host, so the browser never sends them there.
190
+ R2_S3_PUBLIC_ENDPOINT: 'R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000',
188
191
  R2_FORCE_PATH_STYLE: 'R2_FORCE_PATH_STYLE=true',
189
- NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://localhost:9000/${bucket}`,
190
- NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://localhost:9000/${bucket}`,
192
+ NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:9000/${bucket}`,
193
+ NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:9000/${bucket}`,
191
194
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: `NEXT_PUBLIC_TURNSTILE_SITE_KEY=${turnstileSiteKey}`,
192
195
  TURNSTILE_SECRET_KEY: `TURNSTILE_SECRET_KEY=${turnstileSecretKey}`,
193
196
  GOTRUE_MAILER_AUTOCONFIRM: `GOTRUE_MAILER_AUTOCONFIRM=${mailerAutoconfirm}`,
@@ -205,6 +208,18 @@ async function main() {
205
208
  await writeFile(ENV_PATH, nextEnv, 'utf8');
206
209
  console.log('✓ Wrote .env (Postgres, JWT secret + signed anon/service keys, MinIO, app secrets).\n');
207
210
 
211
+ // A brand-new .env means brand-new secrets. Postgres only runs its init scripts (which set role
212
+ // passwords) on an EMPTY volume, so a leftover volume from a previous install would keep the old
213
+ // credentials and GoTrue/PostgREST could not log in. Reset volumes when the config is fresh.
214
+ if (!existing) {
215
+ console.log('Fresh configuration — clearing any previous local sandbox volume so the database matches the new credentials...');
216
+ try {
217
+ await run(compose.cmd, [...compose.args, 'down', '-v'], { cwd: PROJECT_ROOT });
218
+ } catch {
219
+ /* nothing to tear down */
220
+ }
221
+ }
222
+
208
223
  console.log('Building and starting the stack (first run pulls images + builds the app — a few minutes)...');
209
224
  await run(compose.cmd, [...compose.args, 'up', '-d', '--build'], { cwd: PROJECT_ROOT });
210
225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.9.0",
3
+ "version": "0.9.6",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -50,19 +50,36 @@ RUN npm run build
50
50
  ###############################################################################
51
51
  FROM node:22-alpine AS runner
52
52
  WORKDIR /app
53
- RUN apk add --no-cache libc6-compat \
53
+ RUN apk add --no-cache libc6-compat socat \
54
54
  && addgroup -g 1001 -S nodejs \
55
55
  && adduser -u 1001 -S nextjs -G nodejs
56
56
  ENV NODE_ENV=production \
57
57
  NEXT_TELEMETRY_DISABLED=1 \
58
58
  PORT=3000 \
59
- HOSTNAME=0.0.0.0
59
+ HOSTNAME=0.0.0.0 \
60
+ # ipv4first so localhost resolves to the socat IPv4 listener; the larger header limit absorbs
61
+ # Supabase auth cookies (on localhost they aren't port-scoped, so they pile onto every request).
62
+ NODE_OPTIONS="--dns-result-order=ipv4first --max-http-header-size=65536"
60
63
 
61
64
  # A single-app project traces to its own root, so server.js sits at the top of .next/standalone.
62
65
  COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
63
66
  COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
64
67
  COPY --from=builder --chown=nextjs:nodejs /app/public ./public
65
68
 
69
+ # Loopback proxy: the browser-facing localhost URLs (Supabase :8000, MinIO :9000) are inlined into
70
+ # the build, but server-side code + next/image run INSIDE this container. Forward those ports to the
71
+ # compose services so SSR + image optimization work, and so the Supabase auth cookie key (derived
72
+ # from the URL host) matches between browser and server. Override targets with LOOPBACK_PROXIES.
73
+ RUN printf '%s\n' \
74
+ '#!/bin/sh' \
75
+ 'set -e' \
76
+ ': "${LOOPBACK_PROXIES:=8000:kong:8000 9000:minio:9000}"' \
77
+ 'for p in $LOOPBACK_PROXIES; do' \
78
+ ' socat "TCP4-LISTEN:${p%%:*},fork,reuseaddr,bind=127.0.0.1" "TCP:${p#*:}" 2>/dev/null &' \
79
+ 'done' \
80
+ 'exec node "$(cat .server-entry 2>/dev/null || echo server.js)"' \
81
+ > /app/docker-entrypoint.sh && chmod +x /app/docker-entrypoint.sh
82
+
66
83
  USER nextjs
67
84
  EXPOSE 3000
68
- CMD ["node", "server.js"]
85
+ ENTRYPOINT ["/app/docker-entrypoint.sh"]
@@ -38,6 +38,11 @@ interface PageTranslation {
38
38
  }
39
39
 
40
40
  export async function generateStaticParams(): Promise<ResolvedPageParams[]> {
41
+ // Unconfigured instance (pre-/setup): no DB to read slugs from.
42
+ if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
43
+ return [];
44
+ }
45
+
41
46
  const supabase = getSsgSupabaseClient();
42
47
  const { data: pages, error } = await supabase
43
48
  .from("pages")
@@ -1,13 +1,14 @@
1
1
  "use server";
2
2
 
3
3
  import { encodedRedirect } from "@nextblock-cms/utils/server";
4
- import { createClient } from "@nextblock-cms/db/server";
4
+ import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
5
5
  import { headers } from "next/headers";
6
6
  import { redirect } from "next/navigation";
7
7
  import { resolvePostAuthRedirect } from "../lib/auth-redirects";
8
8
  import { createEmailChallenge, evaluateTwoFactor } from "../lib/auth/twoFactor";
9
9
  import { REMEMBER_INTENT_COOKIE, setSecureCookie } from "../lib/auth/cookies";
10
10
  import { sendTwoFactorCodeEmail } from "./actions/twoFactorEmail";
11
+ import { getSystemConfiguration } from "../lib/setup/system-config";
11
12
 
12
13
  export const signUpAction = async (formData: FormData) => {
13
14
  const email = formData.get("email")?.toString();
@@ -29,7 +30,48 @@ export const signUpAction = async (formData: FormData) => {
29
30
  );
30
31
  }
31
32
 
32
- const { error } = await supabase.auth.signUp({
33
+ // Auto-accept mode (system_configuration.auto_accept_signups): create an already-
34
+ // confirmed account via the service role so the user is active immediately, with no
35
+ // outbound verification email — regardless of SMTP / project email-confirmation
36
+ // settings. Falls through to the standard signUp flow if the service role isn't
37
+ // available. NOTE: keep supabase.auth calls outside any try/catch so the redirect
38
+ // they (and encodedRedirect) throw internally is never swallowed.
39
+ const { auto_accept_signups: autoAcceptSignups } = await getSystemConfiguration();
40
+ if (autoAcceptSignups) {
41
+ let admin: ReturnType<typeof getServiceRoleSupabaseClient> | null = null;
42
+ try {
43
+ admin = getServiceRoleSupabaseClient();
44
+ } catch {
45
+ admin = null; // service role missing — use the standard flow below
46
+ }
47
+
48
+ if (admin) {
49
+ const { error: createError } = await admin.auth.admin.createUser({
50
+ email,
51
+ password,
52
+ email_confirm: true,
53
+ });
54
+
55
+ if (createError) {
56
+ if (createError.message.toLowerCase().includes("already")) {
57
+ return encodedRedirect("error", "/sign-up", "auth.signup_existing_account_hint");
58
+ }
59
+ return encodedRedirect("error", "/sign-up", createError.message);
60
+ }
61
+
62
+ const { error: signInError } = await supabase.auth.signInWithPassword({
63
+ email,
64
+ password,
65
+ });
66
+ if (signInError) {
67
+ return encodedRedirect("error", "/sign-in", signInError.message);
68
+ }
69
+
70
+ return redirect("/post-sign-in");
71
+ }
72
+ }
73
+
74
+ const { data, error } = await supabase.auth.signUp({
33
75
  email,
34
76
  password,
35
77
  options: {
@@ -57,13 +99,21 @@ export const signUpAction = async (formData: FormData) => {
57
99
  }
58
100
 
59
101
  return encodedRedirect("error", "/sign-up", error.message);
60
- } else {
61
- return encodedRedirect(
62
- "success",
63
- "/sign-up",
64
- "auth.signup_check_email_profile",
65
- );
66
102
  }
103
+
104
+ // When sign-up returns a session, the account is already confirmed — self-hosted GoTrue with
105
+ // autoconfirm (no SMTP), or any project without email confirmation. The user is already signed
106
+ // in, so send them into the app instead of telling them to check an email that was never sent.
107
+ // (The first account becomes ADMIN and lands in the dashboard; everyone else routes normally.)
108
+ if (data.session) {
109
+ return redirect("/post-sign-in");
110
+ }
111
+
112
+ return encodedRedirect(
113
+ "success",
114
+ "/sign-up",
115
+ "auth.signup_check_email_profile",
116
+ );
67
117
  };
68
118
 
69
119
  export const signInAction = async (formData: FormData) => {
@@ -7816,6 +7816,89 @@ npm run dev</code></pre>
7816
7816
  ), 0 FROM target_posts tp WHERE tp.slug = 'comment-configurer-nextblock';
7817
7817
 
7818
7818
 
7819
+ -- >>> FROM: 00000000000030_setup_system_configuration.sql <<<
7820
+ -- 00000000000030_setup_system_configuration.sql
7821
+ -- First-Boot Setup Wizard: global system configuration.
7822
+ --
7823
+ -- Adds a dedicated, RLS-locked \`system_configuration\` table that holds settings the
7824
+ -- browser /setup wizard manages and that don't belong in the public key-value
7825
+ -- \`site_settings\` store. It is a singleton (exactly one row, id = 1).
7826
+ --
7827
+ -- Shape:
7828
+ -- auto_accept_signups boolean -- when true, new public sign-ups skip outbound email
7829
+ -- verification (the signup route uses a service-role
7830
+ -- admin.createUser({ email_confirm: true }) path).
7831
+ -- settings jsonb -- forward-compatible catch-all for future feature
7832
+ -- toggles ({} by default). Do NOT store true secrets
7833
+ -- here (Turnstile/AI secrets keep living in their
7834
+ -- existing site_settings sensitive keys).
7835
+ --
7836
+ -- Access is locked to the ADMIN role for normal clients (NextBlock has no separate
7837
+ -- "super-admin" tier — ADMIN is the top level). The service_role retains full access
7838
+ -- so the wizard can seed/read it before any admin exists.
7839
+
7840
+ CREATE TABLE IF NOT EXISTS public.system_configuration (
7841
+ id integer PRIMARY KEY DEFAULT 1,
7842
+ auto_accept_signups boolean NOT NULL DEFAULT false,
7843
+ settings jsonb NOT NULL DEFAULT '{}'::jsonb,
7844
+ updated_at timestamptz NOT NULL DEFAULT now(),
7845
+ CONSTRAINT system_configuration_singleton CHECK (id = 1)
7846
+ );
7847
+
7848
+ COMMENT ON TABLE public.system_configuration IS
7849
+ 'Singleton (id = 1) of global setup-wizard configuration. ADMIN-only via RLS; never store secrets in settings.';
7850
+
7851
+ -- Seed the single row so reads always find it.
7852
+ INSERT INTO public.system_configuration (id, auto_accept_signups, settings)
7853
+ VALUES (1, false, '{}'::jsonb)
7854
+ ON CONFLICT (id) DO NOTHING;
7855
+
7856
+ ALTER TABLE public.system_configuration ENABLE ROW LEVEL SECURITY;
7857
+
7858
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.system_configuration TO authenticated;
7859
+ GRANT ALL ON public.system_configuration TO service_role;
7860
+
7861
+ -- ADMIN-only for every operation by authenticated clients.
7862
+ DROP POLICY IF EXISTS system_configuration_admin_select ON public.system_configuration;
7863
+ CREATE POLICY system_configuration_admin_select
7864
+ ON public.system_configuration
7865
+ FOR SELECT
7866
+ TO authenticated
7867
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN');
7868
+
7869
+ DROP POLICY IF EXISTS system_configuration_admin_insert ON public.system_configuration;
7870
+ CREATE POLICY system_configuration_admin_insert
7871
+ ON public.system_configuration
7872
+ FOR INSERT
7873
+ TO authenticated
7874
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');
7875
+
7876
+ DROP POLICY IF EXISTS system_configuration_admin_update ON public.system_configuration;
7877
+ CREATE POLICY system_configuration_admin_update
7878
+ ON public.system_configuration
7879
+ FOR UPDATE
7880
+ TO authenticated
7881
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN')
7882
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');
7883
+
7884
+ DROP POLICY IF EXISTS system_configuration_admin_delete ON public.system_configuration;
7885
+ CREATE POLICY system_configuration_admin_delete
7886
+ ON public.system_configuration
7887
+ FOR DELETE
7888
+ TO authenticated
7889
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN');
7890
+
7891
+ -- Service role bypasses the ADMIN checks (used by the wizard before an admin exists,
7892
+ -- and by the signup route to read auto_accept_signups as an anonymous visitor).
7893
+ DROP POLICY IF EXISTS system_configuration_service_role_all ON public.system_configuration;
7894
+ CREATE POLICY system_configuration_service_role_all
7895
+ ON public.system_configuration
7896
+ FOR ALL
7897
+ TO service_role
7898
+ USING (true)
7899
+ WITH CHECK (true);
7900
+
7901
+
7819
7902
  -- Step D: Anchor preserved profiles
7820
7903
  INSERT INTO public.profiles (id, updated_at, full_name, avatar_url, website, role)
7821
7904
  SELECT preserved_user.id, NULL, NULL, NULL, NULL, 'ADMIN'
@@ -1,6 +1,6 @@
1
1
  // app/api/process-image/route.ts
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import { getS3Client } from '@nextblock-cms/utils/server';
3
+ import { getS3Client } from '@nextblock-cms/utils/server';
4
4
  import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
5
5
  import sharp from 'sharp';
6
6
  import { Readable } from 'stream';
@@ -54,13 +54,13 @@ export async function POST(request: NextRequest) {
54
54
 
55
55
 
56
56
  try {
57
- const s3Client = await getS3Client();
58
- if (!s3Client) {
59
- console.error('R2 client is not configured. Check your R2 environment variables.');
60
- return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
61
- }
62
-
63
- const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
57
+ const s3Client = await getS3Client();
58
+ if (!s3Client) {
59
+ console.error('R2 client is not configured. Check your R2 environment variables.');
60
+ return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
61
+ }
62
+
63
+ const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
64
64
 
65
65
  if (!originalObjectKey || !originalContentType) {
66
66
  return NextResponse.json({ error: 'Missing objectKey or contentType in request body.' }, { status: 400 });
@@ -127,7 +127,11 @@ export async function POST(request: NextRequest) {
127
127
  .toFormat(TARGET_FORMAT, { quality: 75 }) // Adjust quality as needed
128
128
  .toBuffer();
129
129
 
130
- const newObjectKey = `${baseName}_${size.label}.${TARGET_FORMAT}`;
130
+ // size.label keeps its '_avif' suffix as the stored variantLabel (used for
131
+ // variant selection), but it's redundant in the filename since the extension is
132
+ // already .avif — strip it so keys read `..._large.avif`, not `..._large_avif.avif`.
133
+ const fileSuffix = size.label.replace(/_avif$/, '');
134
+ const newObjectKey = `${baseName}_${fileSuffix}.${TARGET_FORMAT}`;
131
135
  const newPublicUrl = `${R2_PUBLIC_URL_BASE}/${newObjectKey}`;
132
136
 
133
137
  const putObjectParams = {
@@ -25,6 +25,11 @@ import { getRequestOrigin } from '../../../lib/visual-editing/edit-info';
25
25
 
26
26
  export const dynamicParams = true;
27
27
  export const revalidate = 3600;
28
+ // Render per-request: the shared root layout reads cookies()/headers()/draftMode() (auth + locale),
29
+ // so attempting a statically-cached render throws DYNAMIC_SERVER_USAGE (500). Matches the sibling
30
+ // content routes /[slug] and /product/[slug], which already force dynamic for the same reason.
31
+ export const dynamic = 'force-dynamic';
32
+ export const fetchCache = 'force-no-store';
28
33
 
29
34
  interface ResolvedPostParams {
30
35
  slug: string;
@@ -67,7 +67,13 @@ export default function ContentLanguageSwitcher({
67
67
  .eq('translation_group_id', currentItem.translation_group_id);
68
68
 
69
69
  if (error) {
70
- console.error(`Error fetching translations for ${itemType} group ${currentItem.translation_group_id}:`, error);
70
+ // Surface a useful message: a network "Failed to fetch" (e.g. the browser
71
+ // Supabase client before the runtime public env is in place) serializes to {},
72
+ // so log the message/code explicitly.
73
+ console.error(
74
+ `Error fetching translations for ${itemType} group ${currentItem.translation_group_id}:`,
75
+ error.message || error.code || JSON.stringify(error),
76
+ );
71
77
  setTranslations([]);
72
78
  } else if (data) {
73
79
  const mappedTranslations = data.map(item => {
@@ -116,17 +122,17 @@ export default function ContentLanguageSwitcher({
116
122
 
117
123
  // Link to create new translation if it doesn't exist
118
124
  // This requires a more complex "create translation" flow or pre-created placeholders
119
- const tableMap = {
120
- 'page': 'pages',
121
- 'post': 'posts',
122
- 'product': 'products'
123
- };
124
- const baseUrl = `/cms/${tableMap[itemType]}`;
125
- const editUrl = version
126
- ? `${baseUrl}/${version.id}/edit`
127
- : itemType === 'product'
128
- ? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
129
- : `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
125
+ const tableMap = {
126
+ 'page': 'pages',
127
+ 'post': 'posts',
128
+ 'product': 'products'
129
+ };
130
+ const baseUrl = `/cms/${tableMap[itemType]}`;
131
+ const editUrl = version
132
+ ? `${baseUrl}/${version.id}/edit`
133
+ : itemType === 'product'
134
+ ? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
135
+ : `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
130
136
 
131
137
  if (version) {
132
138
  return (
@@ -160,4 +166,4 @@ export default function ContentLanguageSwitcher({
160
166
  </DropdownMenuContent>
161
167
  </DropdownMenu>
162
168
  );
163
- }
169
+ }
@@ -24,6 +24,10 @@ import {
24
24
  revokeTrustedDevice,
25
25
  type TrustedDeviceRow,
26
26
  } from '../../../../lib/auth/trustedDevices';
27
+ import {
28
+ getSystemConfiguration,
29
+ updateSystemConfiguration,
30
+ } from '../../../../lib/setup/system-config';
27
31
 
28
32
  export interface SecurityPanelData {
29
33
  email: string;
@@ -33,6 +37,7 @@ export interface SecurityPanelData {
33
37
  isAdmin: boolean;
34
38
  globalSettings: SecuritySettings;
35
39
  trustedDevices: TrustedDeviceRow[];
40
+ autoAcceptSignups: boolean;
36
41
  }
37
42
 
38
43
  async function requireUser() {
@@ -70,6 +75,31 @@ export async function getSecurityPanelData(): Promise<SecurityPanelData> {
70
75
  isAdmin: profile?.role === 'ADMIN',
71
76
  globalSettings: await readSecuritySettings(),
72
77
  trustedDevices: await listTrustedDevices(user.id),
78
+ autoAcceptSignups: (await getSystemConfiguration()).auto_accept_signups,
79
+ };
80
+ }
81
+
82
+ // --- Sign-up policy (admin only) ------------------------------------------------
83
+
84
+ export async function updateAutoAcceptSignups(formData: FormData) {
85
+ const { supabase, user } = await requireUser();
86
+ const { data: profile } = await supabase
87
+ .from('profiles')
88
+ .select('role')
89
+ .eq('id', user.id)
90
+ .single();
91
+ if (profile?.role !== 'ADMIN') {
92
+ throw new Error('Only administrators can change the sign-up policy.');
93
+ }
94
+
95
+ const enabled = formData.get('auto_accept_signups') === 'true';
96
+ await updateSystemConfiguration({ auto_accept_signups: enabled });
97
+ revalidatePath('/cms/settings/security');
98
+ return {
99
+ success: true,
100
+ message: enabled
101
+ ? 'New sign-ups will be auto-approved without email verification.'
102
+ : 'New sign-ups now require email verification.',
73
103
  };
74
104
  }
75
105