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.
- package/bin/create-nextblock.js +40 -60
- package/docker-template/.env.docker.example +5 -4
- package/docker-template/Dockerfile +20 -3
- package/docker-template/docker-compose.yml +5 -1
- package/docker-template/scripts/docker-setup.mjs +19 -4
- package/package.json +1 -1
- package/templates/nextblock-template/Dockerfile +20 -3
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/actions.ts +58 -8
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
- package/templates/nextblock-template/app/api/process-image/route.ts +13 -9
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +19 -13
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
- package/templates/nextblock-template/app/layout.tsx +49 -3
- package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
- package/templates/nextblock-template/app/page.tsx +6 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/providers.tsx +1 -1
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +773 -0
- package/templates/nextblock-template/app/setup/layout.tsx +13 -0
- package/templates/nextblock-template/app/setup/page.tsx +103 -0
- package/templates/nextblock-template/components/AppShell.tsx +12 -0
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -0
- package/templates/nextblock-template/components/header-auth.tsx +24 -62
- package/templates/nextblock-template/docker-compose.yml +5 -1
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
- package/templates/nextblock-template/docs/README.md +2 -0
- package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +9 -1
- package/templates/nextblock-template/lib/setup/actions.ts +370 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +392 -0
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
- package/templates/nextblock-template/lib/setup/types.ts +18 -0
- package/templates/nextblock-template/next.config.js +13 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +143 -49
- package/templates/nextblock-template/scripts/docker-setup.mjs +19 -4
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/bin/create-nextblock.js
CHANGED
|
@@ -104,43 +104,9 @@ async function handleCommand(projectDirectory, options) {
|
|
|
104
104
|
hostingMode = modeChoice;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
if (!yes && hostingMode === '
|
|
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
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
if (!yes) {
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
42
|
-
NEXT_PUBLIC_R2_PUBLIC_URL=http://
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
190
|
-
NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|