Operators handbook — overview

The admin SPA at cms-admin.internal is the operator's surface. Login, manage one or many websites, edit pages inline, configure modules.

This page covers the core navigation + the per-website Settings tab. For per-module configuration see modules/. For the form-protection + mail-from refusal model see security.md.

Login

JWT-bearer auth. Username + password (or passkey, if you've registered one in the Profile page). Tokens are stored in localStorage keys cms-admin.access / cms-admin.refresh. Idle for too long → silent refresh; refresh expired → kicked back to login.

Roles, in increasing power:

Role What they can do
ROLE_EDITOR Everything in every enabled module: pages, media, FAQ, orders, customers, vouchers, shipping, newsletter, landing pages. No platform settings.
ROLE_DESIGNER Editor + can deploy and activate theme versions.
ROLE_ADMIN Designer + per-website settings: which modules are on, payment gateways, checkout and customer settings, Bol credentials, and user management.
ROLE_SUPER_ADMIN Admin + can create/delete websites, impersonate users, and manage page translations across every tenant.

The line between Editor and Admin is modules vs. settings, not content vs. commerce. If a module is enabled on the website, an editor can work in it; deciding which modules are on, wiring up what they talk to (payment gateways, Bol), and adding people are administration.

Worth knowing before handing out the editor role: it includes customer records, order history and newsletter subscribers, which are personal data. There is no sub-role that grants the shop without them.

The role dropdown lives on the Users page. Membership of a website is separate from the role — a ROLE_EDITOR user must also be added to each website they should have access to.

Active website

Most operations happen against one currently-selected website.

  • Editors / Admins see only the websites they're members of. The website picker at the top of the sidebar switches between them.
  • Super-admins see every website and can also pick "no active website" via Clear selection — useful for the cluster-wide pages (Users, Modules registry).

The active website is server-stored (per session), not in localStorage. Reloading the SPA preserves it. Switching websites refreshes the page list, the media library, the Modules tab — everything is scoped.

The sidebar is grouped into Content, Customers, Commerce, Insights, and System sections. Module-gated items only appear when the relevant module is effectively on; the System section is for admins only. A number next to Orders and Form submissions is work waiting (paid orders to ship, unread submissions); it and both lists refresh themselves while the tab is open.

Section Item Shows when… What it covers
— Dashboard always Page-tree overview, theme info + deploy widget, async compile status.
Content Pages always Page list + tree, create/edit/delete, drag-reorder.
Content Articles blog module on Blog articles + tags — editor-facing, like Pages.
Content Landing pages landingpages on and admin+ The landing-page dataset/generation surface.
Content Media always Upload library scoped to the active website. Used by image/gallery/video fields (see Uploading video and Uploading SVG).
Content Portfolio portfolio on Photo collections, each backed by a media folder (see modules/portfolio.md).
Content FAQ faq on Questions and answers, optional categories, one list per language (see modules/faq.md).
Content Menus always Named navigation menus (header_menu, footer_menu, …) — per locale. Drives nav_tree().
Customers Customers customer on Customer accounts, incl. company name and VAT number (see modules/customer.md).
Customers Customer groups customer on Groups that unlock restricted content.
Commerce Orders checkout on Order list; the detail has Order, Packing slip and Invoice tabs (see modules/checkout.md).
Commerce Products catalog on Product list (with categories, tags, tax classes, VAT category, variants behind it).
Commerce Categories catalog on Category tree.
Commerce Product options catalog on The shop-wide option list (Size, Colour) products pick from.
Commerce Catalog discounts catalog_discount on Automatic sale + volume price rules (see modules/catalog-discount.md).
Commerce Vouchers discount on Promo-code CRUD (see modules/discount.md).
Commerce Gift cards giftcard on Issued gift cards and their balances (see modules/giftcard.md).
Commerce Shipping shipping on Carriers, zones, rate tables (see modules/shipping.md).
Commerce Holiday closure checkout on Closed dates and the storefront warning (see modules/checkout.md).
Commerce Delivery moments delivery_slot on Time windows per weekday, cut-off, days bookable ahead and closed dates for the delivery moment a shopper picks at checkout (see modules/delivery-slot.md).
Commerce Newsletter newsletter on Campaigns, subscribers, settings (see modules/newsletter.md).
Insights Form submissions always Every form submission per website, incl. the quarantine review for spam-held ones. Unread ones are marked; opening one marks it read, Mark unread undoes that.
Insights Statistics always (editors included) Per-website analytics rollups.
System Websites admin+ Edit the active website's settings. Super sees every site in a list.
System Users admin+ Cluster-wide user management.
System VAT tax on and admin+ VAT profile, engine switch, VAT return per quarter, proof of transport (see modules/tax.md).
System Tax table super admin VAT categories and dated rates per country, for every shop (see modules/tax.md).

Your own user (change password, register a passkey, theme preference) is reached from the Profile link in the sidebar footer, not the section list above.

Uploading video

Video does not behave like an image upload, and the differences are visible in the library, so they are worth knowing.

It takes a while, and that is not a hang. Anything large — which in practice means every video — is uploaded in chunks, then finalised on a background worker: the pieces are reassembled, the file is converted, a poster frame is grabbed, and smaller copies are made. The library shows the upload as still processing until that finishes. A long clip can sit there for minutes. Closing the tab does not cancel it.

An interrupted upload can be resumed for a week. If the connection drops or the tab is closed while the file is still being sent, upload the same file again: only the missing pieces are sent. The pieces are kept for seven days after the last one arrived; after that a nightly cleanup removes them and the next attempt starts from the beginning. (The cleanup is the cms-cron-media-prune-uploads job in cms-k8s, which queues app:schedule:dispatch media:prune-uploads for cms-worker. Its log line "Pruned stale media upload sessions." says how many sessions and files went.)

The file you get back is not the file you sent. Every video is converted to MP4 (H.264/AAC) so it plays everywhere, and rearranged so it starts playing before it has fully downloaded. A .mov from a phone or a camera comes back as .mp4. That is expected — upload the original and let the CMS do it, rather than converting by hand first.

Smaller copies are made automatically. A 4K or 1080p upload also gets 720p (and 1080p) versions. The site picks one per visitor based on screen size and connection, so a phone is not made to download the full-size file. Nothing to configure; nothing to choose.

If conversion fails, the upload is kept, not thrown away. An exotic codec or a damaged file can defeat the converter. Rather than lose the upload, the original is stored and flagged. Such a file may not play in every browser — the usual fix is to re-export it as a standard MP4 and upload again.

Practical limits. A single upload caps at 500 MB. For a background header loop, a short clip of a few seconds is the right shape: it loops, it is always silent (browsers refuse to autoplay sound), and its file size is what visitors pay on every page load.

Deleting a video removes its poster and every generated copy with it.

Uploading SVG

An SVG is not a picture the way a JPEG is: it is a small text document, and it can carry script that runs on your site when someone opens the file. The CMS therefore cleans every SVG when it is uploaded, and serves every SVG in a way that stops anything in it from running.

What is taken out. Scripts, event handlers (onload= and the like), embedded HTML (<foreignObject>), links that point at javascript: or at another document, and references to files on other sites (an image or a stylesheet loaded from a URL). Comments and editor metadata, such as Inkscape and Illustrator layer information, go as well. Shapes, colours, gradients, inline styles and embedded PNG, JPEG or GIF images stay as they were, so a logo or an icon looks the same afterwards. The stored file can be a little smaller than the one you sent; that is the cleaning, not a problem.

What is refused. A file that is not valid XML with an <svg> root, and a file that relies on entities declared in its DOCTYPE. The second is what older Adobe Illustrator "Save as SVG" output does when Preserve Illustrator Editing Capabilities is on. The upload then fails with "This SVG could not be read". Export it again with File > Export > Export As... > SVG (or save it without that option) and upload the new file.

Imports are cleaned too. An SVG that a product import fetches from a URL goes through the same cleaning. One that cannot be read is left out, and the product is imported without that image, as with any image that fails to download.

SVGs uploaded before the cleaning existed were stored as they were sent. They are served under the same protection as new ones, so any script in them cannot run, and nothing needs doing. Uploading such a file again stores a cleaned copy, if you want the stored file itself to be clean.

Nothing changes in how an SVG is used on a page: in an image field, a gallery or a background it renders exactly as before.

The per-website Settings page

Open a website (Websites → click a row, or the cog next to the picker). Tabs:

General

  • Identity: code (immutable lock after creation; super only), display name, "Active" toggle (super only — taking a tenant offline).
  • Locales: default locale + the full set of supported locales.
  • Mail sender: the default From: address used by every outgoing email — form submissions, customer auth, order receipts. Required. Without it, mailers refuse to send and log. Per-channel overrides live on the per-form row + the Module-settings tab. See security.md → mail-from refusal model.
  • Cookies (AVG / GDPR): cookie consent banner config — enabled toggle, policy URL, free-form read-more text, version (bump to re-prompt every visitor when policy changes). Full page: cookie-consent.md.
  • Search engines: "Hide this site from search engines". On by default for a new website. See below.
  • Under construction: hold the whole site behind a "coming soon" page while it is built, with a list of addresses that still see the real thing. On by default for a new website. Stops people, where the search-engine switch above only stops crawlers. Full page: under-construction.md.
  • Google reviews: a Google Place ID for the website. Set it and the daily sync pulls in the place's Google reviews, surfaced to themes via cms_reviews(); there's a "Sync now" button to fetch on demand. Leave blank to keep the widget empty.

Theme

  • Theme code + theme repository. Super only: other admins see both read-only. The theme code is lowercase letters, digits, dashes and underscores (at most 64). The repository is one of:
    • <owner>/<repo> on our Gitea (e.g. ceems/svbeautique), read with the platform's Gitea token;
    • the HTTPS clone URL of a repository on any other git host (e.g. https://github.com/acme/theme.git, a GitLab subgroup, a customer's own Gitea). HTTPS only: no http://, no SSH, no user name or password in the URL. A renamed repository whose old URL redirects is not followed; enter the new URL.
  • Repository access token (super only, write-only): needed for a private repository. Enter the token on its own (sent with the user name x-access-token, which GitHub and Gitea accept), or <user>:<token> for a host that wants a particular user name with it: oauth2:<token> for GitLab, x-token-auth:<token> for a Bitbucket repository token. A read-only token is enough. For <owner>/<repo> on our Gitea a token here replaces the platform token for that repository. The token is encrypted when stored, never shown again, and removed when the repository changes: pointing the website at another repository without entering a new token leaves it without one, so a token is never sent to a host it was not entered for.
  • Theme versions (only after the website exists): list of deployed tarballs, "Activate" button per row. Activating flips Website.themeVersion instantly — no copy.
  • Deploy, remove, skip a tag: allowed when every website using the theme code is one you hold; a code shared with a website you do not hold is for a super admin. See security.md → theme codes and theme versions.

Hosts (super-only)

  • List of hostnames routing to this website + which one is canonical.
  • Add / remove hostnames. Mark canonical.
  • The canonical host drives 301 redirects from every alternate host (e.g. www.example.nl → example.nl) — see security.md → canonical redirects.

Forms

Visible once a theme version is deployed (the form-id detection scans the active theme's templates). Lists every form the theme uses + every form configured here, with a status chip per row:

  • active — theme uses it AND admin has filled in recipient.
  • detected, not configured — template calls form_start('foo') but no recipient is set; submissions will fail closed with "no recipient" message until configured.
  • orphan — admin config exists for an id no theme template uses; safe to delete.

Per-row settings:

  • Recipient — comma-separate for multiple inboxes.
  • Subject — defaults to <websiteName> form: <formId> if blank.
  • Success URL — path (e.g. /bedankt); blank → return to source page.
  • Sender email override / Sender name override — optional; blank → fall back to the General tab default.

Attachment settings (forms that accept file uploads) are not on this tab — they live in the Form settings dialog of the inline page editor: open the page holding the form → form block → Form settings → Attachment retention (days). Blank means the 90-day default. Files are deleted when the window closes; the submission itself is kept and shows each attachment as expired.

Designer-side reference: forms.md — including the upload limits and retention table.

Module settings

Visible when a module that ships configurable knobs is effectively enabled (or when you're admin+, so you can pre-configure before flipping the module on). Sub-tabs per module: Checkout, Customer, bol.com (only while the bol module is on) and Vehicle lookup. See modules/.

Each configurable module's settings live in its own database table (checkout_settings, customer_settings, bol_settings), lazily created on first save. So if you've never opened the panel, the row literally doesn't exist yet — nothing to "reset to defaults"; deleting the row would mean re-doing the form. (Modules with no per-website knobs — e.g. Discount, where each promo code is the unit of config — have no sub-tab here.)

Backups

A per-website backup/restore surface (admin+). Take a snapshot of the active website's tenant-scoped data (pages, media metadata, catalog, orders, etc.) and restore from a prior one. Tenant backups are scoped to website data only — global records such as admin users and their passkeys (admin_credential) are excluded, so a restore never touches anyone's login. Restores run through Messenger; watch the status on the panel.

A backup is one .tar.gz per website, taken nightly and on request. It holds:

  • the website's rows, read as one point in time, so an order is never in a backup without its lines;
  • the website itself and its hostnames (settings, theme, modules, company details), which a restore onto the existing site leaves as they are now, and which recreating a deleted site uses;
  • the media files, form attachments, review photos and proof-of-transport files.

A restore rewinds the content to the backup, with a few exceptions: issued invoices and credit notes stay, review-request and abandoned-cart opt-outs stay, and an address unsubscribed from the newsletter now stays unsubscribed. A backup only restores onto the website it was taken of.

A restore first takes a backup of the site as it is now (kind pre-restore), and goes on only when that worked, so there is always a way back. The panel shows how the last restore went, and why when it failed; a failed restore leaves the site as it was. A backup taken before an update that removed a column still restores, without that column.

Into another environment. To look at a production site on a test cluster, copy its bundle (<backup root>/<code>/<id>.tar.gz) over and import it on the API pod:

php bin/console app:backups:import /path/to/<id>.tar.gz --host=<code>.internal

When the website is not there yet, the import recreates it with the bundle's ids, so the target database must not hold another site's rows with those ids (a clean or older database is fine; the error names the table when it does not fit). --host replaces the production hostnames, repeat it for more, the first is canonical. Admin access is dropped (admin user ids differ per environment; --keep-admin-access keeps it), and credentials sealed with another environment's key, such as payment keys, are cleared. The import says when the site's theme version is not deployed there. When the website is there already, nothing changes: the bundle is added to its backups as imported, to restore from the panel.

A backup is for restoring into CeeMS. For a client moving to another provider there is the website export (super admins): a zip with a static copy of the site, the theme source and the data as CSV, kept for seven days. See website-export.md.

Storage

On the same tab: what this website occupies, and its quota if it has one.

Most websites have no quota, and that is the default — for existing sites and new ones alike. A quota is something a super admin deliberately sets on a specific site; nothing is imposed by default. Without one the card shows usage only, and nothing is ever refused.

What counts: media, video renditions, backups, review photos, form attachments, and every deployed theme version — not just the active one, since they all sit on disk.

What doesn't: the image cache (it regenerates itself) and uploads still in progress (gone within minutes once the upload finishes; an abandoned one is removed by the nightly cleanup after a week). Both are still shown, labelled not counted, because "what does this site cost on disk" is a fair question with a different answer.

When it's full:

Action What happens
Media upload Refused, with a message saying how much room is left. Large uploads are refused before you send the file, not after.
Backup Refused, and the backup is marked failed with the reason. It fails loudly on purpose — a backup that silently skipped itself is only discovered when you try to restore.
Theme deploy Allowed, with a warning in the log. A full site still has to be fixable.

The card turns amber at 80% and red at 95%, and the dashboard shows a warning from 80% for the active website.

Freshness. Media, renditions and backups are counted live, so they're correct the moment an upload lands. Themes, review photos, form attachments and the cache are measured by a nightly job — the card says when that last ran, so a theme deployed this afternoon may not be in the figure yet.

Changing the quota is super-admin only. Everyone with admin access can see it; the field is in GB, and clearing it means no limit.

Modules

List of every module compiled into cms-core with a per-website toggle (super-only) or read-only chips (everyone else). A module stays "off" when its dependencies are off — the toggle lights up but the effective state row stays grey. See modules/index.md → dependencies.

Deleting a website

Super admins only, from Delete on the website's page. Deleting is in two steps:

  1. Right away the site goes offline: its hosts answer 404 and leave the ingress, nothing is sent from it any more (newsletters, gift cards, abandoned-cart and review mails, bol.com sync) and its own admins no longer see it. A final backup is taken.
  2. 30 days later it is removed for good: its rows (pages, customers, orders, issued invoices too) and every file (media, form attachments, review photos, proof of transport, backups). Keeping invoices for the tax authorities is the client's job; export them before deleting.

Until the purge the site is in the super admins' website list as Deleted, with Undo delete, which puts it back as it was, online or not. After the purge the final backup is kept for another 90 days under <backup root>/.purged/<id>-<code>/, so the site can still be brought back: app:backups:import on that bundle, with --keep-admin-access since it is the same environment (see Backups above).

Hiding a site from search engines

Websites → General → Search engines → "Hide this site from search engines". For a site that is still being built.

This switch stops crawlers and nothing else: anyone with the address still reads the site. To stop people as well, see under-construction.md — a site being built usually wants both, and a new website gets both.

A new website gets it switched on. That is on purpose: a half-built site that gets crawled leaves placeholder pages and lorem ipsum in Google long after launch, and getting them back out is slow and not entirely in your control. So the launch checklist has one more item than you might expect: turn this off when the site goes live, or the new site quietly stays invisible.

The other direction: switch it on before the site is reachable, not after someone notices. Removing pages already indexed is the search engine's decision and takes weeks.

While it is on, three separate things say "stay away", and all three are needed:

Layer Covers
<meta name="robots"> HTML rendered through the shared SEO partial
X-Robots-Tag header Everything else: PDFs and media, JSON, feeds, a theme that renders its own <head>, and pages served from a cache warmed before you flipped the switch
robots.txt: Disallow: / Asks crawlers not to fetch at all, and drops the Sitemap: line

The meta tag alone would miss every non-HTML response. robots.txt alone is worse than it looks: it stops crawling but cannot remove what is already indexed, and a page a crawler never fetches is a page whose noindex it never reads. The header is the part that actually says "drop this".

It overrides per-page SEO. A page with index,follow pinned in its SEO overrides is still noindex while the site switch is on, which is the whole point.

robots.txt is cached for 5 minutes while the switch is on (24 hours otherwise), so going live is not held up for a day by a CDN copy still saying Disallow.

Dashboard widgets

  • Page tree — top-level pages, drag to reorder. Edit / preview from the row.
  • Theme — active version, last deploy timestamp, async compile status. Refresh icon polls latest. Triggers a new deploy via the "Deploy version" picker.
  • Pending orders (checkout module) — count + click-through to the Orders page filtered to "pending payment".

Inline page editor

Open a page → "Edit". The SPA loads the page rendered by cms-api inside an iframe; the editor JS attaches contentEditable on text / html fields, a cog button on image / gallery / slideshow / embed fields, a side panel listing every field on the page (filled vs. empty), and a draft badge that flips dirty as soon as anything changes.

The right-hand "Settings" panel covers things that aren't visible in the rendered HTML: slug, locale, template, SEO (title, meta description, Open Graph image, JSON-LD override, canonical override, robots).

See the designer-side reference for what those fields mean: ../field-types.md.

Docs websites from Markdown

A docs website (docs.ceems.nl, kdef.dev, the WarrenFS docs) is written in Markdown in a Git repository, and a cronjob publishes it every 15 minutes with app:docs:sync. Each Markdown file is a page on the docs template (README.md on the site's home template), its body in the body field, and the docs_sidebar menu is rebuilt from README.md. Pages on another template are left alone.

Title and meta description. A new page takes its first # heading as title and its first paragraph as meta description. After that they are the site's: change them in the page's Settings panel and the sync keeps them; only the body follows the Markdown. To keep them in the repository instead, set them in front matter at the very top of the file:

---
title: Erasure coding in WarrenFS
description: How WarrenFS splits objects into data and parity shards, and what that costs.
---
# Erasure coding

What the front matter sets is written on every run, so an edit in the admin to that value is put back; leave a key out to keep editing it in the admin. The title is the page's title everywhere (heading, browser tab, sidebar). Other keys are not used and show as a warning in the sync's output.

Things you cannot do from the admin

  • Edit theme templates. That's a designer job — push a new git tag to the theme repo, then deploy from the Theme tab.
  • Deploy a theme version to a different website's repo. Each website has one theme repo (themeRepository field). To share themes across sites, designers maintain one repo and each website references it — the deployed version directories are per-website-code.
  • Bulk-edit pages / fields. v1 has no bulk operations on the content side; one page at a time.
  • Run SQL. All changes go through the API. There's no "advanced query" surface.
  • Configure the SMTP relay. That's cms-public's MAILER_DSN environment variable, set per-deployment by ops. The admin only sets per-website From: addresses.
  • Free up space by clearing the image cache. It isn't counted toward the quota anyway, and it rebuilds itself on the next page view. Delete unused media, or old backups, instead.
  • Set your own storage quota. Admins see it; only a super admin changes it.
  • Schedule publishing. A page is either published or not. Future feature; for now publish manually when ready.