Skip to content

Bonbon Dino — Admin Panel Checklist

Patterns for client sites that need a single-admin internal dashboard — the kind a small business owner uses to reply to Q&A, edit pricing, update business hours, or reset their password.

These are best practices, not rules. Adapt to the actual client. If the client doesn't need an admin panel, skip the whole file.

Reference impls: - Turso + Drizzle + bcrypt: rushroad_storage/ (lib/auth.ts, lib/access.ts, app/admin/, app/api/auth/) - D1 + raw prepare() + PBKDF2-via-WebCrypto: keilen_family_services/ (lib/auth.ts, lib/password.ts, lib/db.ts, middleware.ts, app/admin/, app/api/auth/, app/api/admin/*)


1. Single-Admin Model

What it does: One admin account per site. No user table, no roles, no signup. The first admin is seeded from env vars; from there the credentials live in a small admin_credentials DB table the admin can update via the settings page.

Why: A self-storage business owner has one employee logging in. A users table + RBAC is overkill and adds attack surface. Seeded-then-DB-stored credentials let the admin change their own email/password without a redeploy.

Pattern: - app/api/auth/login/route.ts checks the DB first; falls back to env-var compare if DB is empty. - On first successful env-var login, write the credentials to DB; from then on the env var is irrelevant. - Settings page (app/admin/settings) lets the admin update email + password. - Reference impl: rushroad_storage/app/api/auth/login/route.ts, lib/schema.ts (adminCredentials table).


2. JWT Session with jose

What it does: Admin login sets an httpOnly cookie containing a JWT signed with HS256. Every admin server component calls getSession() at the top and redirects to /admin (login) if it returns null.

Why: No third-party auth dependency, no DB session table. jose is ESM, works on Cloudflare Workers, and is one of the few JWT libs that doesn't break in the OpenNext build.

Pattern:

// lib/auth.ts
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function signSession(email: string) {
  return new SignJWT({ email })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(secret);
}

export async function verifySession(token: string) {
  const { payload } = await jwtVerify(token, secret);
  return payload as { email: string };
}

export async function getSession() {
  const token = (await cookies()).get("rrs-admin-session")?.value;
  if (!token) return null;
  try { return await verifySession(token); } catch { return null; }
}

Cookie settings: - name: prefix with the site slug (e.g., rrs-admin-session) so it doesn't collide with other apps on the same domain. - httpOnly: true, secure: true, sameSite: "lax", path: "/". - 7-day expiry for normal admin use; 8h if security matters more than convenience.

Server-component guard:

// app/admin/dashboard/page.tsx
export default async function Page() {
  const session = await getSession();
  if (!session) redirect("/admin");
  // ... render dashboard
}


3. Password Hashing — Bcrypt (Node/Vercel) OR PBKDF2 via WebCrypto (Cloudflare Workers)

Two valid approaches, pick by deploy target.

3a. Bcrypt (Node, Vercel, Pages with Node runtime)

What it does: Passwords are hashed with bcrypt cost=12. Password comparison uses crypto.timingSafeEqual (or bcrypt.compare which already does this internally).

Why: Cost=12 is the current sweet spot (~250ms on a Worker, slow enough to deter offline cracking, fast enough to not impact UX). Timing-safe compare prevents timing attacks on email lookup (if (email === storedEmail) is unsafe — use timingSafeEqual on Buffer comparison or always run bcrypt.compare even when the email doesn't exist, to keep response time constant).

Pattern (constant-time email check):

// Always run bcrypt.compare even when user isn't found
const dummyHash = "$2a$12$............................................";
const hash = found?.passwordHash ?? dummyHash;
const ok = await bcrypt.compare(submittedPassword, hash);
if (!found || !ok) return new Response("Invalid credentials", { status: 401 });

Hashing utility: Keep scripts/hash-password.ts in the repo so the admin or developer can regenerate the env-var hash with one command:

npx tsx scripts/hash-password.ts <password>

3b. PBKDF2-HMAC-SHA256 via WebCrypto (Cloudflare Workers / OpenNext) — default since 2026-06

What it does: Passwords are hashed with PBKDF2 (300k iterations, 16-byte random salt, 32-byte derived key) using crypto.subtle.deriveBits, available natively in the Workers runtime. Comparison uses a hand-rolled timingSafeEqual (XOR-accumulate, no early return).

Why PBKDF2 over bcrypt on Workers: - bcryptjs works on Workers but is ~3× slower per call than PBKDF2-HMAC-SHA256 at the same work factor, and pulls in a wasm/js fallback. WebCrypto's PBKDF2 is a single, native call. - OWASP's 2026 guidance for PBKDF2-HMAC-SHA256 is ~600k iterations. Workers' CPU budget caps us around 300k–600k depending on compatibility_date; 300k verifies in ~150ms, which is the practical ceiling. Store the iteration count inside the hash so the same verifyPassword() function can verify older hashes and re-hash on next change. - verifyPassword is constant-time even when no admin row exists — always run the derivation so a missing user and a wrong password take the same time. Use a stored hash from admin_credentials if present, otherwise a known dummy hash for the comparison.

Pattern (lib/password.ts):

const ITERATIONS = 300_000
const SALT_BYTES = 16
const KEY_BITS = 256

export async function hashPassword(password: string): Promise<string> {
  const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))
  const hash = await deriveBits(password, salt, ITERATIONS)
  return `pbkdf2$${ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`
}

export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  const parts = stored.split('$')
  if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false
  const iterations = Number(parts[1])
  if (!Number.isInteger(iterations) || iterations <= 0) return false
  let salt, expected
  try { salt = fromBase64(parts[2]); expected = fromBase64(parts[3]) }
  catch { return false }
  const actual = await deriveBits(password, salt, iterations)
  return timingSafeEqual(actual, expected)
}

Stored format: pbkdf2$<iterations>$<saltBase64>$<hashBase64>. Encoding the iteration count means raising the count (e.g., from 100k → 300k) doesn't break existing hashes — verifyPassword reads the count from the stored string and uses that. New hashes use the new count; old hashes continue to verify at their old count.

Bumping the iteration count later: just change the ITERATIONS constant. No migration needed. Optionally rotate to the higher count on next successful login (if (iterations < CURRENT_ITERATIONS) await hashPassword(password)) to bring existing accounts forward.

Why not bcryptjs? It works, but per-verify latency is higher and OWASP's PBKDF2 guidance is what we'll be benchmarked against in a security review. Default to PBKDF2-via-WebCrypto on Workers; reach for bcrypt only if a non-WebCrypto deployment target forces it.

Reference impl: keilen_family_services/lib/password.ts


4. Login Flow

What it does: app/admin/page.tsx (login form) → POST /api/auth/login → bcrypt check → set cookie → redirect to /admin/dashboard.

Pattern: - Client form (AdminLoginForm) posts JSON { email, password }. - API route validates, calls signSession, sets the cookie, returns { ok: true }. - Form does router.push("/admin/dashboard") on success, shows error banner on failure.

UX: - Show a "Forgot password?" link below the form — link to /admin/forgot-password. - On a successful password reset, redirect to /admin?reset=success and show a success flash on login. - Wrap login form with py-12 mx-auto (NOT min-h-screen flex items-center justify-center — that floats the form in a giant empty box and looks unbalanced).


5. Password Reset Flow

What it does: Admin clicks "Forgot password?" → enters email → token emailed → admin clicks link → enters new password → redirect to login.

Pattern: - Separate table password_reset_tokens with columns id, token_hash, expires_at, used_at, created_at. - Token is a random 32-byte hex string; store the bcrypt hash, never the plaintext. - Expire after 30 minutes; mark used_at on first use; single-use. - Reset link format: ${DOMAIN}/admin/reset-password?token=<plaintext>. - Validate DOMAIN env var before the DB insert. No DOMAIN → return 500 before writing the token row. Otherwise you get orphan rows from typos or missing env vars and emailable links that 404. - After reset, invalidate any other active tokens for the same admin (UPDATE ... SET used_at = NOW() WHERE used_at IS NULL).

Reset password form: - Client-side min-length validation matches the API min-length. - Show "password updated" success state, then redirect to /admin?reset=success after 1.5s.

Reference impl: rushroad_storage/app/api/auth/forgot-password/route.ts, app/api/auth/reset-password/route.ts, lib/schema.ts (passwordResetTokens).


6. Settings Page (Email + Password Update)

What it does: Admin can change their own email or password from /admin/settings. No "user management" UI — single admin, single record.

Pattern: - Read current email from DB via getSession() payload. - Email update: simple form, server route verifies session + writes to DB. - Password update: ask for current password (bcrypt verify), then accept new password (re-hash, write). - Show/hide password toggle: put the className for sizing/padding on the wrapper <div>, NOT on the <input>. Putting padding-right on the input pushes the eye-icon button outside the input's visual box. - Buggy: <input className="pr-10" /> + absolute-positioned eye button - Correct: <div className="relative pr-10"><input /> <button className="absolute right-2 top-2">eye</button></div>

Reference impl: rushroad_storage/components/password-input.tsx, app/admin/settings/page.tsx.


7. Admin Layout Conventions

What it does: Consistent layout across /admin/* so the admin always knows where they are.

Pattern: - Sticky top bar with site logo (links to /admin/dashboard) on the left, current admin email + logout button on the right. - Sidebar (collapsed to hamburger on mobile) with: Dashboard / Site Info / Settings / Logout. - Hide the public site's CTA strip on admin pages (different UX context). - Don't share the public header — admin pages have their own slimmer header.

Mobile: Hide the desktop sidebar entirely (hidden md:block) and add a top-nav-bar dropdown instead. A 320px-wide phone with a sidebar leaves no room for content.


8. replied_at / created_at UI

What it does: Dashboard lists items (questions, reset tokens, audit log) ordered by date with relative-time pills ("3 hours ago").

Pattern: - Server formats the date to a short string + relative pill, passes both to the client. - Use date-fns/formatDistanceToNow or write a tiny helper — don't pull in moment. - ALWAYS store dates as ISO 8601 text (not int timestamps). See db-and-deploy-checklist.md for why.


9. Logout

What it does: POST /api/auth/logout clears the cookie, redirects to /admin.

Pattern: - Cookie cleared by setting Max-Age=0 (not just value=""). - Logout button on every admin page. - After logout, the admin lands on the login screen — not the public homepage.


10. Square Invoice Sending

What it does: Admin fills in client name, email, phone, service description, amount, and due date. The server creates or finds the Square customer, creates an order and invoice, publishes it, and returns the hosted invoice URL to show the admin.

Why: Service-based clients (mediators, therapists, consultants) need per-session billing without a full e-commerce storefront. Square Invoices sends a professional payment link — no credit card terminal needed.

Pattern: Three files — a raw-fetch Square client, a pure form parser, and a thin API route:

  • lib/square.ts — 5-function chain: findOrCreateCustomer, createOrder, createInvoice, publishInvoice, plus a top-level sendInvoice orchestrator. No SDK — raw fetch to https://connect.squareup.com (prod) or https://connect.squareupsandbox.com (sandbox).
  • lib/invoices.tsparseInvoiceForm(formData) returns { ok: true; data } | { ok: false; error }. No network calls, fully unit-testable.
  • app/api/admin/invoices/route.tsgetSession() guard → parseInvoiceFormsendInvoice → return { invoiceNumber, publicUrl }.

Reference impl: keilen_family_services/lib/square.ts, lib/invoices.ts, app/api/admin/invoices/route.ts, components/admin/InvoiceForm.tsx

Critical gotchas: - Pin Square-Version header to a date string (e.g. '2026-05-21'). Freezes response shape against future API changes. - Every mutating POST needs idempotency_key: crypto.randomUUID() — required for customer create, order create, invoice create, and invoice publish. Omitting it causes duplicate records on retries. - findOrCreateCustomer searches by email first before creating. Prevents duplicate customer records when the same client is invoiced again later. - Invoice publish is a separate API call from invoice create. The version field from the create response must be passed into the publish call — the publish will fail without it. - Best-effort cancel the order on invoice create/publish failure. The naive 3-call flow (order → invoice → publish) leaves a draft order dangling in Square if step 2 or 3 throws (network blip, validation error, rate limit). Wrap invoice create + publish in try/catch and on failure POST /v2/orders/{id} with state: 'CANCELED' to transition the orphan to a terminal state. Idempotency key required (any UUID). Swallow the cancel error — the original error is what the caller needs. Don't try DELETE; Square orders can only transition state, not be removed. Reference impl: keilen_family_services/lib/square.ts (sendInvoice + private cancelOrder). - Dollar → cents conversion: Math.round(parseFloat(raw) * 100), NOT parseInt. parseInt("150.50") returns 150, silently truncating cents. - Three env vars required: SQUARE_ACCESS_TOKEN (wrangler secret), SQUARE_LOCATION_ID (var), SQUARE_ENVIRONMENT ("sandbox" | "production" — validate on startup, throw if invalid).


11. Centralized Admin Auth Gate via Next Middleware (2026-06)

What it does: A middleware.ts at the project root verifies the admin JWT session cookie once, before any /admin/* page or /api/admin/* route runs. Per-page getSession() checks remain as defense in depth.

Why: Without middleware, security depends on every admin page calling getSession() + redirect('/admin'). The app/admin/layout.tsx pattern returns children even when unauthenticated (the layout's only job is to add the top bar), so the next admin page added could silently leak if a developer forgets the per-page guard. Middleware makes the gate a single, hard-to-miss location.

Pattern:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const SESSION_COOKIE = '<site-prefix>-admin-session'

export async function middleware(request: NextRequest) {
  // Fail closed if JWT_SECRET is missing — never assume an open surface.
  if (!process.env.JWT_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
  const token = request.cookies.get(SESSION_COOKIE)?.value
  let valid = false
  if (token) {
    try {
      await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET))
      valid = true
    } catch { valid = false }
  }
  if (valid) return NextResponse.next()
  const { pathname } = request.nextUrl
  if (pathname.startsWith('/api/')) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
  // Browser page: bounce to login, preserving the original target.
  const loginUrl = request.nextUrl.clone()
  loginUrl.pathname = '/admin'
  loginUrl.searchParams.set('next', pathname)
  return NextResponse.redirect(loginUrl)
}

export const config = {
  matcher: [
    // Exclude the login page (path "/admin" exactly) and the public
    // reset-password flow. Otherwise we'd redirect the login form away.
    '/admin/((?!$|reset-password).*)',
    '/api/admin/:path*',
  ],
}

Key details: - jose is Workers/edge-compatible. Plain jsonwebtoken is not. - Fail closed when JWT_SECRET is missing — return 401 rather than letting the request through. The deploy is misconfigured; refuse to assume an open surface. - The matcher excludes /admin (the login page itself) and /admin/reset-password (public flow). Otherwise you get an infinite redirect loop. - API routes get a 401 JSON response; browser pages redirect to /admin?next=… so the user lands back where they were trying to go after signing in. - Keep the per-page getSession() checks. Middleware is defense in depth, not a replacement. A bug in the matcher (wrong regex, missed route) should not silently expose an admin page.

Reference impl: keilen_family_services/middleware.ts


12. Turnstile-Gated Login & Forgot-Password (2026-06)

What it does: Both POST /api/auth/login and POST /api/auth/forgot-password require a valid Turnstile token alongside the user input. Without it, return 400 with a generic message; same generic response on a bad token.

Why: A single-admin login form is an automated brute-force target. An open forgot-password endpoint can be hammered to email-bomb the admin or invalidate an in-progress reset by overwriting the token row on every call.

Pattern (server):

// app/api/auth/login/route.ts
const turnstileToken = typeof body.turnstileToken === 'string' ? body.turnstileToken : ''
if (!turnstileToken) return NextResponse.json({ error: 'Security check incomplete.' }, { status: 400 })
if (!(await verifyTurnstile(turnstileToken))) {
  return NextResponse.json({ error: 'Security check failed.' }, { status: 400 })
}
// ... rest of login flow

Pattern (client form): - Render <Turnstile siteKey={...} onSuccess={setToken} /> below the submit button; submit becomes disabled={!token || loading}. - Send { email, password, turnstileToken } to the API. - For layout gotchas (button-above-captcha is confusing for users), see contact-form-checklist.md §2a and the KFS ContactForm.tsx helper-text pattern.

Forgot-password specifically: - Don't issue a new token if an unexpired one already exists. The naive DELETE then INSERT pattern lets attackers invalidate a legitimate user's in-progress reset. Look up the existing token's expires_at first and short-circuit if it's still in the future. - Still 200-OK the response when no admin row exists (anti-enumeration — never leak whether the admin account exists).

Reuse the same verifyTurnstile(token) helper as the contact form. Extract to lib/turnstile.ts so all three call sites (contact, login, forgot-password) share one siteverify implementation. Don't duplicate the fetch('https://challenges.cloudflare.com/...') call in every route.

Pair with a Cloudflare dashboard rate-limit rule on these two routes. Turnstile slows bots but doesn't enforce an upper bound on request rate. The dashboard rule (Security → WAF → Rate limiting) is the second layer. See db-and-deploy-checklist.md §11.

Reference impl: keilen_family_services/lib/turnstile.ts, app/api/auth/login/route.ts, app/api/auth/forgot-password/route.ts, components/admin/LoginForm.tsx


13. Require Current Password to Change Admin Email (2026-06)

What it does: POST /api/admin/credentials/update-email re-prompts for currentPassword and verifies it against the stored hash before persisting the new email — matching what update-password already does.

Why: A live session can change the admin login email without re-confirming the password (unlike update-password, which already verifies currentPassword). The session already proves identity, and sameSite=lax + JSON-only requests mitigate CSRF, but requiring the current password adds resilience to a hijacked-session scenario and brings the email-update flow to parity with the password-update flow.

Pattern:

// app/api/admin/credentials/update-email/route.ts
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : ''
if (!currentPassword) {
  return NextResponse.json({ error: 'Current password is required' }, { status: 400 })
}
const row = await db
  .prepare('SELECT password_hash FROM admin_credentials WHERE id = 1')
  .first<AdminRow>()
if (!row || !(await verifyPassword(currentPassword, row.password_hash))) {
  return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
// ... update the email, re-issue the session cookie

Client-side: add a "Current password" field to the change-email form, alongside the new email input. The change-password form already has one — keep them visually consistent.

Reference impl: keilen_family_services/app/api/admin/credentials/update-email/route.ts


14. D1 Provisioning + Admin Secrets (2026-06)

What it does: Creates the <client>-admin D1 database in the client's Cloudflare account, applies the migration, and sets the three runtime secrets the admin panel needs to operate.

Why this is a separate step: D1 creation requires a Cloudflare API token with D1:Edit scope. The default "Edit Cloudflare Workers" token does NOT include this. Omitting D1 causes every login attempt to throw an unhandled exception (empty 500 body → "Unexpected end of JSON input" on the client).

The API token must be scoped to the CLIENT's account. If you use the developer MCP token, the database lands in the wrong Cloudflare account and the Worker can't reach it.

Step 1 — Add D1:Edit to the API token

In the client's Cloudflare dashboard (logged in as the client's email): - My Profile → API Tokens → Edit the existing token → Add permission: Account → D1 → Edit - Update CLOUDFLARE_API_TOKEN in .env.local

Step 2 — Create the database

export $(grep CLOUDFLARE_API_TOKEN .env.local | xargs)
npx wrangler d1 create <client-slug>-admin
# Copy the database_id UUID from the output

Add to wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "<client-slug>-admin"
database_id = "<uuid-from-above>"

Step 3 — Apply migrations

npx wrangler d1 execute <client-slug>-admin --remote --file migrations/0001_admin.sql

Step 4 — Set the three secrets

Generate the password hash (Node.js — uses the same PBKDF2-HMAC-SHA256 as lib/password.ts):

node -e "
const crypto = require('crypto');
const saltBytes = crypto.randomBytes(16);
const saltB64 = saltBytes.toString('base64');
crypto.pbkdf2('<password>', saltBytes, 300000, 32, 'sha256', (err, key) => {
  console.log('pbkdf2\$300000\$' + saltB64 + '\$' + key.toString('base64'));
});
"

Generate the JWT secret:

node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Set secrets on the Worker:

echo "<jwt-secret>"      | npx wrangler secret put JWT_SECRET --name <worker-name>
echo "<client-email>"    | npx wrangler secret put ADMIN_EMAIL --name <worker-name>
echo "<password-hash>"   | npx wrangler secret put ADMIN_PASSWORD_HASH --name <worker-name>

On first login the route lazy-seeds the admin_credentials row from ADMIN_EMAIL + ADMIN_PASSWORD_HASH. Subsequent logins read from D1 directly.

Do NOT add Turnstile to the admin login. Cloudflare Turnstile widgets fail on *.workers.dev staging URLs (the widget calls home and the domain isn't in the allowlist), leaving the Sign In button permanently disabled. The admin panel is a private tool — password + JWT is sufficient protection.

Reference impl: kobe/wrangler.toml, kobe/migrations/0001_admin.sql, kobe/lib/password.ts


Quick Checklist — "Is the admin panel done?"

  • [ ] Single-admin model: env-var seed → DB-stored credentials (no users table)
  • [ ] getSession() guard at the top of every /admin/* server component (defense in depth — see §11)
  • [ ] Centralized middleware.ts gate verifies the session cookie for /admin/* (except login + reset-password) and /api/admin/* (§11)
  • [ ] JWT cookie is httpOnly + secure + sameSite=lax + site-prefixed name
  • [ ] Password hashing matches the deploy target: bcrypt cost=12 (Node/Vercel) or PBKDF2-HMAC-SHA256 ≥ 300k iterations via WebCrypto (Workers); constant-time email lookup — always run verifyPassword/bcrypt.compare even when no row exists (§3)
  • [ ] Login form uses py-12 mx-auto, not full-screen flex-center
  • [ ] "Forgot password" link visible on login form
  • [ ] Admin login does NOT use Turnstile — widget fails on *.workers.dev; password + JWT is sufficient (§14)
  • [ ] D1 provisioned in the client's Cloudflare account, API token has D1:Edit scope, JWT_SECRET / ADMIN_EMAIL / ADMIN_PASSWORD_HASH set as Worker secrets (§14)
  • [ ] Forgot-password skips issuing a new token when an unexpired one exists (§12)
  • [ ] Change-email form re-prompts for current password and verifies it before persisting (§13)
  • [ ] Reset tokens: hashed at rest, 30-min expiry, single-use, DOMAIN validated before insert
  • [ ] Show/hide password toggle has className on the wrapper <div>, not the <input>
  • [ ] Settings page only updates current admin's own email/password
  • [ ] Admin layout has its own header — does NOT share the public site CTAStrip
  • [ ] Mobile: sidebar hidden, replaced by top-nav dropdown
  • [ ] All dates stored as ISO 8601 text (not int)
  • [ ] Logout clears cookie via Max-Age=0 and redirects to /admin
  • [ ] Square: Square-Version header pinned to a date string
  • [ ] Square: idempotency_key: crypto.randomUUID() on every mutating POST (customer create, order create, invoice create, invoice publish)
  • [ ] Square: best-effort cancel the order on invoice create/publish failure (§10)

How to extend this file

  • This is a living document. Rewrite existing sections in place when they go stale (e.g., when the deploy mechanism changes and a code snippet needs updating, or when a stale "bcrypt" section is replaced by "PBKDF2 for Workers"). The old rule was append-only; it has been retired — outdated content is worse than no content.
  • When adding new content, insert it as a new numbered section before the Quick Checklist. Match the house style: numbered section, What it does / Why / Pattern, code snippet where useful. Optionally date-stamp the section header (e.g., ## 12. Turnstile-Gated Login (2026-08)).
  • When another checklist covers something, reference it instead of duplicating: "see website-checklist.md §17".
  • The Quick Checklist at the bottom is the canonical "is the panel done?" list. Add or update an item whenever you add or change a section above.