super admin prod fix
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
dimitar 2026-08-03 18:10:19 +02:00
parent abc7f77622
commit 8c6390d6ac
10 changed files with 83 additions and 13 deletions

View File

@ -37,6 +37,11 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# The `prisma` CLI is not part of the standalone trace, and `npx prisma`
# fails non-interactively in the runner. Install it (pinned to the project's
# Prisma version) so `scripts/start.sh` can run `prisma migrate deploy`.
RUN npm install --no-save --no-audit --no-fund prisma@5.22.0
COPY scripts/start.sh /app/start.sh
RUN chmod +x /app/start.sh

View File

@ -36,10 +36,10 @@ Username and bcrypt-hashed password are read from env:
Because `Code.createdById` is a non-nullable FK to `AdminUser`, the
env super-admin also needs a row in `AdminUser`. Provisioning is
handled by `prisma/seed.ts`, which upserts the SUPER_ADMIN row from
the same env values. Run `npm run db:seed` after starting the DB
(or the container's `start.sh` will keep migrations up to date on
boot; seed is run manually or via CI as needed).
handled by `prisma/seed.cjs`, which upserts the SUPER_ADMIN row from
the same env values. Run `npm run db:seed` after starting the DB; the
container's `scripts/start.sh` also runs it automatically on every
boot (after `prisma migrate deploy`).
## Codes

View File

@ -13,7 +13,7 @@
> bcrypt) — see `docs/admin.md`.
> - **Super-admin row in `AdminUser`**: planned as optional seed;
> required by the `Code.createdById` FK and provisioned by
> `prisma/seed.ts`.
> `prisma/seed.cjs` (run by `scripts/start.sh` on boot).
> - **Rate limiting, CSRF/Origin checks, password complexity, atomic
> code claim** (updateMany guard): all added in Phases 16 and not
> in the original plan.

View File

@ -109,6 +109,15 @@ S3_BUCKET_NAME=monuments-images
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Admin session signing secret (64+ random hex chars; openssl rand -hex 32)
ADMIN_SESSION_SECRET=your-random-64-char-secret
# Super-admin — username + BCRYPT HASH (not plaintext). Generate with:
# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))"
# Coolify UI env vars are passed to the container literally — no `$` escaping needed.
SUPER_ADMIN_USERNAME=super
SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH
# Node
NODE_ENV=production
```
@ -116,6 +125,11 @@ NODE_ENV=production
**Important:**
- `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`.
- Use your **production** Clerk keys (`pk_live_` / `sk_live_`), not the test ones.
- Use a **different, strong** super-admin password than your local development one.
- The `super` admin row is provisioned automatically on container start by
`scripts/start.sh` (`node prisma/seed.cjs` after migrations). If you instead
use a custom Nixpacks start command (Option A below), run the seed manually
after the first deploy: `npx prisma db seed`.
## Step 5: Configure Domain & Subdomain Routing
@ -315,6 +329,9 @@ npx prisma db push
| `S3_BUCKET_NAME` | Yes | S3 bucket name |
| `NEXT_PUBLIC_APP_URL` | Yes | `https://testbed.mk` |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | `testbed.mk` |
| `ADMIN_SESSION_SECRET` | Yes | Secret signing admin session cookies (openssl rand -hex 32) |
| `SUPER_ADMIN_USERNAME` | No | Super-admin username (default `super`) |
| `SUPER_ADMIN_PASSWORD_HASH` | No | Super-admin bcrypt hash; if unset, super login is unavailable |
| `NODE_ENV` | Yes | `production` |
## Useful Coolify Commands

View File

@ -104,9 +104,24 @@ S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Admin session signing secret (64+ random hex chars; openssl rand -hex 32)
ADMIN_SESSION_SECRET=your-random-64-char-secret
# Super-admin — username + BCRYPT HASH (not plaintext) of the super-admin password.
# Generate the hash with:
# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))"
# If you run the app via `docker compose` with these vars in a `.env` env_file,
# you MUST escape every `$` as `$$` (e.g. `$$2b$$12$$...`). Newer Compose
# versions interpolate env_file values and will otherwise strip the `$2b$12`
# prefix, silently breaking super-admin login. If the vars are provided via a
# platform UI (Coolify/Vercel) they are passed literally and need no escaping.
SUPER_ADMIN_USERNAME=super
SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH
```
**Important**: Use a strong, unique password for `POSTGRES_PASSWORD`.
**Important**: Use a strong, unique password for `POSTGRES_PASSWORD`. Do **not**
reuse the development super-admin password (`Irina@7654321`) in production.
### Create the Prisma Migration
@ -135,6 +150,14 @@ npx prisma migrate dev --name init
Then commit the generated migration files. The `scripts/start.sh` entrypoint will run `npx prisma migrate deploy` automatically on every container start.
### Super-admin provisioning
The `super` admin is provisioned from the environment on **every container
start**: `scripts/start.sh` runs `node prisma/seed.cjs` after migrations. It
upserts the `SUPER_ADMIN_USERNAME` row (role `SUPER_ADMIN`) with the bcrypt hash
from `SUPER_ADMIN_PASSWORD_HASH`. If the hash is unset the seed is skipped
(logged). No manual seeding is required on first deploy.
## 4. Configure Contabo S3
### Create the Bucket
@ -461,4 +484,7 @@ docker compose exec app printenv DATABASE_URL
| `S3_SECRET_ACCESS_KEY` | Yes | S3 secret key |
| `S3_BUCKET_NAME` | Yes | S3 bucket name (monuments-images) |
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL (https://testbed.mk) |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | Domain only (testbed.mk) |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | Domain only (testbed.mk) |
| `ADMIN_SESSION_SECRET` | Yes | Secret signing admin session cookies (openssl rand -hex 32) |
| `SUPER_ADMIN_USERNAME` | No | Super-admin username (default `super`) |
| `SUPER_ADMIN_PASSWORD_HASH` | No | Super-admin bcrypt hash; if unset, super login is unavailable |

View File

@ -14,10 +14,10 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts"
"db:seed": "node prisma/seed.cjs"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
"seed": "node prisma/seed.cjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1073.0",

View File

@ -1,5 +1,4 @@
import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const { PrismaClient, Role } = require("@prisma/client");
const prisma = new PrismaClient();

View File

@ -14,5 +14,8 @@ if ! npx prisma migrate deploy; then
exit 1
fi
echo "Provisioning super-admin (skips if SUPER_ADMIN_PASSWORD_HASH unset)..."
node prisma/seed.cjs
echo "Starting Next.js server on 0.0.0.0:3000..."
exec node server.js

View File

@ -2,6 +2,10 @@ import { getAdminSession } from "@/lib/admin-session";
import { redirect } from "next/navigation";
import AdminSidebar from "./AdminSidebar";
// Admin pages must always render against the live session — never at build
// time (static generation would run DB queries during `next build`).
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getAdminSession();
if (!session) {

View File

@ -4,6 +4,22 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
function getPrisma(): PrismaClient {
if (!globalForPrisma.prisma) {
globalForPrisma.prisma = new PrismaClient();
}
return globalForPrisma.prisma;
}
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// Lazy singleton: `new PrismaClient()` throws during `next build` when
// DATABASE_URL is absent (e.g. in a Docker builder stage where env files
// aren't present). Defer construction until the first real query so merely
// importing this module never fails. Method calls are bound to the real
// instance so `this` is preserved.
export const prisma = new Proxy({} as PrismaClient, {
get(_target, prop) {
const client = getPrisma();
const value = (client as unknown as Record<PropertyKey, unknown>)[prop];
return typeof value === "function" ? value.bind(client) : value;
},
});