Compare commits

..
108 Commits
Author SHA1 Message Date
Florian Sylvain c050db78d2 docs: release notes for v3.7.0-beta 2026-10-30 20:20:51 +01:00
Florian Sylvain 10a66cd979 feat: change email, export my data, delete account (with data anonymization) 2026-10-30 20:19:07 +01:00
Florian Sylvain bb191c389a chore: gdpr features design groundwork 2026-10-29 23:24:15 +01:00
Florian Sylvain c6d49db95e chore: updated reference design to app changes and fixes 2026-10-29 18:07:06 +01:00
Florian Sylvain 8cdd70bd81 fix: french buttons labels overflowing 2026-10-29 17:16:19 +01:00
Florian Sylvain b40a792d02 fix: hide date separators when date picker field idle, to prevent overlapping labels 2026-10-29 17:14:02 +01:00
Florian Sylvain 7a66318a73 fix: sticky footer on role editor 2026-10-29 17:11:40 +01:00
Florian Sylvain 89da2f6b14 fix: "used in" media badge color when selected readabiltiy issue 2026-10-29 17:09:17 +01:00
Florian Sylvain fda2793888 fix: add details to media panel when image is used in author or site 2026-10-29 17:05:25 +01:00
Florian Sylvain 63c0aec444 fix: visual issues (footer floating, table elements overflowing each others) 2026-10-29 16:57:39 +01:00
Florian Sylvain b39a41be88 refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types 2026-10-29 16:28:25 +01:00
Florian Sylvain 5d6f378fce refac: stale test entries, failing web tests, Postgre backup bug, Hardcoded MySQL table count, Stale Postgre connection in test, own-scope visibility filter, GetAll ignoring its error, JSON tags in use-case types, 31 copies of DESKTOP_QUERY, ProfileView endpoint paths 2026-10-28 17:54:10 +01:00
Florian Sylvain efecdee200 docs: release notes for v3.6.0-beta 2026-10-28 00:13:30 +01:00
Florian Sylvain 34dffe222f feat: roles SPA Users, Profile, API keys, Activity 2026-10-28 00:10:15 +01:00
Florian Sylvain 3db490a615 feat: SPA Roles screens 2026-10-27 23:05:04 +01:00
Florian Sylvain 907949aa12 feat: roles SPA foundations 2026-10-27 22:21:12 +01:00
Florian Sylvain 80f7037d3a feat: Users API, ownership transfer and removal of the legacy role string 2026-10-27 03:08:15 +01:00
Florian Sylvain 094df63a7f feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision. 2026-10-27 00:45:24 +01:00
Florian Sylvain a043f7f41a refac: debt on role backend 2026-10-26 23:34:35 +01:00
Florian Sylvain de142f6c5f feat: more role backend and admin/writer roles additions and adjustments 2026-10-26 18:15:26 +01:00
Florian Sylvain 8c6c6456da feat: roles system backend 2026-10-26 05:02:49 +01:00
Florian Sylvain 44cca46d5e chore: roles management groundwork 2026-10-26 02:53:33 +01:00
Florian Sylvain 7c0ee21906 feat: updated integration page with missing API routes 2026-10-26 02:18:35 +01:00
Florian Sylvain 25d9c30b8a feat: integration search bar 2026-10-25 21:57:43 +01:00
Florian Sylvain a8fea8d900 fix: changed "Jump to" so it scrolls only the page's own scroll container 2026-10-25 20:26:52 +01:00
Florian Sylvain a0fdf03be0 docs: release notes for v3.5.3-beta 2026-10-23 01:57:29 +02:00
Florian Sylvain 9ab98fd50c fix: response schema is now inferred from each endpoint's sample JSON 2026-10-23 01:56:35 +02:00
Florian Sylvain d2af39da61 docs: release notes for v3.5.2-beta 2026-10-23 01:31:51 +02:00
Florian Sylvain 4cff43fefb feat: llms.txt and llms-full.txt, openapi.json, MCP server 2026-10-23 01:29:59 +02:00
Florian Sylvain daba3c04c0 fix: relation picker page size refused by entry list 2026-10-23 00:48:10 +02:00
Florian Sylvain f2cdbcd3cb docs: release notes for v3.5.0-beta 2026-10-23 00:38:22 +02:00
Florian Sylvain 37a77c9c22 feat: relation field and picker 2026-10-23 00:13:42 +02:00
Florian Sylvain b45c99be3b fix: reduced SPA build chunk sizes 2026-10-22 10:15:32 +02:00
Florian Sylvain fc51ab8c10 fix: editor's right side panel vertical overflow 2026-10-22 03:04:25 +02:00
Florian Sylvain 07f5c5d1ee docs: release notes for v3.4.2-beta 2026-10-22 00:50:33 +02:00
Florian Sylvain ea98c5ec05 fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report) 2026-10-22 00:49:19 +02:00
Florian Sylvain e2812ee0b9 docs: release notes for v3.4.1-beta 2026-10-21 23:31:01 +02:00
Florian Sylvain aa5d3582f8 fix: updated go dependencies with latest security fixes 2026-10-21 23:28:41 +02:00
Florian Sylvain c68e9a7858 refac: removed 7 direct and 12 indirect go modules for custom code instead 2026-10-21 23:13:46 +02:00
Florian Sylvain cfcf108488 fix: web dependencies security updates 2026-10-21 22:55:37 +02:00
Florian Sylvain b43cc6861f docs: removed coding standards from version control 2026-10-21 22:48:31 +02:00
Florian Sylvain 074944c99a refac: atomic merge, lost-update race, RunPurge cost, duplicated walks 2026-10-21 22:47:46 +02:00
Florian Sylvain 0fc3511e2b feat(SPA): media library indications for custom medial field type usage 2026-10-21 16:17:09 +02:00
Florian Sylvain 3e7fc9a4a5 feat(SPA): media field type 2026-10-21 15:17:15 +02:00
Florian Sylvain a2d596f4ca feat(API): media field type 2026-10-21 05:07:36 +02:00
Florian Sylvain ad5faf3d19 docs: release notes for v3.4.0-beta 2026-10-21 01:46:15 +02:00
Florian Sylvain 89051b3f1f refac: duplicates, crap, dry etc. on last features commits 2026-10-21 00:52:14 +02:00
Florian Sylvain fba20f83c5 feat: entry editor at /content/:key/new and /content/:key/:id 2026-10-21 00:12:34 +02:00
Florian Sylvain 91d916fb74 feat: entry list at /content/:key 2026-10-20 21:05:03 +02:00
Florian Sylvain b4cbcb0395 refac: split useSchemaBuilder & SchemaDeleteControls 2026-10-20 18:12:29 +02:00
Florian Sylvain 5404291af2 feat: delete a custom type 2026-10-20 17:56:44 +02:00
Florian Sylvain 76e9e1220c fix: accessibility issues for builder's design 2026-10-20 05:11:14 +02:00
Florian Sylvain 8e344b2500 feat: edit an existing custom type 2026-10-20 04:42:50 +02:00
Florian Sylvain 083cc9e23e feat: schema builder's create flow 2026-10-20 00:29:01 +02:00
Florian Sylvain f514146fed feat: built-in type read-only view,s chema list, states, spec files 2026-10-19 22:31:13 +02:00
Florian Sylvain 3c67cd2314 feat(SPA): schema list and the sidebar Content group 2026-10-19 19:34:45 +02:00
Florian Sylvain 73a75d5cb9 feat: custom content type icons, built-in types, user lookup 2026-10-19 18:22:55 +02:00
Florian Sylvain e6e088c963 chore: custom content types design groundwork 2026-10-19 17:38:41 +02:00
Florian Sylvain 58d58f5a04 feat: custom content types backend 2026-10-19 17:01:37 +02:00
Florian Sylvain ed67b7e70d docs: release notes for v3.3.0-beta 2026-10-19 04:10:29 +02:00
Florian Sylvain 23e88190d9 fix: route every toggle switch in the SPA through one new component, for one correct geometry 2026-10-19 04:06:34 +02:00
Florian Sylvain 340a0ad461 feat: pagination in the SPA for the Sites and Authors lists 2026-10-19 04:00:47 +02:00
Florian Sylvain 500ad59458 fix: authors profile pictute all accross the SPA 2026-10-19 00:46:16 +02:00
Florian Sylvain 18600ab49d chore: fix pagination for authors and site 2026-10-19 00:30:19 +02:00
Florian Sylvain daf3594316 fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination 2026-10-19 00:25:31 +02:00
Florian Sylvain 9f4ce426a8 chore: groundwork for SEO fixes and tech&vis debt 2026-10-18 17:30:12 +02:00
Florian Sylvain 02fb8751fd feat: SEO SPA Sites, Articles and Integration (4/4) 2026-10-18 15:51:28 +02:00
Florian Sylvain cb737b7ae3 fix: SEO SPA editor last fixes (3/4) 2026-10-16 16:31:17 +02:00
Florian Sylvain 470584400f feat: SEO SPA editor integration 2026-10-16 03:06:57 +02:00
Florian Sylvain eb9876fde3 feat: SEO backend output (2/4) 2026-10-15 21:48:55 +02:00
Florian Sylvain 0eb46b0d74 feat: SEO backend data (1/4) 2026-10-15 19:30:29 +02:00
Florian Sylvain cb1410f0b0 chore: grounwork for SEO features 2026-10-15 15:48:57 +02:00
Florian Sylvain 6b8990c55e feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally) 2026-10-15 05:25:44 +02:00
Florian Sylvain 3d81d2c717 feat(SPA): screens for Sites and Authors 2026-10-14 23:00:50 +02:00
Florian Sylvain b34a173d83 feat: Authors feature backend (and upgraded deprecated mods) 2026-10-14 17:45:00 +02:00
Florian Sylvain a11338094e feat: Sites feature backend 2026-10-14 03:56:50 +02:00
Florian Sylvain 45fcbd890d chore: groundwork for sites & authors 2026-10-14 00:33:52 +02:00
Florian Sylvain 0d03a27f31 docs: release notes for v3.2.1-beta 2026-10-13 22:39:42 +02:00
Florian Sylvain 63c1e7076e feat(media): pagination 2026-10-13 19:54:59 +02:00
Florian Sylvain 04dc151ed7 feat(variant): image preview on variant creation with a real sample image 2026-10-13 17:29:56 +02:00
Florian Sylvain a1cdfce529 fix(integration): missing french translations 2026-10-13 16:54:06 +02:00
Florian Sylvain c41e6dbca5 fix(SPA): activity list saves pagination too 2026-10-13 16:36:42 +02:00
Florian Sylvain 9d9453c1b3 fix(SPA): save article list pagination in query url 2026-10-13 16:20:37 +02:00
Florian Sylvain 54f27ac98f docs: release notes for v3.2.0-beta and version references cleanup 2026-10-13 03:26:04 +02:00
Florian Sylvain 0632bf53cb feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading 2026-10-13 03:19:25 +02:00
Florian Sylvain fe254112a2 fix(register): onSubmit now calls await session.retry() before going to /register/pending 2026-10-13 02:49:43 +02:00
Florian Sylvain 160c0a49ce feat(media): align visual checks and variant UI with merged design 2026-10-13 02:35:20 +02:00
Florian Sylvain e6746f2be7 feat(integration): add image variants section and standardize code snippets 2026-10-12 23:27:41 +02:00
Florian Sylvain 6f9ae6207a feat(editor): link insertion full UI and flow 2026-10-12 17:12:36 +02:00
Florian Sylvain 03b39e64f2 feat(activity): update summary cards to toggles and introduce export menu 2026-10-12 05:13:25 +02:00
Florian Sylvain 73f4b539fe feat: articles pagination 2026-10-11 19:14:37 +02:00
Florian Sylvain a2d224240c fix: small design harmonization and overhaul 2026-10-11 17:17:22 +02:00
Florian Sylvain d6566743a7 fix: prevent air from deleting the whole tmp folder, used for visual diff task 2026-10-11 08:49:31 +02:00
Florian Sylvain 10ec74c8cf feat: remove the 10,000-character article body limit and make the New article body optional 2026-10-11 08:49:11 +02:00
Florian Sylvain 5e05b606a7 chore: 2 big design passes to harmonize and fix stuff 2026-10-11 07:45:17 +02:00
Florian Sylvain 67b3ff03d0 fix: linux dev workflow 2026-10-11 06:31:49 +02:00
fsylvain ce9c876db6 refac: removed ugly multi-line method chaining 2026-10-10 20:49:05 +02:00
fsylvain e8dff6bb5f chore: improved mutate script so we don't end up with hunders of GBs of gremlins cache after a few slices 2026-10-10 00:23:39 +02:00
fsylvain c484d5bd12 release: v3.1.0-beta Image variants 2026-10-08 21:15:43 +02:00
fsylvain d768668014 feat: image variants cache panel 2026-10-08 21:13:32 +02:00
fsylvain 951fef18ba feat: image variants per image 2026-10-08 19:57:51 +02:00
fsylvain f567866161 feat: image variants settings screens 2026-10-08 13:57:41 +02:00
fsylvain f1d56ef50e feat: image variants engine 2026-10-08 02:33:32 +02:00
fsylvain f0845de643 chore: media compression feature groundwork 2026-10-08 00:26:31 +02:00
fsylvain f914eae3c5 feat: setup logs retention by max file size 2026-10-07 22:02:13 +02:00
fsylvain 70c8492f4f fix: design harmonization on last features 2026-10-07 18:20:18 +02:00
fsylvain 953be7342d fix: jsdom doesn't implement Range.prototype.getClientRects 2026-10-07 16:51:57 +02:00
fsylvain 28d2d5c826 fix: missing integration page routes 2026-10-07 16:50:44 +02:00
1727 changed files with 232803 additions and 10636 deletions

No files matched your search

+4 -4
View File
@@ -1,11 +1,11 @@
root = "."
tmp_dir = "tmp"
tmp_dir = "tmp/air"
[build]
cmd = "go build -tags nofrontend -o ./tmp/renewcms.exe ./cmd/server"
bin = "tmp/renewcms.exe"
cmd = "go build -tags nofrontend -o ./tmp/air/renewcms ./cmd/server"
bin = "tmp/air/renewcms"
delay = 500
exclude_dir = ["tmp", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
exclude_dir = ["tmp", "tmp/air", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
exclude_regex = ["_test.go"]
include_ext = ["go", "html"]
log = "build-errors.log"
+4
View File
@@ -14,8 +14,12 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
SMTP_ALLOW_INSECURE=false
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
+5
View File
@@ -28,3 +28,8 @@ CLAUDE.md
.agents/
AGENTS.md
codex-workflow.md
CODING_STANDARDS.md
# Generated LLM docs
internal/infrastructure/llmdocs/generated/*
!internal/infrastructure/llmdocs/generated/.gitkeep
-100
View File
@@ -1,100 +0,0 @@
# Coding Standards
These rules apply to every file in this repo , for humans and agents alike.
## General rules
1. Follow standard conventions.
2. Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
3. Boy scout rule. Leave the campground cleaner than you found it.
4. Always find root cause. Always look for the root cause of a problem.
## Design rules
1. Keep configurable data at high levels.
2. Prefer polymorphism to if/else or switch/case.
3. Separate multi-threading code.
4. Prevent over-configurability.
5. Use dependency injection.
6. Follow Law of Demeter. A class should know only its direct dependencies.
## Understandability tips
1. Be consistent. If you do something a certain way, do all similar things in the same way.
2. Use explanatory variables.
3. Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
4. Prefer dedicated value objects to primitive types.
5. Avoid logical dependency. Don't write methods which work correctly only depending on something else in the same class.
6. Avoid negative conditionals.
## Names rules
1. Choose descriptive and unambiguous names.
2. Make meaningful distinctions.
3. Use pronounceable names.
4. Use searchable names.
5. Replace magic numbers with named constants.
6. Avoid encodings. Don't append prefixes or type information.
## Functions rules
1. Small.
2. Do one thing.
3. Use descriptive names.
4. Prefer fewer arguments.
5. Have no side effects.
6. Don't use flag arguments. Split the method into several independent methods that can be called from the client without the flag.
## Comments rules
1. No comments in the codebase. This covers Go, TypeScript/Vue, HTML templates and config files (YAML, TOML, `.env.example`, `.gitattributes`, …).
2. Explain yourself in code instead: descriptive names, small functions, named constants, explanatory variables.
3. The only exceptions are tool directives the code needs to work, such as `//go:build` and `//go:embed`.
4. Don't comment out code; just remove it.
5. Prose belongs in `docs/` and `README.md`, not in source files.
## Source code structure
1. Separate concepts vertically.
2. Related code should appear vertically dense.
3. Declare variables close to their usage.
4. Dependent functions should be close.
5. Similar functions should be close.
6. Place functions in the downward direction (caller above callee).
7. Keep lines short.
8. Don't use horizontal alignment.
9. Use white space to associate related things and disassociate weakly related ones.
10. Don't break indentation.
## Objects and data structures
1. Hide internal structure.
2. Prefer data structures when there's no behavior to attach.
3. Avoid hybrid structures (half object, half data).
4. Keep classes small.
5. Each class should do one thing.
6. Keep the number of instance variables small.
7. Base classes should know nothing about their derivatives.
8. Prefer many small functions over one function with a parameter that selects behavior.
9. Prefer non-static methods to static methods.
## Tests
1. One assert per test.
2. Readable.
3. Fast.
4. Independent.
5. Repeatable.
## Code smells to watch for
1. **Rigidity** — the software is difficult to change; a small change causes a cascade of subsequent changes.
2. **Fragility** — the software breaks in many places due to a single change.
3. **Immobility** — you cannot reuse parts of the code in other projects because of the risk and effort involved.
4. **Needless complexity**.
5. **Needless repetition**.
6. **Opacity** — the code is hard to understand.
## Credits
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
+6 -1
View File
@@ -4,8 +4,12 @@ COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build-only
WORKDIR /src
COPY docs/ ./docs/
COPY scripts/generate-llms.ts ./scripts/generate-llms.ts
RUN bun scripts/generate-llms.ts
FROM golang:1.27.1-alpine AS server
FROM golang:1.27.3-alpine AS server
ARG VERSION=dev
ENV CGO_ENABLED=0
WORKDIR /src
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
COPY --from=web /src/internal/infrastructure/llmdocs/generated ./internal/infrastructure/llmdocs/generated
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
FROM alpine:3.22
+8 -5
View File
@@ -23,7 +23,7 @@ docker compose pull
docker compose up -d # http://localhost:8080
```
Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**.
Upgrade with `docker compose pull && docker compose up -d`. The image is `git.floriansylvain.fr/fsylvain/renewcms`. Every release is a pre-release for now, so the default tag is the moving `beta`. Pin a version with `RENEWCMS_IMAGE`. Tags, building from source and publishing are in **[docs/docker.md](docs/docker.md)**.
### Updates from the browser
@@ -47,11 +47,14 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/privacy.md](docs/privacy.md) | Personal data stored, retention, cookies, outbound connections and GDPR requests |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
## API Usage
TODO
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
## TODOs
+19 -3
View File
@@ -3,6 +3,7 @@ version: '3'
vars:
BINARY: renewcms
DIST_DIR: internal/infrastructure/assets/dist
LLMS_DIR: internal/infrastructure/llmdocs/generated
VERSION:
sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}}
@@ -32,6 +33,7 @@ tasks:
dev:api:
desc: Run the API with hot reload on :8080 (SPA not embedded)
deps: [build:llms]
cmds:
- air
@@ -178,7 +180,7 @@ tasks:
mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds:
- bash scripts/mutate.sh
- bash scripts/mutate.sh {{.CLI_ARGS}}
test:web:
desc: Run the SPA test suite (Vitest) in run mode
@@ -231,22 +233,36 @@ tasks:
desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web
deps: [web:install]
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
sources: [src/**/*, public/**/*, vite/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
generates: ['../{{.DIST_DIR}}/index.html']
cmds:
- bun run build
build:llms:
desc: Generate llms.txt, llms-full.txt and openapi.json from the route catalogue and docs/ → {{.LLMS_DIR}}
deps: [web:install]
sources: [scripts/generate-llms.ts, web/src/data/apiCatalogue.ts, docs/api.md, docs/api/*.md, docs/llm/*.md, docs/configuration.md]
generates: ['{{.LLMS_DIR}}/llms-full.txt']
cmds:
- bun scripts/generate-llms.ts
build:
desc: Production binary for this machine → bin/{{.BINARY}}
deps: [build:web]
deps: [build:web, build:llms]
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
build:mcp:
desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp
release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds:
- task: release:check
- task: build:web
- task: build:llms
- for: { var: RELEASE_TARGETS }
task: build:target
vars:
+206 -26
View File
@@ -1,18 +1,27 @@
package api
import (
"RenewCMS/api/pkg/authtoken"
domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit"
domainAuthor "RenewCMS/internal/domain/author"
domainContentType "RenewCMS/internal/domain/contenttype"
domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role"
domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/database"
@@ -20,6 +29,7 @@ import (
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
@@ -30,21 +40,56 @@ import (
"runtime"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
Token *authtoken.Auth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
RetentionScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
ListSitemapUseCase *useCases.ListSitemapUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase
CheckPermissionUseCase *useCases.CheckPermissionUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
ListContentTypesUseCase *useCases.ListContentTypesUseCase
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListRolesUseCase *useCases.ListRolesUseCase
GetRoleUseCase *useCases.GetRoleUseCase
CreateRoleUseCase *useCases.CreateRoleUseCase
UpdateRoleUseCase *useCases.UpdateRoleUseCase
DeleteRoleUseCase *useCases.DeleteRoleUseCase
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
CreateEntryUseCase *useCases.CreateEntryUseCase
GetEntryUseCase *useCases.GetEntryUseCase
ListEntriesUseCase *useCases.ListEntriesUseCase
UpdateEntryUseCase *useCases.UpdateEntryUseCase
DeleteEntryUseCase *useCases.DeleteEntryUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
@@ -53,11 +98,20 @@ type UseCases struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
RemoveUserUseCase *useCases.RemoveUserUseCase
ExportUserDataUseCase *useCases.ExportUserDataUseCase
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
@@ -87,11 +141,26 @@ type UseCases struct {
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
ListVariantsUseCase *useCases.ListVariantsUseCase
CreateVariantUseCase *useCases.CreateVariantUseCase
UpdateVariantUseCase *useCases.UpdateVariantUseCase
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
DeleteVariantUseCase *useCases.DeleteVariantUseCase
PurgeVariantUseCase *useCases.PurgeVariantUseCase
ServeVariantUseCase *useCases.ServeVariantUseCase
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
GetVariantCacheStatsUseCase *useCases.GetVariantCacheStatsUseCase
PurgeVariantCacheUseCase *useCases.PurgeVariantCacheUseCase
VariantUsageTracker *usage.Tracker
}
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler, c.RetentionScheduler} {
if runner == nil {
continue
}
@@ -106,10 +175,23 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
type Repositories struct {
ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository
ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository
UserEraser domainUser.Eraser
RoleRepo domainRole.Repository
ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository
VariantUsageRepo domainImage.VariantUsageRepository
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
SessionRepo domainSession.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
@@ -133,6 +215,8 @@ const (
updateHelperFileName = "update-helper.json"
updatesDirName = "updates"
backupsDirName = "backups"
maxConcurrentDataExports = 2
)
func imageStorageConfig(settings config.Settings) storage.Config {
@@ -219,7 +303,7 @@ func openDatabase(settings config.Settings) *database.Connection {
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := database.Migrate(connection.DB); err != nil {
if err := database.Migrate(connection.DB, settings.PublicURL); err != nil {
panic("Failed to migrate database: " + err.Error())
}
return connection
@@ -236,12 +320,26 @@ func releasePublicKey() ed25519.PublicKey {
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
variantFiles := persistence.NewVariantFileRepository(db)
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db),
UserEraser: persistence.NewAccountEraser(db),
RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles,
VariantUsageRepo: persistence.NewVariantUsageRepository(db),
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
SessionRepo: persistence.NewRevokedSessionRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
@@ -262,50 +360,102 @@ func getRepositories(connection *database.Connection, settings config.Settings)
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
mailDispatcher := mailer.NewAsyncDispatcher()
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, recorder)
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo, repos.UserRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: mediaUsage,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
@@ -315,13 +465,28 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
ServeVariantUseCase: serveVariant,
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()),
GetVariantCacheStatsUseCase: useCases.NewGetVariantCacheStatsUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.VariantCacheRepo, repos.ImageRepo, repos.VariantUsageRepo, usageTracker, time.Now),
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(repos.VariantRepo, repos.ImageRepo, repos.VariantCacheRepo, repos.VariantFileRepo, imageStorage, time.Now, recorder),
VariantUsageTracker: usageTracker,
}
}
@@ -363,7 +528,22 @@ func InitContainer(settings config.Settings) (*UseCases, func() error) {
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return getUseCases(repos, imageStorage, settings), connection.Close
container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close)
}
func (c *UseCases) runUsageTracker(closeDatabase func() error) func() error {
ctx, stop := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() {
defer close(stopped)
c.VariantUsageTracker.Run(ctx)
}()
return func() error {
stop()
<-stopped
return closeDatabase()
}
}
type BrowserUpdateCommands struct {
+14 -8
View File
@@ -1,11 +1,12 @@
package activity
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -29,10 +30,10 @@ type Handler struct {
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites authors"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d custom"`
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
access.WriteMissingPermission(w, role.KeyActivityView)
case access.WriteIfMissingPermission(w, err):
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
@@ -156,7 +156,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
return
}
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)})
if err != nil {
writeActivityError(w, err)
return
+7 -2
View File
@@ -1,6 +1,7 @@
package apikey
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return
}
entries, err := h.ListUseCase.ListAPIKeys(userId)
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound):
+363 -53
View File
@@ -1,20 +1,28 @@
package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
type Handler struct {
@@ -23,57 +31,131 @@ type Handler struct {
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body" validate:"required,max=10000"`
Body string `json:"body"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
GetUserUseCase: getUser,
}
}
type articleResponse struct {
type baseArticleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy *uint32 `json:"created_by,omitempty"`
Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
type articleResponse struct {
baseArticleResponse
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
}
type listItemResponse struct {
baseArticleResponse
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
SEOStatus string `json:"seo_status"`
SEOSuggestions int `json:"seo_suggestions"`
}
func creatorFor(article domain.Article, viewer access.Caller) *uint32 {
if viewer.UserID == 0 {
return nil
}
createdBy := article.CreatedBy
return &createdBy
}
func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (baseArticleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
return baseArticleResponse{}, err
}
body = encoded
}
return articleResponse{
authors := article.Authors
if authors == nil {
authors = []author.Byline{}
}
return baseArticleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
SiteID: article.SiteID,
CreatedBy: creatorFor(article, viewer),
Authors: authors,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return articleResponse{}, err
}
dismissed := article.SEODismissed
if dismissed == nil {
dismissed = []string{}
}
return articleResponse{
baseArticleResponse: base,
Slug: article.Slug,
Excerpt: article.Excerpt,
SEOTitle: article.SEOTitle,
SEODescription: article.SEODescription,
CoverImageID: article.CoverImageID,
CoverImageAlt: article.CoverImageAlt,
SocialTitle: article.SocialTitle,
SocialDescription: article.SocialDescription,
SocialImageID: article.SocialImageID,
CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex,
SEODismissed: dismissed,
}, nil
}
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return listItemResponse{}, err
}
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer)
if err != nil {
return nil, err
}
@@ -106,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
var article domain.Article
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
@@ -121,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := encodeArticle(article, format)
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -153,29 +230,86 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
Caller: access.CallerOf(r),
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError)
}
return
}
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
if !writeKnownArticleError(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format)
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if access.WriteIfMissingPermission(w, err) {
return true
}
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
}
func writeSlugTaken(w http.ResponseWriter, err error) {
var conflict *domain.SlugTakenError
if errors.As(err, &conflict) && conflict.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.ArticleSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -201,17 +335,108 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
}
type optionalImageID struct {
Set bool
Value *uint32 `validate:"omitempty,gte=1"`
}
func (o *optionalImageID) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value uint32
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
func (o optionalImageID) toNullable() domain.Nullable[uint32] {
return domain.Nullable[uint32]{Set: o.Set, Value: o.Value}
}
type UpdateArticleBody struct {
Body string `json:"body" validate:"required,max=10000"`
Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
func hasDuplicates(ids []uint32) bool {
seen := make(map[uint32]bool, len(ids))
for _, id := range ids {
if seen[id] {
return true
}
seen[id] = true
}
return false
}
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0
for name, value := range fields {
switch {
case slices.Contains(nonNullable, name):
if string(value) == "null" {
return 0, false
}
known++
case slices.Contains(nullableArticleFields, name):
known++
}
}
return known, true
}
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return UpdateArticleBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return UpdateArticleBody{}, false
}
known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 {
return UpdateArticleBody{}, false
}
var request UpdateArticleBody
if err := json.Unmarshal(raw, &request); err != nil {
return UpdateArticleBody{}, false
}
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
return UpdateArticleBody{}, false
}
return request, true
}
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
@@ -226,24 +451,41 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
request, ok := readUpdateArticleBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(localArticle); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Body: request.Body,
SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
Format: format,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -254,18 +496,73 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false)
}
func (h *Handler) callerIsVerified(r *http.Request) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Site *string `validate:"omitempty,min=1"`
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
const defaultListSort = "updated_at:desc"
func parseSort(value string) domain.Sort {
field, direction, _ := strings.Cut(value, ":")
return domain.Sort{Field: domain.SortField(field), Direction: domain.SortDirection(direction)}
}
type listResponse struct {
Items []listItemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts domain.Counts `json:"counts"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return false
return listQuery{}, err
}
return currentUser.IsVerified
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
status := values.Get("status")
if values.Has("status") && status == "" {
return listQuery{}, errors.New("empty status")
}
sortValue := defaultListSort
if values.Has("sort") {
sortValue = values.Get("sort")
}
var siteKey *string
if values.Has("site") {
key := values.Get("site")
if key == "" {
return listQuery{}, errors.New("empty site")
}
siteKey = &key
}
seoStatus := values.Get("seo")
if values.Has("seo") && seoStatus == "" {
return listQuery{}, errors.New("empty seo")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
@@ -274,21 +571,31 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
return
}
var articles []domain.Article
var err error
if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles(format)
} else {
articles, err = h.ListUseCase.ListOnlineArticles(format)
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format,
Caller: access.CallerOf(r),
Page: query.Page,
Size: query.Size,
Q: query.Q,
Status: query.Status,
Sort: parseSort(query.Sort),
SiteKey: query.Site,
SEO: query.SEO,
})
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return
}
responses := make([]articleResponse, 0, len(articles))
for _, article := range articles {
response, err := toArticleResponse(article, format)
responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -296,7 +603,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
responses = append(responses, response)
}
articlesJson, err := json.Marshal(responses)
articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -314,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil {
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
}
+127
View File
@@ -0,0 +1,127 @@
package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
)
type SEOHandler struct {
ResolveUseCase *useCases.ResolveArticleSEOUseCase
CheckUseCase *useCases.CheckArticleSEOUseCase
}
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler {
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check}
}
type CheckArticleSEOBody struct {
Body *string `json:"body"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return CheckArticleSEOBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
return CheckArticleSEOBody{}, false
}
known, ok := countKnownFields(fields, checkNonNullableFields)
if !ok || known != len(fields) {
return CheckArticleSEOBody{}, false
}
var request CheckArticleSEOBody
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return CheckArticleSEOBody{}, false
}
return request, true
}
func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r))
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, resolved)
}
func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readCheckBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{
Body: request.Body,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, checked)
}
func writeSEOJson(w http.ResponseWriter, payload any) {
encoded, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(encoded); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+5 -4
View File
@@ -2,6 +2,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
@@ -9,12 +10,10 @@ import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth
Token *authtoken.Auth
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
Email string `json:"email"`
}
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *authtoken.Auth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{
Token: token,
GetInviteUseCase: getInvite,
@@ -96,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrPasswordTooLong):
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type EmailChangeHandler struct {
RequestUseCase *useCases.RequestEmailChangeUseCase
ResendUseCase *useCases.ResendEmailChangeUseCase
CancelUseCase *useCases.CancelEmailChangeUseCase
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
}
type RequestEmailChangeCredentials struct {
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
CurrentPassword string `json:"current_password" validate:"required"`
}
type ConfirmEmailChangeCredentials struct {
Code string `json:"code" validate:"required"`
}
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
return &EmailChangeHandler{
RequestUseCase: request,
ResendUseCase: resend,
CancelUseCase: cancel,
ConfirmUseCase: confirm,
}
}
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
var credentials RequestEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
UserID: userId,
NewEmail: credentials.NewEmail,
CurrentPassword: credentials.CurrentPassword,
})
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
writeEmailChangeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var credentials ConfirmEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
if err != nil {
writeEmailChangeError(w, err)
return
}
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writePendingEmail(w http.ResponseWriter, pending string) {
body, err := json.Marshal(map[string]string{"pending_email": pending})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writeEmailChangeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, user.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrSameEmail):
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
case errors.Is(err, user.ErrNoPendingEmailChange):
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
case errors.Is(err, user.ErrInvalidEmailChangeCode):
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrEmailUnavailable):
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+26 -19
View File
@@ -2,6 +2,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
@@ -9,12 +10,10 @@ import (
"errors"
"net/http"
"time"
"github.com/go-chi/jwtauth/v5"
)
type LoginHandler struct {
Token *jwtauth.JWTAuth
Token *authtoken.Auth
CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool
@@ -31,7 +30,7 @@ type LoginCredentials struct {
Password string `json:"password" validate:"required,min=8"`
}
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
return &LoginHandler{
Token: token,
CreateUseCase: create,
@@ -41,28 +40,21 @@ func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase,
}
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
}
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: authtoken.CookieName,
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Expires: time.Now().Add(authtoken.SessionLifetime),
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
return nil
@@ -123,6 +115,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
@@ -134,14 +130,25 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
}
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()})
open, err := h.CreateUseCase.IsRegistrationOpen()
if err != nil {
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
body, _ := json.Marshal(map[string]bool{"open": open})
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction)
if claims, err := authtoken.FromContext(r.Context()); err == nil {
if err := h.Token.Revoke(claims); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
h.AuthenticateUseCase.Logout(r.Context())
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
}
+61 -2
View File
@@ -3,7 +3,10 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -13,14 +16,58 @@ import (
type MeHandler struct {
GetUseCase *useCases.GetUserUseCase
GetAuthorUseCase *useCases.GetMyAuthorUseCase
GetAccessUseCase *useCases.GetMyAccessUseCase
}
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler {
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler {
return &MeHandler{
GetUseCase: getUser,
GetAuthorUseCase: getAuthor,
GetAccessUseCase: getAccess,
}
}
type meAuthor struct {
author.Byline
ArticleCount int `json:"article_count"`
}
type meAccess struct {
Role payload.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
}
type meResponse struct {
publicuser.PublicUser
PendingEmail *string `json:"pending_email"`
Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
}
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: payload.RoleRefOf(useCases.NewRoleRef(access.Role)),
Permissions: access.Permissions,
RoleAssignedBy: payload.RoleAssignerPtrOf(access.RoleAssignedBy),
}
}
func toMeAuthor(found *author.Author) *meAuthor {
if found == nil {
return nil
}
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
}
func pendingEmailOf(account user.User) *string {
if account.PendingEmail == "" {
return nil
}
return &account.PendingEmail
}
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
@@ -38,7 +85,19 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return
}
body, err := json.Marshal(publicuser.New(loadedUser))
linked, err := h.GetAuthorUseCase.Get(loadedUser.ID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
access, err := h.GetAccessUseCase.Get(loadedUser)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -72,6 +72,10 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
+383
View File
@@ -0,0 +1,383 @@
package author
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
const (
defaultListPage = 1
defaultListSize = 25
reassignParam = "reassign_to"
reassignNone = "none"
)
type Handler struct {
ListUseCase *useCases.ListAuthorsUseCase
CreateUseCase *useCases.CreateAuthorUseCase
CreateMyUseCase *useCases.CreateMyAuthorUseCase
UpdateUseCase *useCases.UpdateAuthorUseCase
DeleteUseCase *useCases.DeleteAuthorUseCase
}
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type socialLinkRequest struct {
Type string `json:"type" validate:"socialtype"`
URL string `json:"url"`
}
func toLinks(requests []socialLinkRequest) []domain.SocialLink {
links := make([]domain.SocialLink, 0, len(requests))
for _, request := range requests {
links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL})
}
return links
}
type PostAuthor struct {
Name string `json:"name" validate:"authorname"`
Slug string `json:"slug" validate:"omitempty,authorslug"`
Bio string `json:"bio"`
AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"`
Website *string `json:"website"`
SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchAuthor struct {
Name *string `json:"name" validate:"authorname"`
Slug *string `json:"slug" validate:"authorslug"`
Bio *string `json:"bio"`
AvatarID optional[uint32] `json:"avatar_id"`
Website optional[string] `json:"website"`
SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID optional[uint32] `json:"user_id"`
}
func (p PatchAuthor) toPatch() domain.Patch {
patch := domain.Patch{
Name: p.Name,
Slug: p.Slug,
Bio: p.Bio,
AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value},
Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value},
UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value},
}
if p.SocialLinks != nil {
links := toLinks(*p.SocialLinks)
patch.SocialLinks = &links
}
return patch
}
var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"}
var nullableFields = []string{"avatar_id", "website", "user_id"}
func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return PatchAuthor{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return PatchAuthor{}, false
}
known := false
for _, name := range requiredNonNullFields {
value, present := fields[name]
if !present {
continue
}
if string(value) == "null" {
return PatchAuthor{}, false
}
known = true
}
for _, name := range nullableFields {
if _, present := fields[name]; present {
known = true
}
}
if !known {
return PatchAuthor{}, false
}
var request PatchAuthor
if err := json.Unmarshal(raw, &request); err != nil {
return PatchAuthor{}, false
}
return request, true
}
func parseAuthorId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
type ownAuthorResponse struct {
domain.Author
SlugAdjusted bool `json:"slug_adjusted"`
}
func presentAuthor(a domain.Author) domain.Author {
if a.SocialLinks == nil {
a.SocialLinks = []domain.SocialLink{}
}
return a
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeAuthorError(w http.ResponseWriter, err error) {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAuthorNotFound):
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSlugTaken):
http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorHasArticles):
http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrUserAlreadyLinked):
http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorAlreadyLinked):
http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrAuthorRights):
http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrBioTooLong):
http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidURL):
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrTooManyLinks):
http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidSocialType):
http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSlug):
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.AuthorSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
type listResponse struct {
Items []domain.Author `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]domain.Author, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, presentAuthor(item))
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostAuthor
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{
Name: request.Name,
Slug: request.Slug,
Bio: request.Bio,
AvatarID: request.AvatarID,
Website: request.Website,
SocialLinks: toLinks(request.SocialLinks),
UserID: request.UserID,
})
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, presentAuthor(created))
}
func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateMyUseCase.Create(r.Context(), userId)
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readPatchAuthor(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
callerID, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch())
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusOK, presentAuthor(updated))
}
func readReassignment(r *http.Request) useCases.Reassignment {
query := r.URL.Query()
if !query.Has(reassignParam) {
return useCases.Reassignment{Mode: useCases.ReassignAbsent}
}
value := query.Get(reassignParam)
if value == reassignNone {
return useCases.Reassignment{Mode: useCases.ReassignNone}
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return useCases.Reassignment{Mode: useCases.ReassignMalformed}
}
return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)}
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil {
writeAuthorError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,38 @@
package builtintype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domain "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListBuiltinTypesUseCase
GetUseCase *useCases.GetBuiltinTypeUseCase
}
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if errors.Is(err, domain.ErrNotFound) {
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response.WriteJSON(w, http.StatusOK, found)
}
@@ -0,0 +1,184 @@
package contenttype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateContentTypeUseCase
ListUseCase *useCases.ListContentTypesUseCase
GetUseCase *useCases.GetContentTypeUseCase
UpdateUseCase *useCases.UpdateContentTypeUseCase
DeleteUseCase *useCases.DeleteContentTypeUseCase
}
func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListContentTypesUseCase, get *useCases.GetContentTypeUseCase, update *useCases.UpdateContentTypeUseCase, deleteUseCase *useCases.DeleteContentTypeUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type iconInput struct {
value *string
invalid bool
}
func (i *iconInput) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
i.invalid = true
return nil
}
i.value = &text
return nil
}
type PostContentType struct {
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type PutContentType struct {
Key string `json:"key"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type contentTypeResponse struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Icon *string `json:"icon"`
Fields []domain.Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResponse(contentType domain.ContentType) contentTypeResponse {
fields := contentType.Fields
if fields == nil {
fields = []domain.Field{}
}
return contentTypeResponse{
ID: contentType.ID,
Key: contentType.Key,
Name: contentType.Name,
Icon: contentType.Icon,
Fields: fields,
CreatedAt: contentType.CreatedAt,
UpdatedAt: contentType.UpdatedAt,
}
}
func writeContentTypeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrKeyTaken):
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrReferenced):
http.Error(w, apperr.ContentTypeReferencedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidIcon):
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidKey):
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidField):
http.Error(w, apperr.InvalidFieldErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
found, err := h.ListUseCase.List()
if err != nil {
writeContentTypeError(w, err)
return
}
responses := make([]contentTypeResponse, 0, len(found))
for _, contentType := range found {
responses = append(responses, toResponse(contentType))
}
response.WriteJSON(w, http.StatusOK, responses)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request PutContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key")); err != nil {
writeContentTypeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+291
View File
@@ -0,0 +1,291 @@
package entry
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/contenttype"
domain "RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateEntryUseCase
GetUseCase *useCases.GetEntryUseCase
ListUseCase *useCases.ListEntriesUseCase
UpdateUseCase *useCases.UpdateEntryUseCase
DeleteUseCase *useCases.DeleteEntryUseCase
}
func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler {
return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type EntryBody struct {
Data map[string]any `json:"data" validate:"required"`
}
type entryResponse struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedBy uint32 `json:"created_by"`
UpdatedBy uint32 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"`
Relations map[string]any `json:"relations"`
}
type firstFieldBody struct {
Key string `json:"key"`
Type contenttype.FieldType `json:"type"`
Value any `json:"value"`
}
type relationBody struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
FirstField firstFieldBody `json:"first_field"`
}
type missingRelationBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toRelationItem(item useCases.EntryRelation) any {
if item.Missing {
return missingRelationBody{ID: item.ID, Missing: true}
}
return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}}
}
func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any {
body := make(map[string]any, len(relations))
for key, held := range relations {
if !held.Many {
if len(held.Items) > 0 {
body[key] = toRelationItem(held.Items[0])
}
continue
}
items := make([]any, 0, len(held.Items))
for _, item := range held.Items {
items = append(items, toRelationItem(item))
}
body[key] = items
}
return body
}
type mediaBody struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Width *int `json:"width"`
Height *int `json:"height"`
Size int64 `json:"size"`
ThumbnailURL string `json:"thumbnail_url"`
}
type missingMediaBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toMediaResponse(media map[string]useCases.EntryMedia) map[string]any {
body := make(map[string]any, len(media))
for key, item := range media {
if item.Missing {
body[key] = missingMediaBody{ID: item.ID, Missing: true}
continue
}
body[key] = mediaBody{ID: item.ID, Filename: item.Filename, Width: item.Width, Height: item.Height, Size: item.Size, ThumbnailURL: item.ThumbnailURL}
}
return body
}
func toResponse(typed useCases.TypedEntry) entryResponse {
return entryResponse{
ID: typed.ID,
ContentType: typed.ContentTypeKey,
Data: typed.Data,
CreatedBy: typed.CreatedBy,
UpdatedBy: typed.UpdatedBy,
CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
}
}
type pageResponse struct {
Items []entryResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Sort domain.Sort
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
order := domain.DefaultSort()
if values.Has("sort") {
order, err = domain.ParseSort(values.Get("sort"))
if err != nil {
return listQuery{}, err
}
}
return listQuery{Page: page, Size: size, Sort: order}, nil
}
func parseEntryId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeEntryError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var fieldError domain.FieldError
switch {
case errors.Is(err, contenttype.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrEntryNotFound):
http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound)
case errors.As(err, &fieldError):
http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err))
case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidEntry):
http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func fieldErrorStatus(err error) int {
if errors.Is(err, domain.ErrUniqueViolation) {
return http.StatusConflict
}
return http.StatusUnprocessableEntity
}
func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
var body EntryBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return EntryBody{}, false
}
return body, true
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q"), Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
items := make([]entryResponse, 0, len(page.Items))
for _, typed := range page.Items {
items = append(items, toResponse(typed))
}
response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id, access.CallerOf(r))
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
body, ok := readBody(w, r)
if !ok {
return
}
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
body, ok := readBody(w, r)
if !ok {
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id, access.CallerOf(r)); err != nil {
writeEntryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+141
View File
@@ -0,0 +1,141 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"math"
"net/http"
validatorlib "github.com/go-playground/validator/v10"
)
type cacheUnusedResponse struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Days int `json:"days"`
}
type cacheVariantResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheImageResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheResponse struct {
GeneratedFiles int `json:"generated_files"`
DiskUsed int64 `json:"disk_used"`
VariantsDefined int `json:"variants_defined"`
VariantsEnabled int `json:"variants_enabled"`
VariantsDisabled int `json:"variants_disabled"`
RejectedRequests int `json:"rejected_requests"`
RejectedWindowDays int `json:"rejected_window_days"`
Unused cacheUnusedResponse `json:"unused"`
ByVariant []cacheVariantResponse `json:"by_variant"`
TopImages []cacheImageResponse `json:"top_images"`
}
type cachePurgeRequest struct {
Scope string `json:"scope" validate:"required,oneof=all unused variant image"`
Variant string `json:"variant" validate:"required_if=Scope variant"`
ImageID int64 `json:"image_id" validate:"required_if=Scope image,omitempty,gt=0,lte=4294967295"`
}
type cachePurgeResponse struct {
Scope string `json:"scope"`
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
FailedFiles int `json:"failed_files"`
}
func (r *cachePurgeRequest) ignoreUnusedTargets() {
if r.Scope != string(image.CacheScopeVariant) {
r.Variant = ""
}
if r.Scope != string(image.CacheScopeImage) {
r.ImageID = 0
}
}
func cacheResponseOf(stats useCases.VariantCacheStats) cacheResponse {
response := cacheResponse{
GeneratedFiles: stats.GeneratedFiles,
DiskUsed: stats.DiskUsed,
VariantsDefined: stats.VariantsDefined,
VariantsEnabled: stats.VariantsEnabled,
VariantsDisabled: stats.VariantsDisabled,
RejectedRequests: stats.RejectedRequests,
RejectedWindowDays: stats.RejectedWindowDays,
Unused: cacheUnusedResponse{Files: stats.Unused.Files, Bytes: stats.Unused.Bytes, Days: stats.UnusedDays},
ByVariant: make([]cacheVariantResponse, 0, len(stats.ByVariant)),
TopImages: make([]cacheImageResponse, 0, len(stats.TopImages)),
}
for _, entry := range stats.ByVariant {
response.ByVariant = append(response.ByVariant, cacheVariantResponse{Name: entry.Name, Enabled: entry.Enabled, Files: entry.Files, Bytes: entry.Bytes})
}
for _, top := range stats.TopImages {
response.TopImages = append(response.TopImages, cacheImageResponse{ID: top.ID, Filename: top.Filename, URL: top.URL, Files: top.Files, Bytes: top.Bytes})
}
return response
}
func (h *Handler) CacheStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.cacheStats.Get()
if err != nil {
h.writeError(w, "", err)
return
}
writeJSON(w, http.StatusOK, cacheResponseOf(stats))
}
func (h *Handler) PurgeCache(w http.ResponseWriter, r *http.Request) {
var request cachePurgeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
request.ignoreUnusedTargets()
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, cachePurgeValidationMessage(err), http.StatusUnprocessableEntity)
return
}
result, err := h.purgeCache.Purge(r.Context(), useCases.PurgeVariantCacheCommand{
Scope: image.CachePurgeScope(request.Scope),
Variant: request.Variant,
ImageID: uint32(min(request.ImageID, math.MaxUint32)),
})
if err != nil {
h.writePurgeCacheError(w, err)
return
}
writeJSON(w, http.StatusOK, cachePurgeResponse{Scope: string(result.Scope), PurgedFiles: result.Files, PurgedBytes: result.Bytes, FailedFiles: result.Failed})
}
func cachePurgeValidationMessage(err error) string {
var failures validatorlib.ValidationErrors
if errors.As(err, &failures) && len(failures) > 0 && failures[0].StructField() != "Scope" {
return apperr.CachePurgeTargetErrorMessage
}
return apperr.CachePurgeScopeErrorMessage
}
func (h *Handler) writePurgeCacheError(w http.ResponseWriter, err error) {
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
h.writeError(w, "", err)
}
@@ -0,0 +1,266 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
validatorlib "github.com/go-playground/validator/v10"
"github.com/go-chi/chi/v5"
)
type Handler struct {
list *useCases.ListVariantsUseCase
create *useCases.CreateVariantUseCase
update *useCases.UpdateVariantUseCase
setEnabled *useCases.SetVariantEnabledUseCase
remove *useCases.DeleteVariantUseCase
purge *useCases.PurgeVariantUseCase
cacheStats *useCases.GetVariantCacheStatsUseCase
purgeCache *useCases.PurgeVariantCacheUseCase
}
func NewHandler(list *useCases.ListVariantsUseCase, create *useCases.CreateVariantUseCase, update *useCases.UpdateVariantUseCase, setEnabled *useCases.SetVariantEnabledUseCase, remove *useCases.DeleteVariantUseCase, purge *useCases.PurgeVariantUseCase, cacheStats *useCases.GetVariantCacheStatsUseCase, purgeCache *useCases.PurgeVariantCacheUseCase) *Handler {
return &Handler{list, create, update, setEnabled, remove, purge, cacheStats, purgeCache}
}
type definitionRequest struct {
Width *int `json:"width" validate:"required_without=Height,omitempty,min=1,max=8000"`
Height *int `json:"height" validate:"required_without=Width,omitempty,min=1,max=8000"`
Fit string `json:"fit" validate:"omitempty,oneof=cover contain inside"`
Format string `json:"format" validate:"omitempty,oneof=original jpeg png webp"`
Quality *int `json:"quality" validate:"omitempty,min=1,max=100"`
}
type switchesRequest struct {
GenerateOnUpload *bool `json:"generate_on_upload"`
Enabled *bool `json:"enabled"`
}
type createRequest struct {
Name string `json:"name" validate:"required,variantname"`
definitionRequest
switchesRequest
}
type updateRequest struct {
definitionRequest
switchesRequest
}
type enabledRequest struct {
Enabled *bool `json:"enabled" validate:"required"`
}
type variantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit image.VariantFit `json:"fit"`
Format image.VariantFormat `json:"format"`
Quality *int `json:"quality"`
GenerateOnUpload bool `json:"generate_on_upload"`
Enabled bool `json:"enabled"`
GeneratedFiles int `json:"generated_files"`
CacheSize int64 `json:"cache_size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type updatedVariantResponse struct {
variantResponse
PurgedFiles int `json:"purged_files"`
}
type listResponse struct {
Items []variantResponse `json:"items"`
}
type purgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func responseOf(summary useCases.VariantSummary) variantResponse {
variant := summary.Variant
return variantResponse{
Name: variant.Name,
Width: variant.Width,
Height: variant.Height,
Fit: variant.Fit,
Format: variant.Format,
Quality: variant.Quality,
GenerateOnUpload: variant.GenerateOnUpload,
Enabled: variant.Enabled,
GeneratedFiles: summary.GeneratedFiles,
CacheSize: summary.CacheSize,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
summaries, err := h.list.List()
if err != nil {
h.writeError(w, "", err)
return
}
items := make([]variantResponse, 0, len(summaries))
for _, summary := range summaries {
items = append(items, responseOf(summary))
}
writeJSON(w, http.StatusOK, listResponse{items})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request createRequest
if !h.decode(w, r, &request) {
return
}
summary, err := h.create.Create(r.Context(), useCases.CreateVariantCommand{
Name: request.Name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, request.Name, err)
return
}
writeJSON(w, http.StatusCreated, responseOf(summary))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request updateRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
result, err := h.update.Update(r.Context(), useCases.UpdateVariantCommand{
Name: name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, updatedVariantResponse{responseOf(result.VariantSummary), result.PurgedFiles})
}
func (h *Handler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request enabledRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
summary, err := h.setEnabled.SetEnabled(r.Context(), name, *request.Enabled)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, responseOf(summary))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := h.remove.Delete(r.Context(), name); err != nil {
h.writeError(w, name, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Purge(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
purged, err := h.purge.Purge(r.Context(), name)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, purgeResponse{purged.Files, purged.Bytes})
}
func (d definitionRequest) command() useCases.VariantDefinitionCommand {
return useCases.VariantDefinitionCommand{Width: d.Width, Height: d.Height, Fit: d.Fit, Format: d.Format, Quality: d.Quality}
}
func (h *Handler) decode(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, h.validationMessage(err), http.StatusUnprocessableEntity)
return false
}
return true
}
func (h *Handler) validationMessage(err error) string {
var failures validatorlib.ValidationErrors
if !errors.As(err, &failures) || len(failures) == 0 {
return apperr.VariantInvalidErrorMessage
}
switch failures[0].StructField() {
case "Name":
return apperr.VariantNameErrorMessage
case "Width", "Height":
return apperr.VariantDimensionsErrorMessage
case "Fit":
return apperr.VariantFitErrorMessage
case "Format":
return h.formatMessage()
case "Quality":
return apperr.VariantQualityErrorMessage
default:
return apperr.VariantInvalidErrorMessage
}
}
func (h *Handler) formatMessage() string {
encodable := h.create.EncodableFormats()
names := make([]string, 0, len(encodable))
for _, format := range encodable {
names = append(names, string(format))
}
last := len(names) - 1
return fmt.Sprintf(apperr.VariantFormatErrorMessageFormat, strings.Join(names[:last], ", ")+" or "+names[last])
}
func (h *Handler) writeError(w http.ResponseWriter, name string, err error) {
switch {
case errors.Is(err, image.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, image.ErrVariantExists):
http.Error(w, fmt.Sprintf(apperr.VariantExistsErrorMessageFormat, name), http.StatusConflict)
case errors.Is(err, image.ErrVariantFormatUnavailable):
http.Error(w, h.formatMessage(), http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrVariantInvalid):
http.Error(w, apperr.VariantInvalidErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
body, err := json.Marshal(value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
+76
View File
@@ -0,0 +1,76 @@
package llms
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
)
const (
versionPlaceholder = "{{VERSION}}"
baseURLPlaceholder = "{{BASE_URL}}"
pageSuffix = ".md"
cacheControl = "public, max-age=300"
notBuiltNotice = "The LLM documentation was not generated for this build. Run `task build:llms`."
)
type Handler struct {
docs fs.FS
version string
baseURL string
}
func NewHandler(docs fs.FS, version string, publicURL string) *Handler {
return &Handler{
docs: docs,
version: version,
baseURL: strings.TrimRight(publicURL, "/"),
}
}
func (h *Handler) Register(r chi.Router) {
r.Get("/llms.txt", h.serve("llms.txt", "text/plain; charset=utf-8"))
r.Get("/llms-full.txt", h.serve("llms-full.txt", "text/plain; charset=utf-8"))
r.Get("/openapi.json", h.serve("openapi.json", "application/json"))
r.Get("/llms/{page}", h.page)
}
func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "page")
if !strings.HasSuffix(name, pageSuffix) || strings.ContainsAny(name, "/\\") {
h.notFound(w)
return
}
h.write(w, path.Join("llms", name), "text/markdown; charset=utf-8")
}
func (h *Handler) serve(name string, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
h.write(w, name, contentType)
}
}
func (h *Handler) write(w http.ResponseWriter, name string, contentType string) {
if h.docs == nil {
h.notFound(w)
return
}
raw, err := fs.ReadFile(h.docs, name)
if err != nil {
h.notFound(w)
return
}
text := strings.NewReplacer(versionPlaceholder, h.version, baseURLPlaceholder, h.baseURL).Replace(string(raw))
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
_, _ = w.Write([]byte(text))
}
func (h *Handler) notFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(notBuiltNotice))
}
+93 -16
View File
@@ -29,6 +29,11 @@ type Handler struct {
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
ListVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
}
type RenameRequest struct {
@@ -53,6 +58,9 @@ type mediaItemResponse struct {
CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"`
VariantCount int `json:"variant_count"`
VariantSize int64 `json:"variant_size"`
}
type countsResponse struct {
@@ -63,22 +71,58 @@ type countsResponse struct {
type listResponse struct {
Items []mediaItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts countsResponse `json:"counts"`
}
type pagingQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
type usageResponse struct {
Type string `json:"type"`
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
Title string `json:"title,omitempty"`
Published *bool `json:"published,omitempty"`
Fields []string `json:"fields"`
ContentTypeKey string `json:"content_type_key,omitempty"`
ContentTypeName string `json:"content_type_name,omitempty"`
FirstField *usageFirstFieldBody `json:"first_field,omitempty"`
}
type usageFirstFieldBody struct {
Key string `json:"key"`
Type string `json:"type"`
Value any `json:"value"`
}
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
if usage.FirstField != nil {
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
}
return body
}
type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
@@ -90,6 +134,11 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
ListVariantsUseCase: listVariants,
GenerateVariantUseCase: generateVariant,
GenerateAllUseCase: generateAll,
PurgeVariantUseCase: purgeVariant,
GenerateOnUploadUseCase: generateOnUpload,
}
}
@@ -145,6 +194,9 @@ func toResponse(item useCases.MediaItem) mediaItemResponse {
CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn,
Duplicate: item.Duplicate,
VariantCount: item.VariantCount,
VariantSize: item.VariantSize,
}
}
@@ -157,20 +209,31 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
query := domainImage.NewListQuery()
query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash"))
query.Cursor = values.Get("cursor")
if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter"))
}
if values.Has("variants") {
if values.Get("variants") == "" {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Variants = domainImage.VariantsFilter(values.Get("variants"))
}
if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort"))
}
if values.Has("limit") {
limit, err := strconv.Atoi(values.Get("limit"))
page, err := intQueryParam(values, "page", domainImage.DefaultListPage)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Limit = limit
size, err := intQueryParam(values, "size", domainImage.DefaultListSize)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
if validator.Validate.Struct(pagingQuery{Page: page, Size: size}) != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Page = page
query.Size = size
return query, nil
}
@@ -197,14 +260,27 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
response := listResponse{
Items: items,
Total: page.Total,
Page: page.Page,
Size: page.Size,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
}
if page.NextCursor != "" {
response.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
item, err := h.ListUseCase.Get(id)
if err != nil {
writeImageError(w, err)
return
}
writeJSON(w, http.StatusOK, toResponse(item))
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
@@ -219,6 +295,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
return
}
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
h.writeItem(w, http.StatusCreated, created)
}
@@ -280,15 +357,15 @@ func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
return
}
articles, err := h.UsageUseCase.Usage(id)
usages, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
response := make([]usageResponse, 0, len(usages))
for _, usage := range usages {
response = append(response, toUsageResponse(usage))
}
writeJSON(w, http.StatusOK, response)
}
@@ -310,7 +387,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, Merged: result.Merged})
}
func writeMergeError(w http.ResponseWriter, err error) {
+4
View File
@@ -53,6 +53,8 @@ type purgeRunResponse struct {
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
FailedGroups int `json:"failed_groups"`
}
@@ -164,6 +166,8 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
SitesUpdated: result.SitesUpdated,
AuthorsUpdated: result.AuthorsUpdated,
FailedGroups: result.FailedGroups,
})
}
+161
View File
@@ -0,0 +1,161 @@
package media
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type imageVariantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit domainImage.VariantFit `json:"fit"`
Format domainImage.VariantFormat `json:"format"`
OutputWidth int `json:"output_width"`
OutputHeight int `json:"output_height"`
OutputFormat domainImage.VariantFormat `json:"output_format"`
Generated bool `json:"generated"`
Size *int64 `json:"size"`
URL string `json:"url"`
}
type imageVariantListResponse struct {
Unsupported *string `json:"unsupported"`
Items []imageVariantResponse `json:"items"`
}
type variantFailureResponse struct {
Name string `json:"name"`
Cause string `json:"cause"`
}
type generateAllResponse struct {
Generated int `json:"generated"`
Failed int `json:"failed"`
Failures []variantFailureResponse `json:"failures"`
Items []imageVariantResponse `json:"items"`
}
type variantPurgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func toImageVariantResponse(view useCases.ImageVariantView) imageVariantResponse {
response := imageVariantResponse{
Name: view.Variant.Name,
Width: view.Variant.Width,
Height: view.Variant.Height,
Fit: view.Variant.Fit,
Format: view.Variant.Format,
OutputWidth: view.OutputWidth,
OutputHeight: view.OutputHeight,
OutputFormat: view.OutputFormat,
Generated: view.Generated,
URL: view.URL,
}
if view.Generated {
size := view.Size
response.Size = &size
}
return response
}
func toImageVariantResponses(views []useCases.ImageVariantView) []imageVariantResponse {
items := make([]imageVariantResponse, 0, len(views))
for _, view := range views {
items = append(items, toImageVariantResponse(view))
}
return items
}
func writeImageVariantError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domainImage.ErrVariantSourceUnsupported):
http.Error(w, apperr.VariantSourceUnsupportedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domainImage.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
case errors.Is(err, domainImage.ErrVariantSourceUnreadable):
http.Error(w, apperr.VariantSourceUnreadableErrorMessage, http.StatusUnsupportedMediaType)
default:
writeImageError(w, err)
}
}
func (h *Handler) ListVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
list, err := h.ListVariantsUseCase.List(id)
if err != nil {
writeImageVariantError(w, err)
return
}
response := imageVariantListResponse{Items: toImageVariantResponses(list.Items)}
if list.Unsupported != "" {
response.Unsupported = &list.Unsupported
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GenerateVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
view, err := h.GenerateVariantUseCase.Generate(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, toImageVariantResponse(view))
}
func (h *Handler) GenerateAllVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
result, err := h.GenerateAllUseCase.Generate(r.Context(), id)
if err != nil {
writeImageVariantError(w, err)
return
}
failures := make([]variantFailureResponse, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, variantFailureResponse{Name: failure.Name, Cause: failure.Cause})
}
writeJSON(w, http.StatusOK, generateAllResponse{
Generated: result.Generated,
Failed: result.Failed,
Failures: failures,
Items: toImageVariantResponses(result.Items),
})
}
func (h *Handler) PurgeVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
purged, err := h.PurgeVariantUseCase.Purge(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, variantPurgeResponse{PurgedFiles: purged.Files, PurgedBytes: purged.Bytes})
}
@@ -0,0 +1,69 @@
package ownership
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
TransferUseCase *useCases.TransferOwnershipUseCase
}
type TransferRequest struct {
UserID uint32 `json:"user_id" validate:"required"`
ConfirmEmail string `json:"confirm_email" validate:"required"`
}
func NewHandler(transfer *useCases.TransferOwnershipUseCase) *Handler {
return &Handler{TransferUseCase: transfer}
}
func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
var request TransferRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
transferred, err := h.TransferUseCase.Transfer(r.Context(), useCases.TransferOwnershipCommand{
Caller: access.CallerOf(r),
UserID: request.UserID,
ConfirmEmail: request.ConfirmEmail,
})
if err != nil {
writeTransferError(w, err)
return
}
body, err := json.Marshal(payload.OwnershipTransferOf(transferred))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeTransferError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrInvalidTransfer):
http.Error(w, apperr.InvalidTransferErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrConfirmationMismatch):
http.Error(w, apperr.ConfirmationMismatchErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
@@ -0,0 +1,53 @@
package permission
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
ListUseCase *useCases.ListPermissionsUseCase
}
func NewHandler(list *useCases.ListPermissionsUseCase) *Handler {
return &Handler{ListUseCase: list}
}
type permissionItem struct {
Key string `json:"key"`
ScopeKind role.Kind `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type permissionGroup struct {
Key string `json:"key"`
Items []permissionItem `json:"items"`
}
type permissionResponse struct {
Groups []permissionGroup `json:"groups"`
}
func toResponse(groups []useCases.PermissionGroup) permissionResponse {
response := permissionResponse{Groups: make([]permissionGroup, 0, len(groups))}
for _, group := range groups {
items := make([]permissionItem, 0, len(group.Items))
for _, permission := range group.Items {
items = append(items, permissionItem{Key: permission.Key, ScopeKind: permission.Kind, OwnerOnly: permission.OwnerOnly, PerType: permission.PerType()})
}
response.Groups = append(response.Groups, permissionGroup{Key: group.Key, Items: items})
}
return response
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(toResponse(h.ListUseCase.List()))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+251
View File
@@ -0,0 +1,251 @@
package role
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListRolesUseCase
GetUseCase *useCases.GetRoleUseCase
CreateUseCase *useCases.CreateRoleUseCase
UpdateUseCase *useCases.UpdateRoleUseCase
DeleteUseCase *useCases.DeleteRoleUseCase
ReassignUseCase *useCases.ReassignRoleMembersUseCase
}
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
}
type GrantRequest struct {
Key string `json:"key" validate:"required,max=100"`
Scope string `json:"scope"`
}
type PostRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
}
type PutRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
Version uint32 `json:"version" validate:"required,min=1"`
}
type ResetRole struct {
Version uint32 `json:"version" validate:"required,min=1"`
}
type ReassignRole struct {
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
}
func toGrants(requested []GrantRequest) []domain.Grant {
grants := make([]domain.Grant, 0, len(requested))
for _, grant := range requested {
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
}
return grants
}
func parseRoleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return false
}
return true
}
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := parseRoleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return id, true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
type refusal struct {
cause error
status int
message string
}
var refusals = []refusal{
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
{domain.ErrReassignTargetNotFound, http.StatusConflict, apperr.ReassignTargetNotFoundErrorMessage},
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
}
func writeRoleError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var escalation domain.EscalationError
if errors.As(err, &escalation) {
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
return
}
for _, candidate := range refusals {
if errors.Is(err, candidate.cause) {
http.Error(w, candidate.message, candidate.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
listed, err := h.ListUseCase.List()
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewsOf(listed))
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
found, err := h.GetUseCase.Get(id)
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostRole
if !decodeRequest(w, r, &request) {
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusCreated, payload.RoleViewOf(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request PutRole
if !decodeRequest(w, r, &request) {
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(updated))
}
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ResetRole
if !decodeRequest(w, r, &request) {
return
}
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, payload.RoleViewOf(restored))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
writeRoleError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ReassignRole
if !decodeRequest(w, r, &request) {
return
}
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
}
+30 -3
View File
@@ -7,6 +7,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
@@ -18,12 +19,15 @@ type Handler struct {
}
type UpdateSettingsRequest struct {
ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"`
ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -43,8 +47,13 @@ func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || hasNullValue(body) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
@@ -53,7 +62,10 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays)
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
Days: request.ActivityRetentionDays,
MaxGB: request.ActivityRetentionMaxGB,
})
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -65,10 +77,25 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
h.write(w, retention)
}
func hasNullValue(body []byte) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return false
}
for _, value := range fields {
if string(value) == "null" {
return true
}
}
return false
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityRetentionMaxGB: retention.RetentionMaxGB,
ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
+296
View File
@@ -0,0 +1,296 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListSitesUseCase
CreateUseCase *useCases.CreateSiteUseCase
UpdateUseCase *useCases.UpdateSiteUseCase
SetDefaultUseCase *useCases.SetDefaultSiteUseCase
DeleteUseCase *useCases.DeleteSiteUseCase
}
func NewHandler(list *useCases.ListSitesUseCase, create *useCases.CreateSiteUseCase, update *useCases.UpdateSiteUseCase, setDefault *useCases.SetDefaultSiteUseCase, deleteUseCase *useCases.DeleteSiteUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, UpdateUseCase: update, SetDefaultUseCase: setDefault, DeleteUseCase: deleteUseCase}
}
type PostSite struct {
Name string `json:"name" validate:"required,sitename"`
Key string `json:"key" validate:"required,sitekey"`
BaseURL string `json:"base_url" validate:"required"`
DefaultRobots string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale *string `json:"locale"`
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
IsDefault bool `json:"is_default"`
SiteName string `json:"site_name" validate:"max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription string `json:"default_description" validate:"max=320"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchSite struct {
Name *string `json:"name" validate:"omitempty,sitename"`
Key *string `json:"key" validate:"omitempty,sitekey"`
BaseURL *string `json:"base_url"`
DefaultRobots *string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale optional[string] `json:"locale"`
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
IsDefault *bool `json:"is_default"`
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
}
func (p PatchSite) toPatch() domain.Patch {
return domain.Patch{
Name: p.Name,
Key: p.Key,
BaseURL: p.BaseURL,
DefaultRobots: p.DefaultRobots,
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
IsDefault: p.IsDefault,
SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription,
}
}
func parseSiteId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeSite(w http.ResponseWriter, status int, s domain.Site) {
body, err := json.Marshal(s)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeSiteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrSiteNotFound):
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSiteKeyTaken):
http.Error(w, apperr.SiteKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSiteHasArticles):
http.Error(w, apperr.SiteHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrDefaultSite):
http.Error(w, apperr.DefaultSiteErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrKeyImmutable):
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSiteURL):
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidTitleTemplate):
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrUseSetDefault):
http.Error(w, apperr.UseSetDefaultErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"omitempty,oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
paged := values.Has("page") || values.Has("size")
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size := 0
if paged {
size, err = intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
if size == 0 {
return listQuery{}, errors.New("invalid size")
}
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(page.Items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateSiteCommand{
Name: request.Name,
Key: request.Key,
BaseURL: request.BaseURL,
DefaultRobots: request.DefaultRobots,
Locale: request.Locale,
DefaultSocialImageID: request.DefaultSocialImageID,
IsDefault: request.IsDefault,
SiteName: request.SiteName,
TitleTemplate: request.TitleTemplate,
DefaultDescription: request.DefaultDescription,
})
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusCreated, created)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request PatchSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, request.toPatch())
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
func (h *Handler) SetDefault(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
updated, err := h.SetDefaultUseCase.SetDefault(r.Context(), id)
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
const reassignQueryParam = "reassign_to"
func readReassign(r *http.Request) (*uint32, bool) {
query := r.URL.Query()
if !query.Has(reassignQueryParam) {
return nil, false
}
parsed, err := strconv.ParseUint(query.Get(reassignQueryParam), 10, 32)
if err != nil {
return nil, true
}
destination := uint32(parsed)
return &destination, false
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
reassignTo, invalid := readReassign(r)
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteSiteCommand{ID: id, ReassignTo: reassignTo, ReassignInvalid: invalid}); err != nil {
writeSiteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+87
View File
@@ -0,0 +1,87 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
defaultSitemapPage = 1
defaultSitemapSize = 100
totalCountHeader = "X-Total-Count"
)
type SitemapHandler struct {
ListUseCase *useCases.ListSitemapUseCase
}
func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler {
return &SitemapHandler{ListUseCase: list}
}
type sitemapQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100 500"`
}
type sitemapItem struct {
URL string `json:"url"`
ModifiedAt string `json:"modified_at"`
Noindex bool `json:"noindex"`
}
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readSitemapQuery(values url.Values) (sitemapQuery, error) {
page, err := sitemapIntParam(values, "page", defaultSitemapPage)
if err != nil {
return sitemapQuery{}, err
}
size, err := sitemapIntParam(values, "size", defaultSitemapSize)
if err != nil {
return sitemapQuery{}, err
}
return sitemapQuery{Page: page, Size: size}, nil
}
func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
query, err := readSitemapQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size)
if err != nil {
writeSiteError(w, err)
return
}
items := make([]sitemapItem, 0, len(page.Entries))
for _, entry := range page.Entries {
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set(totalCountHeader, strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
@@ -1,8 +1,10 @@
package staticimages
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
@@ -17,31 +19,115 @@ import (
const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream"
const unknownVariantCode = "unknown_variant"
const unsupportedParameterCode = "unsupported_parameter"
var contentTypeExtensions = map[string]string{
".png": "image/png",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".gif": "image/gif",
}
type Handler struct {
openMedia *useCases.OpenMediaUseCase
serveVariant *useCases.ServeVariantUseCase
modTime time.Time
}
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
return &Handler{
openMedia: openMedia,
serveVariant: serveVariant,
modTime: time.Now(),
}
}
type requestErrorBody struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
blob, err := h.openMedia.Open(cleaned)
served, err := h.serveVariant.Serve(variantRequestFrom(r, cleaned))
if err != nil {
writeServeError(w, r, err)
return
}
switch {
case served.NotModified:
writeNotModified(w, served)
case served.Original:
h.serveOriginal(w, r, cleaned)
default:
serveVariantFile(w, r, cleaned, served)
}
}
func variantRequestFrom(r *http.Request, storedName string) useCases.ServeVariantRequest {
query := r.URL.Query()
request := useCases.ServeVariantRequest{Context: r.Context(), StoredName: storedName, IfNoneMatch: r.Header.Get("If-None-Match")}
for parameter := range query {
request.Parameters = append(request.Parameters, parameter)
}
if query.Has(image.VariantQueryParameter) {
name := query.Get(image.VariantQueryParameter)
request.Variant = &name
}
return request
}
func writeServeError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, image.ErrUnknownVariant):
writeRequestError(w, unknownVariantCode, apperr.VariantUnknownMessage, err)
case errors.Is(err, image.ErrUnsupportedParameter):
writeRequestError(w, unsupportedParameterCode, apperr.VariantUnsupportedParameterMessage, err)
case errors.Is(err, image.ErrBlobNotFound), errors.Is(err, image.ErrImageNotFound):
http.NotFound(w, r)
case errors.Is(err, image.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
default:
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
}
}
func writeRequestError(w http.ResponseWriter, code string, message string, err error) {
body, marshalErr := json.Marshal(requestErrorBody{
Status: http.StatusBadRequest,
Error: code,
Message: message,
Detail: image.VariantRequestDetail(err),
})
if marshalErr != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}
func writeNotModified(w http.ResponseWriter, served useCases.ServedVariant) {
w.Header().Set("ETag", served.ETag)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusNotModified)
}
func serveVariantFile(w http.ResponseWriter, r *http.Request, name string, served useCases.ServedVariant) {
defer served.Content.Close()
w.Header().Set("ETag", served.ETag)
serveContent(w, r, name, served.ContentType, image.Blob{Content: served.Content, Size: served.Size, ModTime: served.ModTime})
}
func (h *Handler) serveOriginal(w http.ResponseWriter, r *http.Request, name string) {
blob, err := h.openMedia.Open(name)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
@@ -52,7 +138,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer blob.Content.Close()
contentType := contentTypeFor(cleaned)
serveContent(w, r, name, contentTypeFor(name), blob)
}
func serveContent(w http.ResponseWriter, r *http.Request, name string, contentType string, blob image.Blob) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
if contentType == "image/svg+xml" {
@@ -60,7 +149,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, cleaned, blob.ModTime, content)
http.ServeContent(w, r, name, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
+7 -30
View File
@@ -1,11 +1,12 @@
package update
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -21,29 +22,23 @@ type Handler struct {
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler {
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
return &Handler{
GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
}
}
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
@@ -51,11 +46,7 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
@@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
return *request.Kind, true
}
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return false, false
}
return currentUser.Role == user.RoleOwner, true
func canApply(r *http.Request) bool {
return access.Grants(r).Holds(role.KeySystemApplyUpdates)
}
func writeDownloadError(w http.ResponseWriter, err error) {
+81
View File
@@ -0,0 +1,81 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type DeleteAccountHandler struct {
DeleteUseCase *useCases.DeleteOwnAccountUseCase
IsProduction bool
}
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
}
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
}
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
var request DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
command.SessionID, command.SessionExpiresAt = sessionOf(r)
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
writeDeleteAccountError(w, err)
return
}
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
w.WriteHeader(http.StatusNoContent)
}
func sessionOf(r *http.Request) (string, time.Time) {
tokenClaims, err := authtoken.FromContext(r.Context())
if err != nil {
return "", time.Time{}
}
id, _ := tokenClaims["jti"].(string)
expiry, _ := tokenClaims["exp"].(float64)
return id, time.Unix(int64(expiry), 0)
}
func writeDeleteAccountError(w http.ResponseWriter, err error) {
var lastAdmin domain.LastAdminError
switch {
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, domain.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.As(err, &lastAdmin):
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
case errors.Is(err, domain.ErrAccountNotDeletable):
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+44
View File
@@ -0,0 +1,44 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
)
type ExportHandler struct {
ExportUseCase *useCases.ExportUserDataUseCase
}
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
return &ExportHandler{ExportUseCase: export}
}
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
archive, err := h.ExportUseCase.Export(r.Context(), userId)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive.Content)
}
+39 -17
View File
@@ -1,8 +1,10 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
@@ -25,6 +27,7 @@ type InviteHandler struct {
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"`
RoleID uint32 `json:"role_id"`
}
type inviteResponse struct {
@@ -32,6 +35,7 @@ type inviteResponse struct {
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
Role payload.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
@@ -40,6 +44,7 @@ type inviteListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Role *payload.RoleRef `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
@@ -50,7 +55,8 @@ type userListItem struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"`
Role *payload.RoleRef `json:"role"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
@@ -83,8 +89,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
}
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Caller: access.CallerOf(r),
Email: request.Email,
InvitedBy: userId,
RoleID: request.RoleID,
})
if err != nil {
writeCreateInviteError(w, err)
@@ -94,12 +102,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusCreated, created)
}
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) {
func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) {
body, err := json.Marshal(inviteResponse{
ID: entry.ID,
Kind: useCases.EntryKindInvite,
Email: entry.Email,
Status: string(entry.StatusAt(time.Now())),
Role: payload.RoleRefOf(entry.Role),
CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt,
})
@@ -144,7 +153,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return
}
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId})
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId})
if err != nil {
writeManageInviteError(w, err)
return
@@ -153,6 +162,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
}
func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
@@ -168,10 +180,15 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
}
func writeCreateInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
@@ -183,22 +200,27 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
}
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{
ID: entry.ID,
Kind: entry.Kind,
Email: entry.Email,
Role: payload.RoleRefPtrOf(entry.Role),
Status: entry.Status,
CreatedAt: entry.CreatedAt,
})
continue
}
items = append(items, userListItemOf(entry))
}
writeNoStoreJSON(w, http.StatusOK, items)
}
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"strings"
)
type LookupHandler struct {
LookupUseCase *useCases.LookupUserNamesUseCase
}
type userNameResponse struct {
ID uint32 `json:"id"`
DisplayName string `json:"display_name"`
}
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
return &LookupHandler{LookupUseCase: lookup}
}
func parseLookupIDs(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
return nil, false
}
parts := strings.Split(raw, ",")
ids := make([]uint32, 0, len(parts))
for _, part := range parts {
id, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, false
}
ids = append(ids, uint32(id))
}
return ids, true
}
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
names, err := h.LookupUseCase.Lookup(ids)
if errors.Is(err, domainUser.ErrInvalidLookup) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]userNameResponse, 0, len(names))
for _, name := range names {
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
}
response.WriteJSON(w, http.StatusOK, items)
}
+62
View File
@@ -0,0 +1,62 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RemoveHandler struct {
RemoveUserUseCase *useCases.RemoveUserUseCase
}
func NewRemoveHandler(removeUser *useCases.RemoveUserUseCase) *RemoveHandler {
return &RemoveHandler{RemoveUserUseCase: removeUser}
}
func (h *RemoveHandler) Remove(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
expectedRoleID, err := strconv.ParseUint(r.URL.Query().Get("expected_role_id"), 10, 32)
if err != nil || expectedRoleID == 0 {
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
return
}
err = h.RemoveUserUseCase.Remove(r.Context(), useCases.RemoveUserCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
ExpectedRoleID: uint32(expectedRoleID),
})
if err != nil {
writeRemoveError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeRemoveError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+73
View File
@@ -0,0 +1,73 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RoleHandler struct {
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
}
type ChangeRoleRequest struct {
RoleID uint32 `json:"role_id" validate:"required"`
}
func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler {
return &RoleHandler{ChangeUserRoleUseCase: changeRole}
}
func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request ChangeRoleRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changed, err := h.ChangeUserRoleUseCase.ChangeRole(r.Context(), useCases.ChangeUserRoleCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
RoleID: request.RoleID,
})
if err != nil {
writeChangeRoleError(w, err)
return
}
writeNoStoreJSON(w, http.StatusOK, userListItemOf(changed))
}
func writeChangeRoleError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+52
View File
@@ -0,0 +1,52 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{
ID: entry.ID,
Kind: entry.Kind,
Username: entry.Username,
DisplayName: entry.DisplayName,
Email: entry.Email,
Role: payload.RoleRefPtrOf(entry.Role),
RoleAssignedBy: payload.RoleAssignerPtrOf(entry.RoleAssignedBy),
Status: entry.Status,
CreatedAt: entry.CreatedAt,
}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
+16 -18
View File
@@ -2,6 +2,7 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases"
"context"
@@ -10,9 +11,6 @@ import (
"strconv"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
const bearerScheme = "bearer "
@@ -51,6 +49,16 @@ func RequireSession(next http.Handler) http.Handler {
})
}
func RejectAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
@@ -69,13 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
return
}
token, err := sessionTokenFor(authenticated)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx))
@@ -91,15 +93,11 @@ func writeAPIKeyError(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
token := jwt.New()
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
return nil, err
func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
return authtoken.Claims{
"user_id": float64(authenticated.User.ID),
"token_version": float64(authenticated.User.TokenVersion),
}
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
}
func apiKeyRateLimitKey(r *http.Request) string {
+3 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
@@ -10,6 +11,7 @@ import (
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
@@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if !ok {
return audit.AnonymousActor("")
}
current, err := getUser.GetUser(userId)
current, err := access.LoadUser(getUser, r, userId)
if err != nil {
return audit.AnonymousActor("")
}
+4 -37
View File
@@ -1,6 +1,7 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
return user.User{}, false
}
currentUser, err := getUser.GetUser(userId)
currentUser, err := access.LoadUser(getUser, r, userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
@@ -39,6 +40,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
@@ -77,39 +80,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
})
}
}
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner {
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package middleware
import (
"net/http"
"strconv"
"strings"
)
const (
corsAllowAnyOrigin = "*"
corsWildcardMarker = "*"
corsPreflightMaxAge = 300
corsPreflightSuccess = http.StatusNoContent
)
var (
corsAllowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}
corsAllowedHeaders = []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}
corsExposedHeaders = []string{"Link", "X-Total-Count"}
)
type originWildcard struct {
prefix string
suffix string
}
func (w originWildcard) matches(origin string) bool {
return len(origin) >= len(w.prefix)+len(w.suffix) && strings.HasPrefix(origin, w.prefix) && strings.HasSuffix(origin, w.suffix)
}
type originPolicy struct {
anyOrigin bool
exact map[string]struct{}
wildcards []originWildcard
}
func newOriginPolicy(origins []string) originPolicy {
policy := originPolicy{exact: map[string]struct{}{}}
for _, origin := range origins {
origin = strings.ToLower(strings.TrimSpace(origin))
prefix, suffix, hasWildcard := strings.Cut(origin, corsWildcardMarker)
switch {
case origin == "":
case origin == corsAllowAnyOrigin:
policy.anyOrigin = true
case hasWildcard:
policy.wildcards = append(policy.wildcards, originWildcard{prefix: prefix, suffix: suffix})
default:
policy.exact[origin] = struct{}{}
}
}
return policy
}
func (p originPolicy) allows(origin string) bool {
if p.anyOrigin {
return true
}
origin = strings.ToLower(origin)
if _, ok := p.exact[origin]; ok {
return true
}
for _, wildcard := range p.wildcards {
if wildcard.matches(origin) {
return true
}
}
return false
}
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
}
func containsFold(allowed []string, value string) bool {
for _, candidate := range allowed {
if strings.EqualFold(candidate, value) {
return true
}
}
return false
}
func requestedHeadersAllowed(r *http.Request) bool {
requested := r.Header.Get("Access-Control-Request-Headers")
if requested == "" {
return true
}
for _, header := range strings.Split(requested, ",") {
if header = strings.TrimSpace(header); header != "" && !containsFold(corsAllowedHeaders, header) {
return false
}
}
return true
}
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
policy := newOriginPolicy(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
if isPreflight(r) {
answerPreflight(w, r, origin, policy)
return
}
if policy.allows(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsExposedHeaders, ", "))
}
next.ServeHTTP(w, r)
})
}
}
func answerPreflight(w http.ResponseWriter, r *http.Request, origin string, policy originPolicy) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
method := r.Header.Get("Access-Control-Request-Method")
if !policy.allows(origin) || !containsFold(corsAllowedMethods, method) || !requestedHeadersAllowed(r) {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.ToUpper(method))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders, ", "))
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsPreflightMaxAge))
w.WriteHeader(corsPreflightSuccess)
}
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"net/url"
)
func CrossOriginProtection(publicURL string) func(http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
if origin, ok := originOf(publicURL); ok {
_ = protection.AddTrustedOrigin(origin)
}
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, apperr.CrossOriginErrorMessage, http.StatusForbidden)
}))
return func(next http.Handler) http.Handler {
protected := protection.Handler(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
next.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
})
}
}
func originOf(rawURL string) (string, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host, true
}
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
)
func OptionalAccess(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
account, signedIn, err := verifiedAccount(getUser, r)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !signedIn {
next.ServeHTTP(w, r)
return
}
grants, err := check.Grants(account)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
caller := access.Caller{UserID: account.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
func verifiedAccount(getUser *useCases.GetUserUseCase, r *http.Request) (user.User, bool, error) {
userID, ok := claims.UserId(r)
if !ok {
return user.User{}, false, nil
}
account, err := access.LoadUser(getUser, r, userID)
if errors.Is(err, user.ErrUserNotFound) {
return user.User{}, false, nil
}
if err != nil {
return user.User{}, false, err
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != account.TokenVersion {
return user.User{}, false, nil
}
return account, account.IsVerified, nil
}
+62
View File
@@ -0,0 +1,62 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"github.com/go-chi/chi/v5"
"net/http"
)
type grantCheck func(account user.User, r *http.Request) (role.GrantSet, error)
func ArticleTypeKey(*http.Request) string {
return builtintype.KeyArticle
}
func PathTypeKey(r *http.Request) string {
return chi.URLParam(r, "key")
}
func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.Require(account, key)
})
}
func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.RequireAll(account, key)
})
}
func RequireTypedPermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string, typeKey func(r *http.Request) string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, r *http.Request) (role.GrantSet, error) {
return check.RequireFor(account, key, typeKey(r))
})
}
func enforce(getUser *useCases.GetUserUseCase, verify grantCheck) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
grants, err := verify(currentUser, r)
if err != nil {
if !access.WriteIfMissingPermission(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
caller := access.Caller{UserID: currentUser.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
+34 -12
View File
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
count int
}
type rateLimitCounters struct {
mu sync.Mutex
window time.Duration
windows map[string]*rateLimitWindow
lastSweep time.Time
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
}
func connectionHostKey(r *http.Request) string {
return hostOf(r.RemoteAddr)
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
@@ -28,12 +39,11 @@ func userRateLimitKey(r *http.Request) string {
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
retryAfter, allowed := counters.recordAttempt(keyOf(r), maxAttempts, time.Now())
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
@@ -44,24 +54,36 @@ func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http
}
}
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
mu.Lock()
defer mu.Unlock()
func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
current, exists := windows[key]
if !exists || now.Sub(current.start) >= window {
c.sweepExpired(now)
current, exists := c.windows[key]
if !exists || now.Sub(current.start) >= c.window {
current = &rateLimitWindow{start: now}
windows[key] = current
c.windows[key] = current
}
current.count++
if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false
return retryAfterSeconds(c.window - now.Sub(current.start)), false
}
return 0, true
}
func (c *rateLimitCounters) sweepExpired(now time.Time) {
if now.Sub(c.lastSweep) < c.window {
return
}
c.lastSweep = now
for key, current := range c.windows {
if now.Sub(current.start) >= c.window {
delete(c.windows, key)
}
}
}
func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second)
if remaining%time.Second > 0 {
+2 -5
View File
@@ -2,16 +2,13 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil || jwt.Validate(token) != nil {
if _, err := authtoken.FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
+2 -2
View File
@@ -4,8 +4,8 @@ import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"time"
)
type successSlots struct {
mu sync.Mutex
window time.Duration
now func() time.Time
taken map[string]time.Time
}
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
return NewUserSuccessLimiterAt(window, time.Now)
}
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := userRateLimitKey(r)
claimed, retryAfter, granted := slots.claim(key)
if !granted {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
succeeded := false
defer func() {
if !succeeded {
slots.release(key, claimed)
}
}()
next.ServeHTTP(recorder, r)
succeeded = recorder.status < http.StatusBadRequest
})
}
}
func (s *successSlots) claim(key string) (time.Time, int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.sweepExpired(now)
if claimedAt, held := s.taken[key]; held {
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
}
s.taken[key] = now
return now, 0, true
}
func (s *successSlots) release(key string, claimedAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
delete(s.taken, key)
}
}
func (s *successSlots) sweepExpired(now time.Time) {
for key, claimedAt := range s.taken {
if now.Sub(claimedAt) >= s.window {
delete(s.taken, key)
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(status int) {
s.status = status
s.ResponseWriter.WriteHeader(status)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
+41
View File
@@ -0,0 +1,41 @@
package access
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
)
type Caller = useCases.ContentCaller
type callerContextKey struct{}
func WithCaller(ctx context.Context, caller Caller) context.Context {
return context.WithValue(ctx, callerContextKey{}, caller)
}
func CallerOf(r *http.Request) Caller {
caller, _ := r.Context().Value(callerContextKey{}).(Caller)
return caller
}
func Grants(r *http.Request) role.GrantSet {
return CallerOf(r).Grants
}
func WriteMissingPermission(w http.ResponseWriter, key string) {
w.Header().Set(apperr.MissingPermissionHeader, key)
http.Error(w, apperr.MissingPermissionErrorMessage, http.StatusForbidden)
}
func WriteIfMissingPermission(w http.ResponseWriter, err error) bool {
var missing role.MissingPermissionError
if !errors.As(err, &missing) {
return false
}
WriteMissingPermission(w, missing.Key)
return true
}
+37
View File
@@ -0,0 +1,37 @@
package access
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"net/http"
)
type currentUserContextKey struct{}
type currentUserHolder struct {
loaded bool
userID uint32
account user.User
err error
}
func WithCurrentUser(r *http.Request) *http.Request {
if _, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder); present {
return r
}
return r.WithContext(context.WithValue(r.Context(), currentUserContextKey{}, &currentUserHolder{}))
}
func LoadUser(getUser *useCases.GetUserUseCase, r *http.Request, userID uint32) (user.User, error) {
holder, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder)
if !present {
return getUser.GetUser(userID)
}
if !holder.loaded || holder.userID != userID {
holder.account, holder.err = getUser.GetUser(userID)
holder.userID = userID
holder.loaded = true
}
return holder.account, holder.err
}
+96 -2
View File
@@ -16,15 +16,19 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const ErrorCodeHeader = "X-Error-Code"
const EmailHasAccountErrorCode = "email_has_account"
const InvitePendingErrorCode = "invite_pending"
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
@@ -37,8 +41,98 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const MissingPermissionErrorMessage = "missing_permission"
const MissingPermissionHeader = "X-Missing-Permission"
const SessionRequiredErrorMessage = "This action needs a real session."
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
const VariantNotFoundErrorMessage = "Variant not found"
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
const VariantInvalidErrorMessage = "The variant is not valid."
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
const VariantFormatErrorMessageFormat = "Format must be %s."
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format."
const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated."
const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image."
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter"
const SiteNotFoundErrorMessage = "site_not_found"
const SiteKeyTakenErrorMessage = "site_key_taken"
const SiteHasArticlesErrorMessage = "site_has_articles"
const DefaultSiteErrorMessage = "default_site"
const SiteKeyImmutableErrorMessage = "key_immutable"
const InvalidSiteURLErrorMessage = "invalid_site_url"
const InvalidLocaleErrorMessage = "invalid_locale"
const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign"
const ReassignTargetNotFoundErrorMessage = "target_role_not_found"
const InvalidSocialTypeErrorMessage = "invalid_social_type"
const AuthorNotFoundErrorMessage = "author_not_found"
const AuthorSlugTakenErrorMessage = "author_slug_taken"
const AuthorHasArticlesErrorMessage = "author_has_articles"
const UserAlreadyLinkedErrorMessage = "user_already_linked"
const AuthorAlreadyLinkedErrorMessage = "author_already_linked"
const UserNotFoundErrorMessage = "user_not_found"
const AuthorRightsErrorMessage = "author_rights"
const BioTooLongErrorMessage = "bio_too_long"
const InvalidURLErrorMessage = "invalid_url"
const TooManyLinksErrorMessage = "too_many_links"
const InvalidSlugErrorMessage = "invalid_slug"
const AuthorImageNotFoundErrorMessage = "image_not_found"
const ArticleSlugTakenErrorMessage = "slug_taken"
const ArticleImageNotFoundErrorMessage = "image_not_found"
const InvalidTitleTemplateErrorMessage = "invalid_title_template"
const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry"
const RoleNotFoundErrorMessage = "role_not_found"
const RoleLockedErrorMessage = "role_locked"
const RoleBuiltInErrorMessage = "role_built_in"
const BuiltInNameErrorMessage = "built_in_name"
const NotBuiltInErrorMessage = "not_built_in"
const OwnRoleErrorMessage = "own_role"
const EscalationErrorMessage = "escalation"
const EscalationHeader = "X-Escalation-Key"
const RoleNameTakenErrorMessage = "role_name_taken"
const RoleConflictErrorMessage = "role_conflict"
const RoleHasMembersErrorMessage = "role_has_members"
const InvalidRoleNameErrorMessage = "invalid_role_name"
const UnknownPermissionErrorMessage = "unknown_permission"
const InvalidScopeErrorMessage = "invalid_scope"
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
const DuplicatePermissionErrorMessage = "duplicate_permission"
const UnknownContentTypeErrorMessage = "unknown_content_type"
const InvalidRoleErrorMessage = "invalid_role"
const UserRoleChangedErrorMessage = "user_role_changed"
const InvalidTransferErrorMessage = "invalid_transfer"
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
const WrongPasswordErrorCode = "wrong_password"
const SameEmailErrorCode = "same_email"
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
const EmailUnavailableErrorCode = "email_unavailable"
const LastAdminErrorCode = "last_admin"
const AccountNotDeletableErrorCode = "account_not_deletable"
const LastRoleHeader = "X-Last-Role"
+225
View File
@@ -0,0 +1,225 @@
package authtoken
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"maps"
"strings"
"time"
)
const (
SessionLifetime = 24 * time.Hour
algorithmHS256 = "HS256"
tokenType = "JWT"
maxTokenBytes = 4096
segmentCount = 3
segmentSep = "."
)
var (
ErrNoToken = errors.New("no token found")
ErrInvalid = errors.New("token is invalid")
ErrExpired = errors.New("token is expired")
ErrNotYetValid = errors.New("token is not yet valid")
ErrRevoked = errors.New("token is revoked")
)
type RevocationStore interface {
Revoke(id string, expiresAt time.Time) error
IsRevoked(id string) (bool, error)
}
type Claims map[string]any
type header struct {
Algorithm string `json:"alg"`
Type string `json:"typ,omitempty"`
}
type Auth struct {
secret []byte
clock func() time.Time
revocations RevocationStore
}
type Option func(*Auth)
func WithClock(clock func() time.Time) Option {
return func(a *Auth) { a.clock = clock }
}
func WithRevocationStore(store RevocationStore) Option {
return func(a *Auth) { a.revocations = store }
}
func New(secret []byte, options ...Option) *Auth {
auth := &Auth{secret: secret, clock: time.Now}
for _, option := range options {
option(auth)
}
return auth
}
func (a *Auth) Encode(claims map[string]any) (string, error) {
payload := maps.Clone(claims)
if payload == nil {
payload = map[string]any{}
}
now := a.clock()
if _, ok := payload["iat"]; !ok {
payload["iat"] = now.Unix()
}
if _, ok := payload["exp"]; !ok {
payload["exp"] = now.Add(SessionLifetime).Unix()
}
if _, ok := payload["jti"]; !ok {
payload["jti"] = rand.Text()
}
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
if err != nil {
return "", err
}
payloadSegment, err := encodeSegment(payload)
if err != nil {
return "", err
}
signingInput := headerSegment + segmentSep + payloadSegment
return signingInput + segmentSep + a.sign(signingInput), nil
}
func (a *Auth) Decode(token string) (Claims, error) {
if token == "" {
return nil, ErrNoToken
}
if len(token) > maxTokenBytes {
return nil, ErrInvalid
}
segments := strings.Split(token, segmentSep)
if len(segments) != segmentCount {
return nil, ErrInvalid
}
var tokenHeader header
if err := decodeSegment(segments[0], &tokenHeader); err != nil || tokenHeader.Algorithm != algorithmHS256 {
return nil, ErrInvalid
}
signingInput := segments[0] + segmentSep + segments[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(segments[2])) {
return nil, ErrInvalid
}
var claims Claims
if err := decodeSegment(segments[1], &claims); err != nil || claims == nil {
return nil, ErrInvalid
}
if err := a.validateTimes(claims); err != nil {
return nil, err
}
if err := a.checkRevocation(claims); err != nil {
return nil, err
}
return claims, nil
}
func (a *Auth) Revoke(claims Claims) error {
id, ok := claims["jti"].(string)
if !ok || a.revocations == nil {
return nil
}
expiry, present, err := numericDate(claims, "exp")
if err != nil || !present {
return ErrInvalid
}
return a.revocations.Revoke(id, expiry)
}
func (a *Auth) checkRevocation(claims Claims) error {
raw, present := claims["jti"]
if !present {
return nil
}
id, ok := raw.(string)
if !ok {
return ErrInvalid
}
if a.revocations == nil {
return nil
}
revoked, err := a.revocations.IsRevoked(id)
if err != nil {
return errors.Join(ErrInvalid, err)
}
if revoked {
return ErrRevoked
}
return nil
}
func (a *Auth) validateTimes(claims Claims) error {
now := a.clock()
expiry, present, err := numericDate(claims, "exp")
if err != nil {
return err
}
if !present {
return ErrInvalid
}
if !now.Before(expiry) {
return ErrExpired
}
notBefore, present, err := numericDate(claims, "nbf")
if err != nil {
return err
}
if present && now.Before(notBefore) {
return ErrNotYetValid
}
return nil
}
func numericDate(claims Claims, name string) (time.Time, bool, error) {
raw, present := claims[name]
if !present {
return time.Time{}, false, nil
}
seconds, ok := raw.(float64)
if !ok {
return time.Time{}, false, ErrInvalid
}
return time.Unix(int64(seconds), 0), true, nil
}
func (a *Auth) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encodeSegment(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func decodeSegment(segment string, into any) error {
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(decoded, into)
}
+19
View File
@@ -0,0 +1,19 @@
package authtoken
import (
"net/http"
"time"
)
func ExpiredCookie(secure bool) *http.Cookie {
return &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
}
}
+79
View File
@@ -0,0 +1,79 @@
package authtoken
import (
"RenewCMS/api/pkg/apperr"
"context"
"net/http"
"strings"
)
const (
CookieName = "jwt"
bearerPrefix = "bearer "
)
type contextKey struct{}
type verdict struct {
claims Claims
err error
}
func Verifier(auth *Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := auth.Decode(tokenFromRequest(r))
next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), claims, err)))
})
}
}
func Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func NewContext(ctx context.Context, claims Claims, err error) context.Context {
return context.WithValue(ctx, contextKey{}, verdict{claims: claims, err: err})
}
func FromContext(ctx context.Context) (Claims, error) {
stored, ok := ctx.Value(contextKey{}).(verdict)
switch {
case !ok:
return nil, ErrNoToken
case stored.err != nil:
return nil, stored.err
case stored.claims == nil:
return nil, ErrNoToken
}
return stored.claims, nil
}
func tokenFromRequest(r *http.Request) string {
if token := tokenFromHeader(r); token != "" {
return token
}
return tokenFromCookie(r)
}
func tokenFromHeader(r *http.Request) string {
header := r.Header.Get("Authorization")
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
return ""
}
return header[len(bearerPrefix):]
}
func tokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return cookie.Value
}
+3 -4
View File
@@ -1,14 +1,13 @@
package claims
import (
"RenewCMS/api/pkg/authtoken"
"math"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
claims, err := authtoken.FromContext(r.Context())
if err != nil {
return 0, false
}
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
}
func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
claims, err := authtoken.FromContext(r.Context())
if err != nil {
return 0, false
}
+91
View File
@@ -0,0 +1,91 @@
package payload
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"time"
)
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func RoleRefOf(ref useCases.RoleRef) RoleRef {
return RoleRef(ref)
}
func RoleRefPtrOf(ref *useCases.RoleRef) *RoleRef {
if ref == nil {
return nil
}
converted := RoleRefOf(*ref)
return &converted
}
func RoleAssignerPtrOf(assigner *useCases.RoleAssigner) *RoleAssigner {
if assigner == nil {
return nil
}
converted := RoleAssigner(*assigner)
return &converted
}
func RoleViewOf(view useCases.RoleView) RoleView {
converted := RoleView{Role: view.Role, Members: view.Members, UpdatedAt: view.UpdatedAt}
if view.UpdatedBy != nil {
editor := RoleEditor(*view.UpdatedBy)
converted.UpdatedBy = &editor
}
return converted
}
func RoleViewsOf(views []useCases.RoleView) []RoleView {
converted := make([]RoleView, 0, len(views))
for _, view := range views {
converted = append(converted, RoleViewOf(view))
}
return converted
}
func OwnershipTransferOf(transfer useCases.OwnershipTransfer) OwnershipTransfer {
return OwnershipTransfer{
Owner: OwnershipSuccessor(transfer.Owner),
FormerOwner: OwnershipPredecessor{ID: transfer.FormerOwner.ID, Name: transfer.FormerOwner.Name, Role: RoleRefOf(transfer.FormerOwner.Role)},
}
}
-2
View File
@@ -13,7 +13,6 @@ type PublicUser struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
}
func New(u user.User) PublicUser {
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName,
Role: u.Role,
}
}
+21
View File
@@ -0,0 +1,21 @@
package response
import (
"encoding/json"
"log"
"net/http"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+55
View File
@@ -1,12 +1,24 @@
package validator
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"reflect"
"regexp"
"github.com/go-playground/validator/v10"
)
const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
const siteKeyTag = "sitekey"
const siteNameTag = "sitename"
const AuthorNameTag = "authorname"
const AuthorSlugTag = "authorslug"
const SocialTypeTag = "socialtype"
const ArticleSlugTag = "articleslug"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -15,6 +27,49 @@ var Validate *validator.Validate
func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
_ = Validate.RegisterValidation(siteKeyTag, isSiteKey)
_ = Validate.RegisterValidation(siteNameTag, isSiteName)
_ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true)
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
}
func isArticleSlug(field validator.FieldLevel) bool {
if isAbsent(field) {
return true
}
value := field.Field().String()
return value == "" || article.IsValidSlug(value)
}
func isAbsent(field validator.FieldLevel) bool {
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
}
func isAuthorName(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidName(field.Field().String())
}
func isSocialType(field validator.FieldLevel) bool {
return author.IsValidSocialType(field.Field().String())
}
func isAuthorSlug(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidSlug(field.Field().String())
}
func isSiteKey(field validator.FieldLevel) bool {
return site.IsValidKey(field.Field().String())
}
func isSiteName(field validator.FieldLevel) bool {
return site.IsValidName(field.Field().String())
}
func isVariantName(field validator.FieldLevel) bool {
return image.IsValidVariantName(field.Field().String())
}
func isInviteEmail(field validator.FieldLevel) bool {
+372 -72
View File
@@ -5,28 +5,42 @@ import (
"RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/author"
"RenewCMS/api/handlers/builtintype"
"RenewCMS/api/handlers/contenttype"
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
"RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/llmdocs"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"
)
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
if environment == "development" {
r.Use(httplog.LoggerWithName(routerName))
r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
}))
}
}
@@ -38,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
const (
apiKeyMaxRequests = 120
apiKeyWindow = time.Minute
emailChangeMaxRequests = 5
emailChangeWindow = 15 * time.Minute
dataExportWindow = 15 * time.Minute
accountDeleteMaxRequests = 5
accountDeleteWindow = 15 * time.Minute
)
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
@@ -47,39 +69,65 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware
}
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func (c *UseCases) requireTyped(key string, typeKey func(*http.Request) string) func(http.Handler) http.Handler {
return middleware.RequireTypedPermission(c.GetUserUseCase, c.CheckPermissionUseCase, key, typeKey)
}
func (c *UseCases) requireArticle(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.ArticleTypeKey)
}
func (c *UseCases) requireEntry(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.PathTypeKey)
}
func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler {
return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Get("/{id}/seo", seoHandler.Resolve)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check)
})
return r
}
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register)
@@ -104,44 +152,111 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Delete("/email/change", emailChangeHandler.Cancel)
r.Post("/email/confirm", emailChangeHandler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
r.Post("/email/change", emailChangeHandler.Request)
r.Post("/email/resend", emailChangeHandler.Resend)
})
})
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me)
})
return r
}
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
r.Use(jwtauth.Verifier(container.Token))
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersInvite))
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
})
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
r.Delete("/me", deleteAccountHandler.Delete)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersChangeRole))
r.Put("/{id}/role", roleHandler.Change)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersRemove))
r.Delete("/{id}", removeHandler.Remove)
})
return r
}
@@ -151,45 +266,186 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsView))
r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemEditRetention))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
})
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesView))
r.Get("/", roleHandler.List)
r.Get("/{id}", roleHandler.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesManage))
r.Post("/", roleHandler.Create)
r.Put("/{id}", roleHandler.Update)
r.Delete("/{id}", roleHandler.Delete)
r.Post("/{id}/reassign", roleHandler.Reassign)
r.Post("/{id}/reset", roleHandler.Reset)
})
return r
}
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyAuthorsView))
r.Get("/", authorHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine)
r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update)
r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create)
r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete)
})
return r
}
func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
contentTypeHandler := contenttype.NewHandler(container.CreateContentTypeUseCase, container.ListContentTypesUseCase, container.GetContentTypeUseCase, container.UpdateContentTypeUseCase, container.DeleteContentTypeUseCase)
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List)
r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get)
r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create)
r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update)
r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List)
r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get)
r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update)
r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete)
return r
}
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySchemaView))
r.Get("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get)
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
return r
}
@@ -203,27 +459,27 @@ const (
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus)
r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(container.require(role.KeySystemApplyUpdates))
r.Post("/update/restart", updateHandler.Restart)
})
return r
@@ -235,41 +491,83 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke)
r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke)
return r
}
func NewPermissionRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", permissionHandler.List)
return r
}
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
r.Use(middleware.RejectAPIKey)
r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}/usage", mediaHandler.Usage)
r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
return r
}
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
return r
}
@@ -282,14 +580,21 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(middleware.JsonContentTypeMiddleware)
r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/permission", NewPermissionRouter(container))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
@@ -315,14 +620,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
if len(origins) == 0 {
return
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
r.Use(middleware.CORS(origins))
}
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
@@ -332,10 +630,12 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"RenewCMS/internal/infrastructure/mcpserver"
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var version = "dev"
func main() {
allowDestructive := flag.Bool("allow-destructive", false, "also expose delete_article and delete_media")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
baseURL := os.Getenv("RENEWCMS_URL")
apiKey := os.Getenv("RENEWCMS_API_KEY")
if baseURL == "" || apiKey == "" {
log.Fatal("RENEWCMS_URL and RENEWCMS_API_KEY must be set")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := mcpserver.New(mcpserver.NewClient(baseURL, apiKey, nil), mcpserver.Options{
Version: version,
AllowDestructive: *allowDestructive,
})
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
+10 -3
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"net"
"net/http"
@@ -15,8 +16,6 @@ import (
"slices"
"syscall"
"time"
"github.com/joho/godotenv"
)
var version = "dev"
@@ -59,8 +58,12 @@ var envDefaults = map[string]string{
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure,
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
}
const versionFlag = "--version"
@@ -103,9 +106,13 @@ func initEnvVariables() {
func loadEnvFile() {
for _, envLocation := range possibleEnvFileLocations {
if godotenv.Load(envLocation) == nil {
err := config.LoadDotenv(envLocation)
if err == nil {
return
}
if !errors.Is(err, fs.ErrNotExist) {
log.Println("WARNING: Could not parse env file:", err)
}
}
log.Println("WARNING: Could not load any .env file")
}
+538 -20
View File
@@ -2,30 +2,41 @@
The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI.
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
articles only.
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once.
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
or revoked key answers 401, even on public routes.
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie
stops working at once; other sessions of the same user stay valid. A password reset ends every
session and revokes every key of that user.
- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (20)
## Routes a key can call (30)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ |
## Activity log
The activity routes are session only: a key answers 403 "This action needs a real session." and a
@@ -34,20 +45,23 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
90), `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}` is session only and owner only (403 "Only the owner can do this.");
any other value answers 400.
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup
removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins.
### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
@@ -59,16 +73,27 @@ never changes the response or the change itself.
| Category | Actions |
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`,
`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed;
`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target
and are recorded only when files were generated or removed;
`media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and
`purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays
`success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded
when no file was removed or failed;
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
@@ -84,12 +109,505 @@ try again."; the text of the error never reaches the event, the API or the CSV.
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
`q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`,
`largest` or `total_size`, the original plus its generated variants), `variants` (`with` or
`without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50
or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is
the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size`
echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring
every filter and the paging. Ordering is applied to the whole matching list before slicing, with the
image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200
with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
(the index rows of the image and their bytes, 0 when none).
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
the id-format message.
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
are no longer parameters; they are ignored, never an error, and have no effect on paging.
## Sites
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
session." with a key. The role is checked before the body.
| Route | Purpose |
| --- | --- |
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
### Resolved SEO, check, list status and sitemap
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
### Article SEO fields
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
kept. `noindex` is only a flag, it does not take the article offline.
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
frees its slug.
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
the free-text fields as a size delta only); a refused slug is recorded as failed.
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
`sites_updated` next to `articles_updated`.
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
`articles_updated`.
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
## Authors
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins
only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches
`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when
omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has
at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null`
or an `https` URL with a host (an empty string means none, at most 2048 characters),
`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`,
`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an
article is allowed and never blocks publishing.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number,
negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422
`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field
is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other
validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a
reason other than a missing target or an invalid value are recorded as failed events.
Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`,
`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it
is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes
`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404
`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and
`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the
creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus
`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`,
`social_links`, `article_count`) or `null`.
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
repoint the avatars of the merged copies and report it as `authors_updated`.
## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose |
| --- | --- |
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
| `GET /v1/image-variants/cache` | Cache statistics, see below. |
| `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. |
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
field (name, width and height, fit, format, quality).
### Cache statistics and purge by scope
`GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined,
variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes,
days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`.
`by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten
images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused`
counts the files of enabled variants whose reference time (the last public request, or the creation
time when never requested) is older than 30 days. `rejected_requests` counts the public requests
refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before
the read. Arrays are never `null`.
`POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image",
"variant": "<name>", "image_id": <id> }` (`variant` for the scope `variant`, `image_id` for `image`; a
target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes,
failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the
failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that
is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image
id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows
cannot be listed.
### Variants of one image
Owners and admins only, session only: a request with an API key is 401. All ids are image ids.
| Route | Purpose |
| --- | --- |
| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. |
| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. |
| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. |
| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. |
An item is `{ name, width, height, fit, format, output_width, output_height, output_format,
generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the
size and format the file has or will have (never above the source), `generated` is true only when
the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is
`/static/uploadedImages/<stored name>?v=<name>`. A `cause` is `too_large`, `unsupported_format` or
`failed`. A file generated here is the same file the public url serves, and the other way round.
Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only)
before answering; a failure there is logged and never fails the upload.
Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a
disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
this format." and 500 "Internal server error".
## Roles
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
| Route | Permission | Purpose |
| --- | --- | --- |
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
| `GET /v1/role/{id}` | `roles.view` | One role. |
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
Refusals are plain text, one snake_case code per body:
| Status | Body | When |
| --- | --- | --- |
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
| 409 | `target_role_not_found` | `POST /v1/role/{id}/reassign` when the target role does not exist (any longer). |
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, 0, a locked role, a role the caller cannot cover). |
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
| 404 | `role_not_found` | Unknown role id. |
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to`, `to_name` (the destination role's name at move time; absent on older events) and `moved` (users and open invites together) and is recorded only when
`moved` is above 0. A refusal records nothing.
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key.
## Changing the email address
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
`You must verify your account to access this resource.`
| Route | Body | Success |
| --- | --- | --- |
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
| `DELETE /v1/auth/email/change` | none | 204, no body |
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
link works once.
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
(`new_email` must be a full address, `current_password` and `code` are required).
A request for an address that another user already holds answers exactly like a success, stores the pending change and
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
`Retry-After` header. Cancel and confirm are not limited.
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
`user.request_email_change` with the reason `Wrong password.`
## Exporting your own data
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
built in memory first, so a failure is a plain text 500 and never a half file.
| File | Content |
| --- | --- |
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
one `user.export_data` audit event (category `users`, not flagged `security`).
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
## Deleting your own account
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
which stays the admin action and is unchanged.
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
`DELETE` (case-sensitive, no spaces). The checks run in this order:
| Answer | Body | When |
| --- | --- | --- |
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
| 403 | `wrong_password` | the password is not the account's |
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
| 500 | "Internal server error" | the removal failed; everything is rolled back |
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
## Users, invites and ownership
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`.
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
is read from the current record of that user at read time (`display_name` when set, else `username`), so a rename shows
at once. When that record is gone the whole object is `null`. It is set by `PUT /v1/user/{id}/role` to the caller (a
request with the user's current role writes nothing), by invite accept to the inviter (`NULL` when the inviter record is
missing), and by the ownership transfer: the new Owner is assigned by the former Owner, whose own value becomes `NULL`.
First-user registration and `POST /v1/role/{id}/reassign` leave it alone. The key is always present on accepted-user
items of `GET /v1/user` (gate `users.view`, unchanged; assigners are resolved from the users already loaded) and on
`access` of `GET /v1/auth/me` (own data, one extra lookup at most). Invite items never carry it, and `PublicUser` and the
login, verify, register and invite-accept bodies do not change.
| Route | Permission | Session only | Purpose |
| --- | --- | --- | --- |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
| `DELETE /v1/user/{id}?expected_role_id=N` | `users.remove` | yes | Removes an accepted user (204, no body). `expected_role_id` is required (a whole number above 0, else 400 `invalid_role`) and must be the id of the role the target still holds: a compare-and-swap (`DeleteIfRole`), 409 `user_role_changed` otherwise. Order of checks: session, permission, id, `expected_role_id`, target (404 `user_not_found`), target is the caller (403 `own_role`), stale role (409), target role locked (403 `role_locked`), target role covered (403 `escalation`), delete. The removed user's cookie sessions and API keys answer 401 afterwards; articles, entries, media, invites and audit events they created stay. Records `user.remove` (category `users`, target the user); a refusal records nothing and a storage failure records it with outcome `failed`. Invites are cancelled with `DELETE /v1/user/invite/{id}`. |
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
by the new grants. An unverified user may be given a role.
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
| Status | Body | When |
| --- | --- | --- |
| 409 | the human message | `POST /v1/user/invite` only. "This address already has an account." carries `X-Error-Code: email_has_account`; "There's already a pending invite for this address." carries `X-Error-Code: invite_pending`. |
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
| 403 | `own_role` | The target of a role change is the caller's own account. |
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
| 409 | `user_role_changed` | `DELETE /v1/user/{id}` as well: the target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
under two simultaneous transfers (one answers 200, the other 409).
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
`GET /v1/auth/me`; it is never promoted.
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
## Content permissions
Articles and the entries of custom content types are checked against the `content.*` permissions of the
caller's role, which is read on every request (a key follows its creator's role).
| Route | Permission | Scope check after the lookup |
| --- | --- | --- |
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
| `DELETE /v1/article/{id}` | `content.delete` | same |
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller |
| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller |
Each action reads its own permission: publish does not imply update, update does not imply read. `own`
means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission
can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that
row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the
wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`.
A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`,
never `content.update:blog`). The permission is checked before the id and the body are read, then the scope
after the lookup, and a refused call writes nothing and records no activity event. The verification and
token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media`
or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the
same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it.
## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
every request, so an API key follows the current role of its creator.
## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
- Browsers can call the API only from origins in the server's CORS setting.
+125 -1
View File
@@ -2,6 +2,8 @@
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
`PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
## Routes that honour `format`
- `GET /v1/article/`
@@ -11,7 +13,41 @@ Every route that returns an article accepts the optional `format` query paramete
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
Anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. A verified caller also sees the drafts that `content.read` covers: every draft at `all`, only their own at `own`, and none without the permission. The `all`, `online` and `draft` counts follow the same visible set.
## Pagination, search and counts
`GET /v1/article/` always answers one page inside an envelope, with or without query parameters:
```json
{
"items": [],
"total": 30,
"page": 1,
"size": 25,
"counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 }
}
```
| Parameter | Values | Default |
| --- | --- | --- |
| `page` | integer >= 1 | `1` |
| `size` | `5`, `25`, `50` or `100` | `25` |
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
| `status` | `online` or `draft` | none |
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
| `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, is `400` | none |
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
- `items` is `[]` when empty, never `null`. Each item is the article object below.
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
- A page beyond the last answers `200` with `items: []` and the real `total`.
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
Breaking change: this route used to answer a bare JSON array. Clients must read the `items` property.
## The `format` parameter
@@ -35,11 +71,96 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"is_online": true,
"site_id": 1,
"created_by": 1,
"authors": [],
"slug": "backyard-renovation-update",
"excerpt": "",
"seo_title": "",
"seo_description": "",
"cover_image_id": null,
"cover_image_alt": "",
"social_title": "",
"social_description": "",
"social_image_id": null,
"canonical_url": "",
"noindex": false,
"seo_dismissed": [],
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
## SEO fields
| Field | Type | Limit and rule |
| --- | --- | --- |
| `slug` | string | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1 to 100 characters, unique per site. `""` regenerates it from the title. |
| `excerpt` | string | 300 characters |
| `seo_title` | string | 120 characters |
| `seo_description` | string | 320 characters |
| `cover_image_id` | number or `null` | an existing image id (`0` is refused) |
| `cover_image_alt` | string | 200 characters, independent of the cover |
| `social_title` | string | 120 characters |
| `social_description` | string | 320 characters |
| `social_image_id` | number or `null` | an existing image id (`0` is refused) |
| `canonical_url` | string | `""` or an absolute `https` URL with a host, at most 2048 characters |
| `noindex` | boolean | only a flag: the article stays online |
| `seo_dismissed` | array of strings | any of `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`; duplicates are collapsed, order kept |
Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and `noindex` are cleared with `""`, `false` and `[]`, the two image ids with `null`.
### The slug
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<slug>-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.
Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug.
## Resolved SEO object
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`).
The fallback chain, first non-empty wins:
| Output | Chain | `fallbacks` value |
| --- | --- | --- |
| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` |
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` |
| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` |
| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` |
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none |
`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
## SEO status and suggestions
`POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters.
| Code | Field | Fires when | `params` |
| --- | --- | --- | --- |
| `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` |
| `title_empty` | `seo_title` | the meta title is empty after trimming | none |
| `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` |
| `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` |
| `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none |
| `cover_none` | `cover_image_id` | there is no cover | none |
| `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` |
| `no_au` | `author_ids` | no author is credited | none |
| `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none |
| `body_alt` | `body` | an image of the body has no alt text | `count` |
| `short` | `body` | the text has fewer than 300 characters | `length`, `min` |
Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article).
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
@@ -93,5 +214,8 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code |
| `404` | `Article not found` |
| `409` | `slug_taken`, with the header `X-Slug-Suggestion: <first free slug>` (this response only) |
| `422` | `invalid_url` for a `canonical_url` that is not an absolute `https` URL with a host |
| `500` | `Internal server error` when the body cannot be converted |
+51 -2
View File
@@ -15,7 +15,7 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
@@ -27,7 +27,8 @@ Nothing under `internal/` imports `api/`.
| Package | Role |
|---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). |
| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
@@ -55,6 +56,14 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
### Sessions and personal data retention
A session is an HS256 token from `api/pkg/authtoken`. `Encode` adds a random `jti` and an `exp` 24 hours ahead; `Decode` refuses a token without `exp`. `authtoken.WithRevocationStore` plugs in `useCases.SessionRevocationUseCase`, which stores a `session.Revocation` (`jti`, `exp`) through the `internal/domain/session` `Repository` (`persistence.RevokedSessionRepository`, table `revoked_sessions`). `LoginHandler.Logout` revokes the presented token; from then on `Decode` answers `ErrRevoked` for it, and a store that cannot be read makes every token invalid rather than valid. Revoking every session of a user stays the job of `token_version`, bumped by a password reset.
`useCases.EnforceRetentionUseCase.Tick(now)` deletes revocations past their expiry, unverified accounts 7 days after their code expired (`user.UnverifiedRetention`) and invites 30 days after they expired or were accepted (`invite.Retention`), and returns a `retention.Result`. `scheduler.NewSystemRetentionRunner` calls it one minute after startup and then hourly, logs one line only when something was removed, and stops with the other runners on shutdown.
Uploads go through the `image.MetadataStripper` port before they are measured for size, hashed and stored. `imaging.MetadataStripper` walks the JPEG segments, PNG chunks and WebP RIFF chunks without re-encoding pixels, drops metadata, keeps colour profiles and re-adds an orientation-only EXIF block when the photo was rotated. A container it cannot walk fails the upload with `image.ErrImageUnreadable`.
### Activity log
`internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it.
@@ -69,10 +78,50 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Sites
`internal/domain/site` holds the `Site` entity (name, immutable key, normalised base URL, optional default social image, `index` or `noindex` robots, optional locale, the default flag and the computed `article_count`), `NormalizeBaseURL`, `SeedBaseURL`, `NormalizeLocale`, `IsValidKey`, the `Patch` type (with `Nullable` for the two nullable fields), the `Err...` sentinels (`ErrSiteNotFound`, `ErrSiteKeyTaken`, `ErrSiteHasArticles`, `ErrDefaultSite`, `ErrKeyImmutable`, `ErrInvalidSiteURL`, `ErrInvalidLocale`, `ErrUseSetDefault`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.SiteRepository` implements it over `models.Site` (`sites`, unique `site_key`); the single default is kept by the transactional operations `Create` (when flagged) and `SetDefault`, not by an index, and `DeleteMovingArticles` moves the articles and deletes the site in one transaction. Articles carry a `site_id` (indexed, no foreign key, so the SQL stays portable).
The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `DeleteSite` live in `internal/infrastructure/useCases`; the article, media and purge use cases take the site repository as a required constructor argument, wired by the composition root (new articles take the default site, `UpdateArticle` moves an article and records `site` with site names, the media use cases count, clear and repoint default social images). `api/handlers/site` serves `/v1/site`; reads accept a session or an API key, writes are session only behind `RequireOwner`. `database.Migrate(db, publicURL)` registers the model, seeds one default site from `PUBLIC_URL` when the table is empty and backfills `articles.site_id`.
### Authors
`internal/domain/author` holds the `Author` entity (name, unique slug, bio, optional avatar, optional website, social links, the unique optional `user_id`, `is_linked`, the computed `article_count` and a `user_email` that only owner and admin responses carry), the public `Byline` shape embedded in articles, the `Patch` type, the slug generation (a hand-written Latin diacritic folding table, no external dependency), the value rules (`ValidateBio`, `ValidateURL`, `ValidateLinks`, `IsValidSlug`, `IsValidName`), the `Err...` sentinels (`ErrAuthorNotFound`, `ErrSlugTaken`, `ErrAuthorHasArticles`, `ErrUserAlreadyLinked`, `ErrAuthorAlreadyLinked`, `ErrAuthorRights`, `ErrBioTooLong`, `ErrInvalidURL`, `ErrTooManyLinks`, `ErrInvalidSlug`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.AuthorRepository` implements it over `models.Author` (`authors`, unique `slug` and unique nullable `user_id`) and the ordered join `models.ArticleAuthor` (`article_authors`, unique `(article_id, author_id)` and a `position`); uniqueness is checked inside the transaction and re-explained after a failed write, so a race still answers a domain sentinel, and `DeleteMovingCredits` moves the credits (keeping the position, never duplicating) and deletes the author in one transaction. `ArticleRepository` attaches the ordered `Byline`s to the articles it returns, writes `author_ids` in the article update transaction and removes the credits when an article is deleted.
The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `DeleteAuthor` and `GetMyAuthor` live in `internal/infrastructure/useCases`; `CreateArticle` credits the creator's author, `UpdateArticle` validates and records `author_ids`, and the media, purge and activity use cases take the author repository as a required constructor argument (avatars count as media usage, are cleared on delete and repointed on merge and purge). `api/handlers/author` serves `/v1/author`: the list accepts a session or an API key, every write is session only, `POST /` and `DELETE` sit behind `RequireOwnerOrAdmin`, and the edit rights (owner or admin, or the caller's own linked author) are decided by `UpdateAuthor` from the caller the handler passes in. `GET /v1/auth/me` adds the linked author through `GetMyAuthor`.
### Article SEO data
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug, in id order, per site, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under an in-process single-flight group keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
### Image metadata backfill
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers`, `RemoveTypedGrants` and `MemberCounts` (users grouped by `role_id` plus invites with `accepted_at` NULL grouped by `role_id`, two statements for any number of roles); no role use case scans `user.Repository.GetAll`. `user.Repository` gains `UpdateRole(id, from, to)`, a compare-and-swap on the role the use case read (`ErrRoleChanged`, `ErrUserNotFound`, `role.ErrRoleNotFound`), and `TransferOwnership(from, to, ownerRoleID, formerRoleID)`, which updates both rows in one transaction and fails whole when `from` no longer holds the Owner role (`ErrRoleChanged`) or `to` is missing or unverified (`ErrInvalidTransfer`). `invite.Invite` carries `RoleID`. `ChangeUserRoleUseCase`, `TransferOwnershipUseCase` and the amended invite use cases find the built-in roles with one helper, `builtInRole(roles, name)` (`GetByName`, then `built_in` must be true), and a user's role with `resolveRole`, which reads `role_id` only. `Delete` re-counts users and open invites inside its transaction and `ReassignMembers` moves users and open invites in one transaction and returns their sum. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (a user whose `role_id` is 0 resolves to `ErrRoleNotFound`, an empty grant set), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
Created-by backfill: `database.BackfillCreatedBy` assigns articles (soft-deleted ones included) and entries whose `created_by` is 0 to the first owner by id. A database without an owner is left alone. Entries have no soft delete, so no unscoped query is needed for them.
## Frontend ↔ backend
```
+9 -5
View File
@@ -1,6 +1,6 @@
# Configuration
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`.
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. The parser reads `NAME=value` lines, an optional `export ` prefix, `#` comments (inline ones need a space before the `#`), and single- or double-quoted values (`\n`, `\t`, `\"` and `\\` are unescaped in double quotes). It does not expand `$VARIABLES` and does not support multi-line values. A malformed line makes the whole file ignored, and the server logs the line number.
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
@@ -10,8 +10,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| Name | Description | Example |
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. It must be at least 32 bytes, and the example values from `.env.example` and `docker.env.example` are refused: the server stops at startup with `JWT_SECRET is too weak` and how to generate one. Changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | |
@@ -29,11 +29,15 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `SMTP_ALLOW_INSECURE` | `false` | `true` lets RenewCMS deliver mail without encryption to a server on another host that does not offer STARTTLS (a relay on a private network, a local Mailpit container). By default such delivery fails with an error naming this variable; port `465` always uses implicit TLS and `localhost`, `127.0.0.1` and `::1` are always allowed in clear text. Credentials (`SMTP_LOGIN`/`SMTP_PASSWORD`) are never sent unencrypted to another host, whatever this says. Any value other than `true` or `false` (case-insensitive) stops the server at boot. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_SOURCE_PIXELS` | `50000000` | Largest declared width x height, in pixels, that an [image variant](image-variants.md) may be generated from. It is read from the image header before any pixel buffer is allocated; a bigger source answers 413 with the same plain-text body as the byte cap. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` | `4` | Number of [image variant](image-variants.md) renders that may run at once. Other requests wait for a free slot until their client goes away; cached files never wait. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
@@ -105,7 +109,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Activity log retention
The activity log needs no environment variable. Its retention period (30, 90, 365 days or forever, 90 by default) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
The activity log needs no environment variable. Its retention (an age of 30, 90, 365 days or forever, 90 by default, and an optional maximum size in GB, none by default; whichever limit is reached first removes the oldest events) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable
+20
View File
@@ -0,0 +1,20 @@
# Custom content types
A content type is a named schema (a key, a name and 1 to 50 fields) stored as one row with the fields as a JSON text column. Entries of every type share one generic table whose `data` column holds a JSON object. No DDL runs at runtime, so creating or changing a type never alters the database schema.
## Icon, built-in types and user lookup
- A content type has an optional `icon`: `null` or a string matching `^[a-z][a-z0-9-]{0,39}$`, validated in the domain (`ErrInvalidIcon`, `400 invalid_icon`) and never checked against a list of known icons. `""` is stored as `null`. `PUT` replaces the icon like the name and the fields, so an absent icon clears it. The column is nullable, so rows stored before it existed read as `null`.
- `GET /v1/builtin-type` and `GET /v1/builtin-type/{key}` expose a static, read-only catalogue (`internal/domain/builtintype`) of the built-in types `article`, `author`, `site`, `media` and `user` as `key`, `name` and `fields` shaped like content type fields. Owners and admins only. It uses no repository; an unknown key is `404 builtin_type_not_found`. A test checks that every catalogue field key exists in the JSON of the matching entity.
- `GET /v1/user/lookup?ids=1,2,3` returns `[{id, display_name}]` for any verified user, ordered by id, with the username when the display name is empty. 1 to 100 distinct uint32 ids; anything else is `400`. Unknown ids are omitted.
## Layering
The domain packages `internal/domain/contenttype` and `internal/domain/entry` own the rules: key, name and field validation, entry validation against a schema, the sort whitelist, the page limits and the value equality used by uniqueness. The use cases orchestrate them through the two repositories. The repositories only store and load rows. Entry counting for the "type still has entries" rule lives on the entry repository.
## Known limitations of phase 1
- Uniqueness and schema re-validation are O(n). Checking a unique field loads every entry of the type and compares values in Go, and updating a type with stored entries loads all of them to re-validate. This keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL. The path to fix it is the filterable-fields phase: project the values of unique and filterable fields into an indexed table, so uniqueness becomes an indexed lookup and re-validation can be restricted to the changed fields.
- Unique values are checked without a transaction, so two concurrent writes of the same unique value can both succeed. A content type key is protected by a unique index, and a violation is reported as `content_type_key_taken` (409).
- `entry.Repository.DeleteByContentType` is intentionally unused for now. A content type that still has entries cannot be deleted; the method is kept as part of the repository contract for a later cascade.
- Listing is limited to page 1,000,000 and to page sizes of 100 so that the SQL offset cannot overflow.
+1 -1
View File
@@ -274,7 +274,7 @@ Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
The CSP serves fonts from `'self'` only (the SPA bundles its fonts, so no visitor IP address is sent to a font CDN) and allows `https:` images, because articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Using MySQL or MariaDB
+148 -44
View File
@@ -1,52 +1,156 @@
# RenewCMS design
# RenewCMS design pass 2
The visual spec of record for the back-office SPA and the transactional emails, exported from Claude Design. `features/` stays the behavioural spec; if the two disagree, stop and ask. Agents read this folder and never edit it; a design change is a new export.
Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
## Files
## Boards
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states).
- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer).
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
| File | What it holds |
## Pass 3: sites and authors (revised)
Roles, decisions and the slice order are in `sites-authors-slices.md` (Sites and Authors) and `seo-slices.md` (SEO).
Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: form, Authors: delete, Editor: site and authors, Profile: author profile, Articles: sites and authors. Plus tables: decisions to confirm (13 rows, 5 marked Decided), deviations, API and errors (with the 403 rows), components, accessibility, copy per state.
- Roles: only creating, editing, deleting and setting the default site are owner only (no sidebar entry or Publishing card for others, 403 page on a direct link). Anyone who can edit an article can set its site and read the site list. Authors: owner and admin manage; other roles read-only, and any role can create and edit their own author (user link locked, no delete).
- The site key is immutable: read-only with a lock in the edit form; the warning state and dialog are gone.
- The "Author profile" card is drawn in every existing Profile frame. The "Site and authors" section shows collapsed in every existing Editor frame that has the details sidebar (39 already did; the Loading frame now shows the header with a skeleton summary). The Not found frame has no editor, and on mobile the section is inside the Details sheet. The dedicated groups keep only the feature-specific states.
- Shared pieces (list, slide-over, delete-with-reassign dialog, slug field, media field) are defined once in `DirectoryFrame`, used by Sites and Authors. The Profile card is defined once in `AppFrame`.
- States trimmed from 87 to 68 (Sites 9, Sites: form 7, Sites: delete 3, Authors 8, Authors: form 11, Authors: delete 6, Editor: site and authors 16, Profile: author profile 5, Articles: sites and authors 3). Dropped: in-progress and done variants (covered by the button loading pattern and the confirm state's trigger), per-error URL variants, the site picker (same picker as the avatar), manual slug override, long names (merged into Many authors), per-field duplicates. The blocked-delete dialog is one frame, "Server fallback (409)".
- Only individual states that render a relative time or a countdown carry the "Clock-sensitive" tag (13 states, listed in states.md). No Home or Articles frame is tagged as a whole.
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
## Pass 4: custom content types
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 99 states. 1 Schema list 7, 2 Schema builder 19, 3 Review and delete 9, 4 Field types 7, 5 Content and entry list 12, 6 Entry editor 23, 7 Relations 5, 8 Permissions 2, 9 Export and import 3, 10 Media picker for a field 8, 11 Media library usage 4. The Media field type (pass 5) is specified in `media-field-changes.md`; the exact copy and measurements are there, and the boards are the visual reference. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md, media.md, media-picker.md, and media-field-changes.md for the Media field type.
- AppFrame gains a Schema item (owner and admin) and a Content group (sidebar and mobile drawer). Nothing else in AppFrame changed.
- Backend gaps still open: built-in types and fields, display names for created_by and updated_by, a type icon, per-change entry counts.
- Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
## Specs
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
## Status
| Section | Status |
|---|---|
| `routes.md` | Every route: layout, auth requirement, page component, API calls |
| `components.md` | The component inventory with its Nuxt UI mapping, props, emits and slots, then the full component and primitive specs |
| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table |
| `screens/<screen>.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts |
| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are |
| `emails.md` | The three email templates and their variables |
| `source/` | The raw Claude Design export. Open the `.dc.html` boards in a browser for the drawings (they load `support.js` locally and fonts from Google). |
| A1 Activity export | Done: CSV only, synchronous; progress state removed; JSON proposed |
| A2 Pagination `page`/`size` | Done in frames, routes.md, change-list.md |
| A3 Unknown variant and unsupported parameter | Done: both 400 bodies in the Responses table |
| A4 New tokens | Done: foundations.md table |
| A5 screens/*.md | Done: 22 files, copy generated from the board source |
| B1 Editor link | Done: 11 states |
| B2 Duplicates | Done for sidebar, topbar, drawer, code block, modal geometry; see Not done |
| B3 CodeSnippet | Done |
| B4 Overlays | Done |
| B5 Prototype | Done |
| B6 Contrast and states matrix | Done for the pairs listed; matrix drawn |
| B7 Consistency sweep | Done for the roles listed |
| B8 Copy glossary | Done by search; not every line re-read |
| B9 Media and picker | Badge added; Media pagination drawn (9 states); filter, sort and size not folded in |
| B10 "proposed" labels | Only article pagination and JSON export |
## Screens
## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 413)
| Group | States |
|---|---|
| Boot | 1 |
| Home | 10 |
| Articles | 14 |
| Articles: pagination | 11 |
| New article | 5 |
| Editor | 41 |
| Media | 56 |
| Media picker | 13 |
| Integration | 8 |
| Activity | 9 |
| Activity: filter cards | 10 |
| Activity: export | 15 |
| Activity details | 6 |
| My activity | 3 |
| Users | 17 |
| Settings | 25 |
| Settings: Media cleanup | 9 |
| Purge dialog | 8 |
| Purge results | 2 |
| Media duplicates entry | 2 |
| Settings: Retention | 6 |
| Profile | 13 |
| Global | 4 |
| Integration: images | 9 |
| Settings navigation | 5 |
| Variants list | 12 |
| Add edit variant | 11 |
| Media library (variants) | 6 |
| Image detail | 8 |
| Cache panel | 10 |
| Editor picker | 5 |
| Screen | Route | States | Board |
|---|---|---|---|
| [Log in](screens/log-in.md) | `/login` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Register](screens/register.md) | `/register` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Pending](screens/pending.md) | `/register/pending` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Verify](screens/verify.md) | `/register/validate?c=CODE` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Forgot password](screens/forgot-password.md) | `/register/reset/request` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Reset password](screens/reset-password.md) | `/register/reset/validate?c=CODE&email=EMAIL` | 7 | `source/RenewCMS Screens Auth.dc.html` |
| [Boot](screens/boot.md) | `any route, first load` | 1 | `source/RenewCMS Screens Back-office.dc.html` |
| [Home](screens/home.md) | `/home` | 6 | `source/RenewCMS Screens Back-office.dc.html` |
| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` |
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) + 4 (Media: used elsewhere) = 413. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, Accept invite 10, Forgot password 6, Reset password 7). API keys board 12. Total 481 states; every one has a desktop and a mobile frame, and the theme switch gives light and dark. The matrix has 24 control rows, 5 states on desktop and 4 on mobile (no hover). screens/*.md list the states of the pass 2 groups and the 68 pass 3 states.
## Other boards
## Fixes applied (pass 2)
1. Activity export dialog: removed the 42% progress bar, the "large export" state, the server-job copy and the "ready later" toast; added "Preparing download…" (indeterminate), "Download started", failed, nothing to export, 403; CSV only, with one proposed JSON state.
2. Export dialog and menu copy now match the implemented endpoint (current view with filters, all with optional range).
3. Articles pagination used `per_page`, sizes 10/25/50, default 10, 32 pages. Now `page`/`size`, 5/25/50/100, default 25, 13 pages; frames, route, URL examples and the change list updated.
4. The Integration responses table now shows both 400 bodies, `unknown_variant` and `unsupported_parameter`.
5. `--rc-sand` and `--rc-on-sand` (and the other `--rc-*` tokens) documented as new tokens with light and dark values and their uses.
6. Raw colours removed from frames: crop handles and mask, tooltip, transparency checker, drag-drop text, selected-row text in MediaFrame, hex in the Prototype. Only the brand mark keeps hex.
7. ActivityFrame, MediaFrame and VariantsFrame sidebars, topbars and (Variants) mobile drawer deleted; they render inside AppFrame. Activity now follows the sidebar order (Users, Activity, Integration, Settings).
8. Prototype's own mobile drawer (Home, Articles, Users, Integration only) replaced by AppFrame's drawer.
9. All code blocks on Integration (auth example, endpoint examples, error response), the base URL, the image URL builder and the one-time API key use CodeSnippet; the three older block styles are gone. CodeSnippet tabs changed from sand pills to the underline style used by tabs elsewhere.
10. Variant form: mobile full page replaced by a 92% bottom sheet with grabber and close ×; back arrow removed.
11. Dialog geometry unified (140 px top offset on desktop, flush bottom sheets with grabber on mobile, header 20 24 0, footer secondary left and primary right, close × 44 px on mobile). Changed: Media and Variants dialogs (mobile inset), Activity export (centered, header bar, 480 wide), API keys dialogs (border, 18 px title, 44 px buttons, 120 px offset).
12. Current page in the paginator: border `--ui-primary` instead of sand (was 1.3:1).
13. Page padding 24/16 everywhere (was 32 and 24); card padding 16 (was 16 and 20); section gap 16 (was 16, 20, 28); page title 20/28 (was 28/36, 24/32, 20/28); stat figure 24/32; card title 15/24; inputs 32/44 (were 40); one 13 px icon became 14; toast width 356 (was 360, 372).
14. Copy: "Removed N …" in activity lines became "Deleted N …"; export titles and footers per the glossary.
15. Media screen: variant count badge added to grid card, list row and mobile row, matching the Variants board.
16. Editor toolbar on mobile was a cut-down 9-button subset; it now has the full toolbar with sideways scroll and edge fades.
17. Editor words and characters show "0 words · 0 characters" for a new empty article (was a fallback sample count).
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
- `source/RenewCMS Directions.dc.html`: the three brand directions (history only).
## Defaults chosen
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
- Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in `X-Total-Count`. Users and authors follow their own routes (see routes.md).
- The Remove action in the link popover has no confirmation; undo restores the link.
- The link button is disabled when an image is selected.
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
- Mobile toolbar scrolls sideways; no wrap, no overflow menu.
- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light).
- Syntax colouring dropped from code samples (one rendering).
- Export "Download started" offers only Close.
- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
- Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
- Activity position in the sidebar follows AppFrame.
- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).
## Checks run
- Opened `RenewCMS Screens Back-office.dc.html` in the preview: it loaded, 38 sections rendered, and I read the DOM of the Activity, Export, Media, Variants, Editor, Integration, Pagination, Picker, Editor picker and States matrix groups to confirm the frames render text and the sidebar. I did not read the console.
- Computed the contrast table from token values with a script.
- Searched frames and boards for raw hex and rgb values (only token definitions on the board roots and the brand mark remain), for exclamation marks, Remove and Delete, Update and Save, and per_page.
- Generated screens/*.md and counted states from the board source; the counts equal the DOM counts for the Back-office board.
## Not done
- Console errors on the Auth, API keys, Prototype, Foundations and Components boards were not read by me. The delivery check covers the Back-office board; the other boards use the changed frames (AppFrame, ApiKeysPage, AuthCard, MediaFrame) and may need a look.
- Light and dark were checked for the Back-office board by reading DOM only; I did not view the screenshots of each state at 1440 and 390, so visual regressions from the shell change (slot mode) are possible.
- Media screen: the "With variants / Without" filter, the "Largest total size" sort and a total-size column exist only on the Variants board states; they are not folded into the Media toolbar.
- Media picker tiles and the Editor picker were not compared field by field.
- Duplicated implementations that look alike are not merged in code: toasts (5), badges (2), empty states (2), modal shells (4). Only their visuals were aligned.
- Hover, active and focus-visible are drawn once in the matrix; screens do not redraw them. Hover and active tokens for destructive buttons use opacity, not a token.
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
- The sandbox cannot export; no files were bundled.
## Pass 5: roles and permissions
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 16 groups, 96 states, all Available now (pass 6 added groups 12 to 15 and changed groups 5, 8 and 9; see change-log-roles-sync.md). Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. Not done: AppFrame itself is unchanged, so the Back-office board still shows owner/admin/user nav; Schema board group 8 (Permissions) should be marked superseded.
The Schema board group 7 (Relations) now has 4 states; the Integration page and the Activity category filter follow the roles sync (see `change-log-roles-sync.md`).
## Sync notes (app sync)
- Back-office board: +4 Media states (Used elsewhere: avatar, site, both, none). Roles board: +6 states (group 12b Remove user).
- Pagination: articles return {items, total, page, size, counts}; media returns an array with X-Total-Count.
- Event names follow the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
- Counts above include the Media (+4) and Roles (+6) states only. States added this round to the Editor, Articles, Sites, Authors, Profile and Schema groups are on the boards and in the screens files, and are not yet recounted here.
## Pass 7: account self-service (email, export, delete)
Board `source/RenewCMS Screens Account.dc.html` (33 states: change email 14, export 9, delete 10), Auth board groups "Verify new email" (7) and "Account deleted" (1), four email templates in `source/emails/` (previews on the Emails board). Specs: `screens/account.md`, `emails.md`, `routes.md`, `components.md`, `states.md`. Frames: `source/AccountCards.dc.html` (new; copy in `source/account-copy.js`), `AppFrame.dc.html` (Profile hooks, modal list, warning, link and disabled confirm). Every Profile frame on the other boards shows the new cards in their default state. English only.
Decided: authored content is anonymized to "Deleted user"; email-change link 2 hours; export link 24 hours, one export per 15 minutes; direct download is the primary export flow, the emailed link is the documented alternative; the export-ready link needs a logged-in session; "last administrator" counts Owner; a change request for an address already in use answers like success; the account-deleted email goes to the address the account had.
Not done: Foundations and Components boards and the Prototype are not updated. The 15-minute rate-limit state is clock-sensitive (frozen clock 09:41) and is skipped by `task visual:check`.
+122
View File
@@ -0,0 +1,122 @@
# Change list for implementation (status after pass 2; SEO round drawn; cleanup round drawn)
## Cleanup round
1. Editor: site and authors, "Create author" states (4 states, desktop and mobile): the mobile dialog sat under the Details sheet (z-index 50 against 55) and was hidden. It now stacks above the sheet (z-index 60), as the SEO panel does. Full-width bottom sheet with grabber, same fields, Cancel left and "Create author" right (Cancel below on mobile, as the other mobile dialogs). Frame: `DirectoryFrame.dc.html`. The four states share the fix: dialog, "for me", slug taken, failed.
2. Integration, "6. Errors": the 403 row gets the muted note "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The row below shifts down by the wrapped note height (two 18 px lines desktop, three mobile, plus the 2 px row gap). The old inline parenthetical with the same sentence in the 403 cause text was removed, so it isn't said twice. Frame: `IntegrationPage.dc.html`.
3. Integration, sitemap errors: 404 now shows the body `site_not_found` in code style (was "Site not found"); 400 now shows the generic body-mismatch text used by the other routes (was "Invalid page or size.").
4. New groups on the Back-office board: "Sites: pagination" and "Authors: pagination", 10 states each (20 states, every one desktop and mobile; light and dark by the theme switch). Back-office total 409 → 429. Same pager as Articles and Media, sizes 5, 25, 50, 100, default 25 (no difference to report). One deviation: on a single page the page-size select is hidden and only "N sites" or "N authors" remains.
5. Specs: screens/sites.md, screens/authors.md (pager states, URL rules, out-of-range fallback), screens/integration.md, states.md (pagination states, three HTTP rows), components.md (single-page variant, no new component), routes.md (Sites pagination in the URL).
**Backend assumptions to confirm.** GET /v1/site/ takes `q`, `page`, `size` and returns `X-Total-Count` (it returns every site today). GET /v1/author/ gets `page`, `size` and `X-Total-Count` (it has `q`).
**Open points.**
- Sites has no search field. The two states that need one (empty after search, search resets to page 1) draw the Authors field on Sites with "Search by name or key". Drop those states if you don't want search on Sites.
- Stale copy found, not changed: GET /v1/article/ on Integration still shows the 400 caption "Invalid page or size." while every other route, and now the sitemap, uses the body-mismatch text. The 404 row of the Errors table still lists only the four resource messages and doesn't mention `site_not_found`. The existing Sites and Authors "Default" frames don't draw the count line. README.md state counts and the "Pagination" notes were not updated (outside this round).
## SEO round: decisions (frames drawn)
**Placement.** A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).
**Principles.** RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.
**Fallback chain (resolved server-side, shown in the UI).**
| Output | Order |
|---|---|
| title | seo_title, then article title; site title_template applied last |
| description | seo_description, then excerpt, then first sentence of the body, then site default_description |
| image | og_image_id, then cover_image_id, then site social image |
| canonical | canonical_url, then site base_url + "/" + slug |
| robots | noindex true gives "noindex, nofollow"; otherwise site default_robots |
| og/twitter title and description | social_title, social_description, then the resolved values above |
**Soft ranges (counters, never blocks).** Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (`--ui-warning`) outside the range, never red.
**New article fields (PATCH /v1/article/{id}, all optional, snake_case).** `slug`, `excerpt` (max 300), `seo_title` (max 120), `seo_description` (max 320), `cover_image_id`, `cover_image_alt` (max 200; the cover is "ready" only with alt text), `social_title`, `social_description`, `social_image_id`, `canonical_url` (absolute https URL), `noindex` (boolean; only sets a flag in the API, it doesn't change visibility), `seo_dismissed` (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 `slug_taken` with `suggestion` ("<slug>-2"); auto-generated from the title until edited.
**New site fields (PATCH /v1/site/{id}, owner only).** `site_name`, `title_template` (default "%title% | %site%", must contain %title%), `default_description` (max 320), `social_image_id` (existing), `default_robots` ("index" | "noindex"; existing as robots).
**New endpoints.**
- `GET /v1/article/{id}/seo`: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: `{ title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }`. `json_ld` is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
- `GET /v1/site/{key}/sitemap?page=&size=`: published URLs only, total in `X-Total-Count`. Items: `{ url, modified_at, noindex }`. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
- `GET /v1/article/?seo=ready|suggestions|hidden`: filter behind "Needs attention" (suggestions) with `seo_status` and `seo_suggestions` (count) added to list items.
**Articles list.** Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).
**Authors.** No change to the Authors screen. Profile URL (website) and social links feed `authors[].url` and `same_as`, and JSON-LD `author[].sameAs`; the API preview shows them.
**Permissions.** Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 `owner_only` on Site SEO fields, 401 on a missing session.
**Left out on purpose.** Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.
**Open questions.**
1. Is `slug` immutable after first publish, or can it change (and break URLs with no redirects)?
2. Does the API return the SEO object for drafts to authenticated callers (for frontend previews)?
3. Should noindex articles be excluded from the sitemap endpoint instead of flagged?
4. Is title_template applied by the API (resolved title) or only returned for the frontend to apply?
5. Backfill: generate slugs for existing articles from their titles at migration, with `-2` suffixes on conflicts?
6. Should excerpt be exposed in the list payload as the article teaser?
**Status.** Frames drawn on the Back-office board (groups: Articles: search status, Editor: search and sharing, Editor: what the frontend gets, Sites: SEO defaults), Integration page group added, specs updated. Placement rationale: the sidebar holds only the summary, address and suggestions because it is 280 px wide; previews and fields need a slide-over.
Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed.
| # | Screen | Change | Done in design? | Needs backend (yes/no, what) | Needs new tokens or components | Frontend only |
|---|---|---|---|---|---|---|
| 1 | All | Add the `--rc-*` tokens (sand, on-sand, four soft grounds, overlay, two shadows) to the app CSS, light and dark (foundations.md) | Yes | No | 9 new tokens | Yes |
| 2 | All | One sidebar, topbar and mobile drawer; Settings parent and child selected states; Activity sits between Users and Integration | Yes | No | NavItem, NavGroup | Yes |
| 3 | All | Overlay pattern: dialog (desktop) and bottom sheet (mobile); slide-over only for the variant form and record details; footer secondary left, primary right | Yes | No | Modal wrapper rules (components.md) | Yes |
| 4 | All | CodeSnippet is the only code block (Integration, Images, API keys, base URL, image URL) | Yes | No | CodeSnippet | Yes |
| 5 | All | Copy glossary pass (Delete vs Remove, Save vs Update, toast phrasing) | Yes, by search; not every line re-read | No | None | Yes |
| 6 | Settings | "Media" card with "Manage image variants" and "Cache usage" | Yes | No | MediaLinksCard | Yes |
| 7 | Media, picker | Variant count badge on grid, list and mobile rows | Yes | No (variant counts already come with GET /v1/media?variants=) | Badge "N variants" | Yes |
| 8 | Media | "With variants / Without" filter and "Largest total size" sort in the Media toolbar | Drawn on the Variants board only, not folded into the Media screen | No (GET /v1/media?variants=&sort=total_size exists) | None | Yes |
| 9 | Image variants | Variant form as slide-over (desktop) and tall sheet (mobile) | Yes | No | None | Yes |
| 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted |
| 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No |
| 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes |
| 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes:** `page`, `size` on `GET /v1/article/` (same convention and 400 as Activity); total in `X-Total-Count`, array body unchanged; Integration reference updated | Paginator, PageSizeSelect | No |
| 13b | Media | Pagination under the grid and list, same Paginator and PageSizeSelect as Articles; `page` and `size` in the URL; saved size preference | Yes (9 states in the Media group) | **Yes:** `page`, `size` on `GET /v1/media/` (replaces `cursor` and `limit`); total in `X-Total-Count`, array body unchanged; Integration reference | None (reuses Paginator, PageSizeSelect) | No |
| 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes |
| 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes |
| 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No |
| 17 | Integration | Images and variants section, URL builder, response bodies `unknown_variant` and `unsupported_parameter` | Yes | No | UrlBuilder | Yes |
| 18 | All | States matrix for controls (hover, focus-visible, active, disabled) | Yes (board "States matrix: controls") | No | None | Yes |
| 19 | All | Contrast fixes: paginator current page border, no muted text on sand | Yes | No | None | Yes |
| 20 | All | Spacing, type and icon sizes made consistent (foundations.md, "One value per role") | Yes in the frames listed there | No | None | Yes |
| 21 | Settings, sidebar | Sites screen under Settings → Sites ("Publishing" group); "Publishing" card on Settings. Owner only: hidden for other roles, 403 page on a direct link. Key immutable after creation | Yes | **Yes:** `site` table, GET/POST/PATCH/DELETE /v1/site/ (writes owner only; PATCH rejects a changed key), `reassign_to`, seed from PUBLIC_URL | SitesTable, SiteForm, SlugField (locked variant), ReassignDialog | No |
| 22 | Sidebar | Authors entry between Media and Users, all roles. Owner and admin manage; other roles read-only list and detail | Yes | **Yes:** `author` table, GET /v1/author/ (any role), POST/PATCH/DELETE (owner and admin; a user may PATCH their own linked author), POST /v1/author/me (any role) | AuthorsTable, AuthorForm, SocialLinksRepeater, MediaField | No |
| 23 | Editor | "Site and authors" section in every Editor frame (collapsed by default): Site select (any role that can edit the article), reorderable authors, inline create (owner and admin), hint | Yes | **Yes:** `site_id`, `author_ids[]` on PATCH /v1/article/{id} | MetaSection, AuthorList, AuthorSelect, SiteSelect, CreateAuthorHint | No |
| 24 | Articles | Authors column; Site column and filter when more than one site | Yes | **Yes:** `site` filter, `site` and `authors` in the list payload | AuthorStack | No |
| 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No |
| 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No |
## Roles and permissions: screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation (AppFrame, done) | New "Access" group with a "Roles" item (shield icon) under Settings in the sidebar and the mobile drawer, shown to everyone except the restricted `user` role. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
Decisions to confirm:
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
+119
View File
@@ -0,0 +1,119 @@
# Change log: roles sync (pass 6)
Diff this against the code. "Old" is the previous design copy, "New" is the copy now in the design. EN is the design language; FR is in the board table "Final copy: toasts and errors, EN and FR" (screens/roles.md, same title). Every state is drawn light and dark, 1440×900 and 390×844, on `RenewCMS Screens Roles.dc.html` (data `roles-spec.js`, frame `RolesFrame.dc.html`) unless stated. Roles board: 61 → 90 states, 11 → 15 groups.
## 3. Differences adopted
### 3.1 Neutral wording ("they")
| File · state | Old | New |
|---|---|---|
| roles-spec.js, screens/roles.md, RolesFrame · Users: Change role dialog and Change role: options blocked | Her API keys follow the new role. | Their API keys follow the new role. |
| same · Users: Transfer ownership: confirm | You become an Admin. You can't take ownership back unless he transfers it. | You become an Admin. You can't take ownership back unless they transfer it. |
Searched all .md, .js, .dc.html and email files for he, she, his, her, him, hers. No other occurrence.
### 3.2 Event names
| File | Old | New |
|---|---|---|
| routes.md, screens/roles.md (Needs backend), roles-spec.js | role.created, role.updated, role.deleted, user.role_changed, site.ownership_transferred | role.create, role.update, role.delete, role.reassign (new), user.role_change, site.ownership_transfer |
| states.md (new section "Roles sync"), screens/activity.md (new section) | event names and sentences were not written down | table of name, sentence, failed sentence |
Sentences themselves are unchanged ("Role Contributor created", and so on).
### 3.3 Invite endpoint
| File | Old | New |
|---|---|---|
| screens/roles.md group 5, roles-spec.js group 5 | POST /v1/invite | POST /v1/user/invite |
routes.md, components.md and the other screen files never cited `/v1/invite`. IntegrationPage already said `/v1/user/invite`.
## 1. Copy
All EN and FR strings: roles board, table "Final copy: toasts and errors, EN and FR", and group 12 (toasts) and 13 (inline errors).
| State | EN |
|---|---|
| Toast: role changed (group 12) | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. |
| Toast: role edited elsewhere, 409 | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload |
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
| Toast: member's role changed before a change | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload |
| Toast: target's role changed before a transfer | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload |
| Toast: member's role changed before a removal | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload |
| Role editor: save failed (new) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again |
| Change role: failed (new) | Couldn't change the role · Nothing was changed. Try again in a moment. |
| Delete role: target deleted (new) | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. |
| Delete role: role gained members (new) | Role still has members · Intern now has 1 member. Move them to another role first. |
| Delete role: move failed (new) | Couldn't move the members · Nothing was changed. Try again in a moment. |
| Invite: invalid email (new) | Enter a valid email address. |
| Invite: account exists, 409 (new) | This address already has an account. |
| Invite: role above yours (new) | Your role can't assign a role with more power than yours. |
| Invite: send failed (new) | Couldn't send the invite · Try again in a moment. |
| Activity, role.reassign | Moved 3 members from Intern to Writer |
| Activity, failed sentences | Failed to {verb} {object}; one per event in the table "Activity events: failed sentences, EN and FR" |
| Activity filter, category names | Articles · Media · Users and invites (old: Users & invites) · Roles (new) · API keys · Authentication · System |
Unchanged and still correct: Name required, Name already taken, Escalation blocked, Conflict banner, Permission lowered.
## 2. Profile role card (group 9)
Decision: the summary line is removed. "Assigned by Marta Alves" is removed with it, because `access.role` has no such field either. "See what I can do" stays; its summary is computed from the permission list in GET /v1/me.
| State | Old | New |
|---|---|---|
| Role card: custom role | Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role… | Your role · [Contributor badge] [Custom tag] · See what I can do · Your API keys follow this role. If your role changes, they change too. |
| Role card: Owner (new) | none | Your role · [Owner badge] [Locked] [Built-in] · See what I can do · same key line |
Layout, top to bottom, inside the existing card: title; one row of badges (role badge with icon, then Locked if `locked`, then Built-in or Custom from `built_in`); link row with the list-checks icon; muted key line. Gap 12, as every card.
## 4. Behaviour
### Read = Own (group 14, new, 5 states; also group 2 Role editor and group 3 preview via the same editor)
| State | Content |
|---|---|
| Role editor: Read set to Own | New info alert under the grid: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. The preview sentence for Read = Own is now type-specific: "Can read online articles and their own drafts" (Articles) and "Can read only entries they created" (custom types). Old preview text was "read only their own". |
| Articles list: Read = Own | Junior writer: 3 online articles by others and 2 own drafts. Tabs All 5, Online 3, Draft 2. Banner: Drafts by others are hidden. |
| Articles list: no drafts of their own | Draft tab, counts All 3, Online 3, Draft 0. You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. |
| Entry list: Read = Own | Only own entries, "3 entries", banner Entries by others aren't shown. |
| Entry list: no entries of their own | You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. |
Decisions table row 4 now says Update and Delete set to Own keep the View only rows, Read set to Own hides them.
### 409 conflicts (group 15, new, 5 states)
| State | Where | Edits kept | Recovery |
|---|---|---|---|
| Stale save: banner (existing Conflict state, copy unchanged) | Banner above the form | Yes, Save off | Reload role, or Review differences |
| Stale save: review differences (new) | Dialog over the editor | Yes | Keep my edits (reload and re-apply edits on top, Save on) or Use the saved role (drop edits) |
| Stale save: edits kept after reload (new) | Info toast, form shows saved role plus edits | Yes | Review, then Save |
| Change role: member's role changed (new) | Alert inside the dialog; toast if closed | Selection kept; Current mark moves | Change role again |
| Transfer ownership: target's role changed (new) | Alert inside the dialog; toast if closed | Typed email kept; primary off | Reload member, then Transfer ownership |
### Integration page (IntegrationPage.dc.html, Back-office board group Integration)
| Item | Old | New |
|---|---|---|
| Authentication, API key card | …with that user's role (owner or admin). A key never has more rights than its creator. | …with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it. |
| Errors table, 403 | "This action needs a real session.", "Only the owner can do this.", "Only owners and admins can do this." or "You must verify your account to access this resource." | "This action needs a real session.", `missing_permission` (code style, literal body), or "You must verify your account to access this resource." Note: `missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article. |
| Access chip on 9 routes (content types create, update, delete; built-in types list, get; media purge settings get, patch, preview, purge) | Owner or admin | Needs permission. The route description ends with Needs the permission "…". Proposed permissions: Create and edit content types; Delete content types; View content types; Delete files (purge routes). |
| Per-route 403 line on those routes | Only owners and admins can do this. | missing_permission (code style) |
| Content types section intro | An owner or admin creates a type… | A user whose role can create and edit content types defines a type… |
"Owner only" chips are kept for the owner-only routes.
### Relation picker (SchemaFrame, Schema board group 7, Later)
Sample data: 25 venues per page (the design had 3 sample results, never 10), footer "Showing 25 of 62" with Load more. New state "Relation picker: loading more": skeleton rows, Loading… on a disabled button. Group 7: 3 → 4 states.
## Files touched
roles-spec.js, RolesFrame.dc.html, screens/roles.md (regenerated from the spec), routes.md, states.md, screens/activity.md, screens/integration.md, screens/schema-later.md, schema-spec.js, SchemaFrame.dc.html, IntegrationPage.dc.html, README.md.
## Open items for you
See the end of this file's companion list in the chat reply; repeated here.
1. Relation picker: the design never had a "Load more" frame or a 10-result sample. Both were added, not updated.
2. Activity event names: states.md and screens/activity.md did not contain event names; they only existed in roles.md, roles-spec.js and routes.md. I added the table to the first two.
3. `user.role_changed` and `site.ownership_transferred` became `user.role_change` and `site.ownership_transfer`. Your brief said "the same present-tense pattern for the other role events". Confirm those two are included.
4. `/v1/invite` appeared only in screens/roles.md and roles-spec.js. routes.md, components.md and the other screens already had `/v1/user/invite` or no citation.
5. The 403 row in the design did not list "missing_permission". It listed "Only the owner can do this." and "Only owners and admins can do this.", which I removed. Check that owner-only routes now also answer `missing_permission`.
6. Status naming: you say All / Online / Draft; the design already used Online and Draft, so no change.
7. "Role deleted by someone else (409)": a deleted role normally answers 404. I drew 404 for the roles list and an inline 409 only where the deleted role was a reassign target. Confirm the codes.
8. "Changing a role that changed meanwhile": I read it as the member's role having changed, not the role definition. Confirm.
9. Profile: `access.role` has no assigned_by, so "Assigned by" was removed along with the summary. Confirm that the permission summary can be built from /v1/me.
10. Activity categories: the filter had six categories. I added Roles and renamed "Users & invites" to "Users and invites" to match the permission group. Authors, Sites and Content types have activity in the product but no category in the filter; I did not add them.
11. Purge routes: the catalogue has no permission for media purge. I used Delete files. Pick the real one.
12. Not changed, but now out of date: IntegrationPage Entries intro still says "Any verified user can write entries"; screens/schema.md and the Schema frames say "Owner or admin" for Schema; states.md "Sites and authors: roles" and the 403 owner_only rows still describe fixed roles; screens/profile.md Display name copy. Say if you want these aligned.
13. Decision 4 in "Decisions to confirm" was narrowed (Read = Own hides rows). Direct link to a hidden item answers the existing 404 page; this is proposed.
14. French role names: built-in names (Owner, Admin, Writer) stay English in FR copy until you decide.
15. Toast bodies use two short sentences in places; the glossary says one. Kept for clarity.
## Corrections made while importing into the repo
These were checked against the backend and applied to the repo copy of the design (the Claude Design copy still has the old values).
1. Event names: the backend emits `role.edit` (not `role.update`), `user.change_role` (not `user.role_change`) and `user.transfer_ownership` (not `site.ownership_transfer`). The other three, `role.create`, `role.delete` and `role.reassign`, match.
2. Media purge permissions: `GET /v1/media/purge-settings` needs "View settings", `PATCH /v1/media/purge-settings` needs "Edit general settings", and `GET /v1/media/purge-preview` and `POST /v1/media/purge` need "Delete files".
3. Open item 7 (deleted role): the backend answers 404 for a deleted role and 409 for a stale role save, as drawn.
4. Open item 8: "changing a role that changed meanwhile" means the member's role, as read.
5. Event names now match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove. The living specs (screens/roles.md, roles-spec.js, routes.md, states.md, screens/activity.md) use these names; the history rows above keep the names as they were at the time.
+148 -829
View File
@@ -1,851 +1,170 @@
# Components
## Inventory
Frames: `AppFrame.dc.html` (shell: sidebar, topbar, mobile nav drawer, modal, toasts, articles, editor, users, settings, profile), `ActivityFrame`, `MediaFrame`, `VariantsFrame` (page bodies only), `IntegrationPage`, `IntegrationImages`, `ApiKeysPage`, `CodeSnippet`, `StatesMatrix`. The page-body frames mount inside AppFrame through two props (`slotBody`, `slotOverlays`) so there is one sidebar, one topbar and one drawer.
| Component | Nuxt UI base | Props | Emits | Slots | Used on |
|---|---|---|---|---|---|
| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes |
| DashboardShell | UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb | none (reads route.meta and the session store) | none | default, navbar-right | All verified routes |
| UserMenu | UDropdownMenu, UButton, UAvatar (a bottom sheet below md) | displayName?, username, email | logout | none | DashboardShell |
| UsersTable | UTable | rows: UserRow[], loading, canManage: boolean, busyIds: Set | cancel-invite, renew-invite | none | Users (md and up) |
| UserCardList | custom list | same as UsersTable | same as UsersTable | none | Users (below md) |
| RoleBadge | UBadge | role: 'owner'\|'admin' | none | none | Users |
| SettingsPage | custom card | none (reads GET /v1/settings) | none | none | Settings |
| InviteStatusBadge | UBadge | status: 'accepted'\|'pending'\|'expired' | none | none | Users |
| InviteUserModal | UModal, UForm, UFormField, UInput | none | invited(invite) | none | Users |
| ProfileCard | custom card (border, padding, optional footer strip) | title, description | none | default, footer | Profile |
| PageHeader | UPageHeader | title, description? | none | actions, meta | Home, Articles, Integration |
| ArticleStatusBadge | UBadge | online: boolean, size?: 'sm'\|'md', pending?: 'publish'\|'unpublish' | none | none | Home, Articles, Editor |
| ArticleTable | UTabs, UInput, UTable, UDropdownMenu, UPagination | articles: Article[], loading, busyIds: Set<number> | publish, unpublish, delete, copy-url | empty, toolbar-right | Articles (md and up) |
| ArticleCardList | ULink, UDropdownMenu (custom list) | same as ArticleTable | same as ArticleTable | empty | Articles (below md) |
| EmptyState | UEmpty | variant: 'no-articles'\|'no-results'\|'no-drafts'\|'no-online', query? | action | none | Home, Articles |
| ConfirmDialog | UModal via useOverlay | title, description, confirmLabel, pendingLabel, tone: 'danger'\|'neutral', action: () => Promise<void> | close(confirmed) | none | Articles, Editor, Pending |
| NewArticleModal | UModal, UForm, UFormField, UInput, UTextarea | none | created(article) | none | Home, Articles, /article/new |
| ArticleEditor | UEditor, UEditorToolbar, UDropdownMenu (+ custom Indent extension) | modelValue: string, articleId: number, disabled? | update:modelValue, request-image | none | Editor |
| ImageUploader | UModal, UFileUpload, UProgress | articleId: number | uploaded(location), close | none | Editor |
| SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor |
| PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor |
| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor |
| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register, Accept invite |
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
Composables behind these components: `useSession` (it also carries the display name), `useUsers` (the list and the invite action), `useProfile` (the display name draft and save), `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown).
## Product component specs
Extracted from `source/RenewCMS Components Product.dc.html`; open it for the drawings.
## Components: product
These are the RenewCMS components, built from the primitives on the first page . Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent.
### AuthShell
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
Log in Welcome back. Log in to manage your articles.
default slot
Forgot password? System
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
A11y The title is the page's h1 , and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1 . The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to.
Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , the card loses its border and padding drops to 20 px, filling the screen on --ui-bg , and fields and buttons use xl .
### DashboardShell
The sidebar has five destinations (Home, Articles, Users, Integration, Settings), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
RenewCMS
Home
Articles
Integration
marta Owner
System
Light
Dark
Log out
MA marta
Articles How we price a bathroom renovation
page body
MA
Articles
Mobile top bar. The menu button opens the sidebar as a left slideover.
Built from UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default ( UNavigationMenu orientation="vertical" :collapsed ) and #footer (UserMenu) slots. Each page is a UDashboardPanel , with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb . On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over.
UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. Superseded: see "UserMenu (changed)" under "Added with the Users and Profile screens", which restores the System, Light and Dark items.
Props, slots No props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb . Slots navbar-right (page actions on mobile) and default . It shows the offline banner when navigator.onLine is false.
A11y The first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page" . In the collapsed rail, items show tooltips and keep their aria-label . The collapse button carries aria-expanded . The mobile slideover traps focus and closes after navigating.
Bend The active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts .
### PageHeader
Articles Everything you've written, online and in draft.
Connect your website New article
Articles Everything you've written, online and in draft.
New article
Built from UPageHeader :title :description , with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' } , which brings Nuxt UI's marketing-size header down to dashboard density.
Props, slots title , description? , slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor).
Responsive Below md , the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu.
### ArticleStatusBadge
Online
Draft
Online
Draft
Publishing…
Unpublishing…
Built from UBadge . Online: color="neutral" variant="solid" icon="i-lucide-globe" . Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line" . Status is shown by the fill, the icon and the word, never by color alone.
Props online: boolean , size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive.
### ArticleTable
Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md , the table becomes a card list with the same data and actions.
All 6 Online 4 Drafts 2
Search by title
Title Status Created Updated
Online Draft
Built from UTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md . Each row has a UDropdownMenu , and the row actions go through useArticleActions() , which shares one set of calls between the table, the list and the editor.
Props, emits Props articles: Article[] , loading: boolean , busyIds: Set<number> . Emits publish(id) , unpublish(id) , delete(article) , copy-url(id) . Search, tab and sort state lives in the URL query ( ?q=&status=&sort= ). Slots #empty , #toolbar-right .
Behavior Search is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu.
States Loading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives.
A11y The table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles".
### EmptyState
No articles yet Write your first one. It stays a draft until you publish it. New article
No articles match "tiles" Check the spelling, or search for a shorter part of the title. Clear search
No drafts Everything you've written is online. New article
Nothing is online yet Publish a draft and it will appear on your website. Show drafts
Built from UEmpty :icon :title :description :actions variant="naked" , placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated .
Props variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' , query?: string . Emits action . Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters.
A11y The title is an h2 . "No results" is announced through the table's live region. Clear search returns focus to the search field.
### ConfirmDialog
Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
Cancel Delete article
Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
Couldn't delete the article, so it's still there. Try again in a moment.
Cancel Deleting…
Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading).
Built from UModal opened through useOverlay().create(ConfirmDialog) . open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay.
Props, emits title , description , confirmLabel , pendingLabel , tone: 'danger' | 'neutral' , action: () => Promise<void> . Emits close(confirmed: boolean) . The delete description comes from the title plus images.length , and reads "and its image" for 1 and just the title for 0.
Other uses Cancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone.
A11y role="alertdialog" . Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one.
### ArticleEditor, SaveStatus, PublishControl, CharacterCounter
The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot.
How we price a bathroom renovation Draft
Unsaved changes
Save
Save and publish
Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays .
What goes into the price
Stripping out and making good
Plumbing moves, if any
Details
Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3
Public API URL /v1/article/12 Returns 404 until the article is online.
4 318 / 10 000
Unsaved changes dirty, Save enabled
Saving… saving, Save disabled
Saved at 11:02 saved, Save disabled
Save failed Retry error, changes kept
Publish draft, saved
Unpublish online
9 240 / 10 000 body ≥ 90%
10 214 / 10 000 214 characters over. Shorten the text or remove an image. Formatting counts too. over limit, Save disabled
ArticleEditor UEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed" . TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 ( h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem / liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4" , because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes() . The editor surface gets class="article-content" .
Props, emits modelValue: string (HTML), articleId: number , disabled? . Emits update:modelValue , request-image . Exposes insertImage(src, alt) and focus() . The toolbar reflects the active state (bold is on at the cursor) using aria-pressed .
SaveStatus A custom inline span aria-live="polite" , driven by useArticleDraft() : 'clean' | 'dirty' | 'saving' | 'saved' | 'error' . Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline" . Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload . There is no autosave , so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry.
PublishControl A single UButton . Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online. ; "Article unpublished", It's a draft again and hidden from your website.
CharacterCounter A custom component in mono type. Props value: number , max: number , min?: number , warnAt?: number (default 0.9). The title uses [...str].length , so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke.
Responsive Below lg , the details panel becomes a right slideover, opened with i-lucide-panel-right . Below md , the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start.
### ImageUploader
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes.
Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB
idle
Drop to upload
dragging over
Crop and resize north-wall.jpg · 3.9 MB
crop, default: whole image selected, width "1600 px"
Crop and resize north-wall.jpg · 3.9 MB
crop, ratio locked to 16:9
Crop and resize north-wall.jpg · 3.9 MB
crop, crop narrower than the chosen width
north-wall.jpg 43%
Uploading 180 of 420 KB
uploading
Image added
success, closes after 600 ms
"plan.pdf" can't be used Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. Choose another file
rejected: type or size
Upload failed The server didn't accept the file. Your article hasn't changed. Try again
server error
Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') .
Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it.
Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB".
Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
A11y, touch The drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow , and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file.
### CodeBlock and EndpointCard
GET /v1/article/{id}
Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist.
fetch curl Response
JavaScript Copied
const res = await fetch( 'https://cms.renew.example/v1/article/12' )
if (res.status === 404 ) return notFound()
const article = await res.json()
CodeBlock This is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light / github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton , which switches to "Copied" for 2 s through useClipboard . Props code , lang , label? . Long lines scroll horizontally and never wrap.
EndpointCard UCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method , path , description , examples: { fetch, curl, response } . Snippets use the current location.origin .
A11y The copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre ( tabindex="0" ) so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes.
### PasswordStrengthHint
At least 8 characters not met yet
At least 8 characters met
At least 8 characters not met on submit
Passwords match reset screen only
Built from A custom ul in the password UFormField 's #help slot. Props rules: { label: string, met: boolean }[] , showErrors: boolean . There is no meter or score, because the API only requires 8 characters.
A11y The list is the field's aria-describedby . Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met.
### ResultPanel
Check your email If an account exists for that email, a password reset link has been sent. The link expires in 1 hour.
Back to log in
This link has expired Reset links work once, for 1 hour. Request a new one and use it within the hour.
Request a new link
Too many attempts For your security, wait a moment before trying again. You can try again in 0:42 .
Try again in 0:42 API pending
Built from A custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info' , icon , title , description , actions: ButtonProps[] . Slot default for extras such as the inline login on verify.
A11y When the panel replaces a form, focus moves to its title and the panel is role="status" , or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero.
### Error pages and offline banner
You're offline. You can keep writing, but saving and publishing will wait until you're back online.
404 Page not found The link may be mistyped, or the page may have moved. Go to Home All articles
Error Something went wrong This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server. Try again Go to Home
Built from UError isn't used because it's built for Nuxt's error.vue . Instead there's a custom ErrorView (props kind: 'not-found' | 'error' ), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner , shown and hidden with useOnline() . Once the connection returns it switches to "You're back online" for 3 s.
A11y The error title is the page h1 and gets focus on render. The banner is role="status" , and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip.
## Added with the Users and Profile screens
The Product components board was not regenerated for these. They are written from `source/RenewCMS Screens Back-office.dc.html`, `source/AppFrame.dc.html` and the Prototype. Their exact values come from AppFrame.
### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
### DashboardShell (changed)
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings".
### UsersTable, UserCardList
Superseded by "UsersTable, UserCardList (changed)" below.
Skeleton rows appear after 150 ms of loading.
### InviteStatusBadge
A 22 px outline-less badge with a 12 px icon and the word, so status is never shown by color alone.
| Status | Icon | Fill and text |
|---|---|---|
| Accepted | circle-check | `--rc-success-soft`, `--ui-success` |
| Pending | mail | `--rc-warning-soft`, `--ui-warning` |
| Expired | clock-alert | transparent, `--ui-text-muted`, border `--ui-border-accented` |
### InviteUserModal
A UModal with one field, following the NewArticleModal pattern: title "Invite a user", the text "We'll email them a link to choose a username and password. The link expires after 3 days.", an Email field (placeholder "name@example.com", focused on open), Cancel and "Send invite". While sending, the field is disabled, the button reads "Sending…" and the dialog can't be dismissed. A field error shows under the input, a request failure shows an alert above the buttons, and the address always stays in the field. On success it closes and emits the new invite.
### ProfileCard
A bordered card with 20 px padding (16 px on mobile), a semibold 14 px title, a muted description, and its content. An optional footer strip sits below a top border on `--ui-bg-muted`, with the status text on the left and the action on the right. The Display name card uses the footer; Account and Password do not.
The Display name field uses CharacterCounter with a maximum of 60, counted in Unicode characters, in the mono style on the right of the help row. Read-only fields (username, email, password) are disabled inputs on `--ui-bg-elevated` with a lock icon at the right.
## Themed primitives
Extracted from `source/RenewCMS Components Primitives.dc.html`.
## Components: themed primitives
These are Nuxt UI components with the Ledger config applied. Every specimen reads the real --ui-* variables, so the theme switch on the right changes the whole page. The product components are on a second page .
### Button
There are five roles. A screen gets one primary button. Danger is used only for irreversible actions, and always inside a confirmation step.
New article primary
Cancel secondary
Preview ghost
Delete article danger
icon-only
Save sm 28
Save md 32
Save lg 40
Save xl 44, touch
Publish default
Publish hover
Publish focus-visible
Publish active
Publish disabled
Publishing… loading
Delete article danger hover
Cancel secondary hover
Preview ghost hover
Nuxt UI UButton . Primary color="primary" variant="solid" · secondary color="neutral" variant="outline" · ghost color="neutral" variant="ghost" · danger color="error" variant="solid" · icon-only square icon="i-lucide-ellipsis" :aria-label wrapped in UTooltip .
Loading :loading swaps the leading icon for i-lucide-loader-circle and disables the button. The label changes to the -ing form ("Publishing…") and the button width stays fixed, so the layout doesn't jump. loading-auto is used on async @click handlers.
Keyboard, ARIA Native <button> , activated with Enter or Space. Icon-only buttons need an aria-label . Loading sets aria-busy="true" . Disabled buttons stay focusable only when a tooltip explains why they're disabled.
Responsive md on desktop, xl (44 px) below md . Primary page actions go full width in sticky footers on 390 px screens.
Bend Disabled opacity is 40% instead of Nuxt UI's 75%, because a 75% black button still looks clickable. Hover darkens the button one step instead of using Nuxt UI's /75 alpha, so the label keeps its contrast.
### Input, password input, textarea
One outline style for every field. Focus adds a 2 px ink ring inside the edge. Errors turn the edge brick red and always come with a message, never color alone.
marta default, placeholder
marta hover
mar focus
ma error
owner@renew.example disabled / read-only
plaster leading icon, clear
•••••••••• password, hidden
limewash2026 password, shown
A short first paragraph. You can keep writing in the editor. textarea, autoresize 3–8 rows
Nuxt UI UInput variant="outline" color="primary" , :highlight bound to the field error so the edge turns red. The search field is icon="i-lucide-search" with a #trailing clear UButton . UTextarea autoresize :rows="3" :maxrows="8" .
Password UInput :type="shown ? 'text' : 'password'" with a #trailing ghost UButton . aria-label reads "Show password" or "Hide password", with aria-pressed and aria-controls . autocomplete is current-password on log in and new-password on register and reset. Showing the password never clears or moves focus.
States Default, hover (edge goes to muted), focus (2 px ink), filled, error, disabled, read-only (same look as disabled but selectable and focusable, used for the email on the reset screen). Fields are 16 px text below md .
Placeholder Placeholders use --ui-text-muted (5.7:1) and only show an example. The label always carries the meaning.
### Form field
A label on top, then the control, then the help text or the error in the same spot. The right-hand hint slot holds the character counter. Every field in the product is required, so none are marked.
Username 5 / 20
marta 3 to 20 characters. You'll use it to log in.
Username 2 / 20
ma Use at least 3 characters.
Email marta@renew Enter a full email address, like name@example.com.
Nuxt UI UForm :schema :state @submit + UFormField name label help , with the counter in the #hint slot. Validation runs on blur and on submit, then on every input once a field has an error, so the error clears as soon as it's fixed. The schema mirrors the API rules exactly, so a validator 400 shouldn't reach the UI.
ARIA Nuxt UI links the label and aria-describedby (help or error) and sets aria-invalid . On a failed submit, focus moves to the first invalid field. Error text sits in the help slot, so there is no layout shift.
### Checkbox, switch, select
All three are square. The Preview toggle in the editor is the only switch. The select sets sort order in the mobile article list. No screen needs a checkbox yet, but it's themed so it matches if one gets added.
Off
On
Focus
Disabled
Preview off
Preview on
Focus
Disabled
Newest updated first
Newest updated first
Oldest updated first
Nuxt UI UCheckbox , USwitch (slots base and thumb set to rounded-none ), USelect :items value-key="value" , whose content uses --shadow-popover .
Keyboard Space toggles a checkbox or switch. The select opens with Enter, Space or ↓, moves with the arrow keys and type-ahead, and closes with Esc, returning focus to the trigger. The switch uses role="switch" and aria-checked , and its visible label is its name.
### Badge
Badges show status or a count. They never replace a button. Solid ink is for the one state that matters most in a list. Soft semantic badges are for system messages.
Online
Draft
12
success
info
warning
error
GET
Online · md
Nuxt UI UBadge . Solid ink color="neutral" variant="solid" · outline color="neutral" variant="outline" · count color="neutral" variant="soft" size="sm" · semantic variant="soft" . Sizes are sm (22 px, in tables) and md (26 px, in the editor header).
ARIA Badges are text, not live regions. When a status changes, the announcement comes from the toast.
### Alert
Alerts are for messages that belong to a page and stay until the situation changes. Anything that confirms an action someone just took is a toast instead.
Your session expired Log in again to pick up where you left off.
Password changed Log in with your new password.
Almost at the length limit 9 240 of 10 000 characters used. Images and formatting count too.
Couldn't load your articles Check your connection and try again.
Try again
Nuxt UI UAlert variant="soft" :color :icon :title :description :actions . close only on alerts that are safe to dismiss (password changed, session expired). The error alert has no close button because its retry is the way out.
ARIA Info, success and warning use role="status" and error uses role="alert" . Alerts that are already there when the page loads aren't announced again. The description text uses --ui-text-toned rather than the semantic color, so long text stays readable.
### Toast
Toasts confirm that an action finished or failed. Each one has a short title and, at most, one action.
Article published "Winter checklist for timber windows" is now online.
That article no longer exists It was deleted somewhere else, so we removed it from the list.
Couldn't unpublish The server didn't respond.
Retry
Placement Bottom right on desktop, 24 px from the edges. Bottom center at full width minus 16 px on mobile, above the sticky action bar.
Duration Success and info close after 5 s. Errors with retry stay until dismissed or retried. The timer pauses on hover and on focus.
Stack At most 3 visible, newest at the bottom. A fourth pushes out the oldest non-error toast. Repeating the same action replaces its toast instead of adding a new one.
Nuxt UI UApp :toaster="{ position: 'bottom-right', duration: 5000, max: 3 }" . Toasts are raised with useToast().add({ id, title, description, color, icon, actions, duration }) , where id is the action plus the article id so a repeat replaces the old toast. Errors use duration: 0 . The progress bar is off.
ARIA The Reka toast region announces politely, and errors assertively. F8 jumps to the region. Esc closes the focused toast.
### Modal and slideover
Modals ask a question or hold a short form. Slideovers carry navigation and side panels on small screens. Both sit over a 40% ink overlay.
Leave without saving? You have changes to "How we price a bathroom renovation" that haven't been saved.
Discard changes Keep editing
Details
Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3
Nuxt UI UModal :title :description with the #footer slot for actions, width sm:max-w-md . Opened programmatically with useOverlay().create(ConfirmDialog) . USlideover side="right" for details, side="left" for mobile navigation (built into UDashboardSidebar ).
Button order The safe action is primary and sits on the right, where the eye lands. In destructive confirmations, the danger button is on the right and Cancel on the left (see ConfirmDialog). On mobile the buttons stack full width, with the primary on top.
Keyboard, ARIA Focus is trapped and starts on the least destructive button. Esc and the overlay close it unless it's :dismissible="false" , which is used while an action is running. Focus returns to the trigger. The title is the aria-labelledby and the description is the aria-describedby .
Responsive Below sm , modals sit 16 px from the screen edges and slideovers take the full width. Motion follows the foundations: 240 ms, and instant with reduced motion.
### Dropdown menu and tooltip
The row-action menu is the main dropdown. Delete always comes last, below a separator, in brick red. Tooltips name icon-only buttons and show their keyboard shortcut.
Edit
Unpublish
Copy API URL
Delete…
Bold Ctrl B
Nuxt UI UDropdownMenu :items :content="{ align: 'end' }" , with items grouped in nested arrays so separators appear automatically. The Delete item is color: 'error' , and its label ends in "…" because it opens a confirmation. UTooltip :text :kbds="['ctrl', 'b']" :delay-duration="400" .
Keyboard The menu opens with Enter, Space or ↓. Arrow keys and type-ahead move through it, Home and End jump to the ends, and Esc closes it and returns focus. Tooltips appear on focus as well as hover and never hold information that isn't also in the aria-label .
Touch Tooltips don't show on touch, so every icon-only control on mobile also appears in a labeled menu or has a visible label.
### Tabs
Tabs filter the article list. Each count comes from the full list, so it doesn't change while you search.
All 6
Online 4
Drafts 2
Focused
From left: selected, default, hover, focus-visible.
Nuxt UI UTabs variant="link" color="neutral" :content="false" :items v-model . The count goes in the #trailing slot as the mono sm count badge. The value syncs to ?status=online|draft so the filtered view can be linked.
Keyboard Roving tabindex: Tab enters the tab list on the selected tab, and ← and → move and activate. Each tab's accessible name includes its count, for example "Drafts, 2 articles".
Responsive Tabs stay in one row at 360 px, which is why the labels are short. Each tab is 44 px tall on touch.
### Table and pagination
Rows are 44 px with hairline separators, and only the Updated column sorts. Pagination appears only when a filter shows more than 25 articles.
Title Status Updated
How we price a bathroom renovation default 2 hours ago
Choosing lime plaster for old stone walls hover Yesterday
Before and after: the Rue Verte kitchen focus-visible 3 days ago
Winter checklist for timber windows busy 5 days ago
26–50 of 142 1 2 3 … 6
Nuxt UI UTable :data :columns v-model:sorting v-model:global-filter v-model:pagination :loading with TanStack getPaginationRowModel , and @select for row click. UPagination :total :items-per-page="25" :sibling-count="1" show-edges , active item color="primary" variant="solid" .
States Default, hover ( bg-elevated ), focus-visible (2 px inset ring), busy (a row action is running: 50% opacity, a spinner in the status cell, aria-busy , actions disabled), loading (skeleton rows), empty (EmptyState in the #empty slot).
Keyboard, ARIA Each row's title is a real link to the editor, so Tab reaches it and Enter opens it. The rest of the row is a click target for the mouse only. The sort header is a button with aria-sort . Pagination is a nav labeled "Pages", and the current page has aria-current .
### Skeleton, avatar, kbd, separator
MA marta
JV jan_vries
BO bob
Ctrl
S
Save
Skeleton USkeleton blocks shaped like the real layout: the same row heights, a badge-sized block and a date-sized block. The pulse runs at 1.6 s and stops with reduced motion. The container is aria-busy="true" and a visually hidden live region says "Loading articles". Skeletons appear only after 150 ms, so fast responses never flash.
Avatar UAvatar :alt="username" size="xs|sm" . The initials come from the username: split on anything that isn't a letter or digit, take the first letter of the first two parts, or the first two letters if there is only one part, then uppercase. Sand #d4c5b0 with ink text (11.2:1) in both themes. It's decorative ( aria-hidden ) because the username is always shown next to it.
Kbd UKbd variant="outline" , only inside tooltips and the editor's shortcut hints. The editor shortcuts are Ctrl/⌘ S to save, plus TipTap's defaults: B, I, U, Z and Shift Z.
Separator USeparator uses --ui-border by default and --ui-border-accented between groups in dense toolbars. It's decorative, with role="none" , except between menu groups.
### Color mode
Three choices: System (the default), Light and Dark. The toggle at the top of this page is the component itself, so you can try it.
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
### LanguageSwitcher
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
- The choice persists across reloads and applies to every screen at once.
## Added with roles, invite actions and Settings
Written from `source/RenewCMS Screens Back-office.dc.html` and `source/AppFrame.dc.html`. The Product components board was not regenerated for these.
### UsersTable, UserCardList (changed)
Columns Email, Display name, Role, Invite status, Created (sorted descending, with an arrow-down icon on the header) and an unlabeled actions column. Grid `minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px`, 12 px gap, 36 px header on `--ui-bg-elevated`, 48 px rows with a top border. Below md the same rows become cards with a 68 px minimum height: the email line, the display name below it when set, then role badge, status badge and "Created {date}" on one line, then the row action as a 44 px button.
The email cell is medium weight and highlighted, with a mono "you" tag on the current user's row. The display name is highlighted, or a muted `—` when empty. The row of a just-sent or just-renewed invite has `--ui-bg-elevated`. Skeleton rows have one bar per column. The username is not shown.
### RoleBadge
A 20 px badge in IBM Plex Mono 11 px medium, 7 px side padding, no icon.
| Role | Fill | Text | Border |
## Overlay pattern (one rule, applies everywhere)
| Kind | Desktop | Mobile (390) | Use |
|---|---|---|---|
| owner | `--ui-primary` | `--ui-bg` | `--ui-primary` |
| admin | transparent | `--ui-text-toned` | `--ui-border-accented` |
| Dialog | Centered horizontally, 140 px from the top, width 460 (560 wide forms, 640 crop), `--rc-shadow-modal` | Bottom sheet: full width, flush to the bottom, grabber (36×4, `--ui-border-accented`) | Confirmations and forms with up to about four fields |
| Slide-over | Right panel, full height, over a scrim (Variant form 960 wide with live preview, Media detail, Activity details 480) | Tall bottom sheet, 92% height, grabber, close ×, sticky footer. Never a full page | Only forms with a live preview, and record details. Nothing else may be a slide-over |
| Menu | Popover under the trigger | Bottom sheet with a title and a close × | Action lists (Export, row menus on mobile) |
### Row action button (Users)
Dialog anatomy: header (title 16/24 weight 600, optional description in `--ui-text-muted`, close × 28 px desktop / 44 px mobile), body (padding 20 24 0, gap 16), footer (padding 20 24). Footer order: secondary on the left, primary on the right, aligned right. On mobile the buttons stack with the primary on top, 44 px high. Destructive confirms use `--ui-error` fill with `--ui-bg` text and the verb of the action ("Delete image", "Revoke key"). The scrim is `--rc-overlay`. Esc, the scrim and swipe close unless an action is in flight. In flight, the primary shows `loader-circle` and the verb in -ing form; Cancel is disabled; the dialog cannot be dismissed. Focus is trapped and returns to the trigger.
Cancel is replaced by an explicit label where "Cancel" would read as the action: "Keep invite", "Keep key", "Restart later".
Redrawn to this pattern in pass 2: variant form (mobile full page became a sheet), Activity export dialog (was centered, with a header bar and a 480 wide box), API keys dialogs (bordered box, 18 px title, 44 px buttons, 120 px offset), all Media and Variants dialogs (mobile floated with a 16 px inset; now flush sheet with grabber).
A 28 px outline button on desktop (12 px medium text, 13 px icon, 9 px side padding, `--ui-border-accented` border, `--ui-bg` fill, no wrapping), 44 px on mobile (14 px side padding, 14 px icon). Cancel invite: user-x, text `--ui-error`. Renew invite: refresh-cw, text `--ui-text-highlighted`. While busy: the label becomes "Renewing…", the icon loader-circle, opacity 50 %, disabled. Nuxt UI: `UButton variant="outline" size="xs"` with the classes above.
## Unified in pass 2
- Sidebar: ActivityFrame, MediaFrame and VariantsFrame each had their own copy (VariantsFrame's with the Settings group, MediaFrame's with a raw colour). Removed. AppFrame's is the only one. Prototype's own mobile drawer was replaced by AppFrame's drawer (`s.drawer`).
- Topbar (breadcrumb on desktop, title and lead button on mobile): three copies removed; AppFrame's is used.
- Mobile nav drawer: VariantsFrame's drawer removed; AppFrame renders one (with the Settings > Media group).
- Code block: Integration endpoints, Authentication example, error response, API keys (one-time key), base URL field and the image URL builder all use `CodeSnippet`.
- Modal geometry: AppFrame, MediaFrame, VariantsFrame, ActivityFrame (export) and ApiKeysPage now draw the same dialog and sheet.
- Toast width: 356 px desktop everywhere (was 356, 360, 372).
Still separate implementations that look the same (not merged in code, see README "Not done"): toast (5 copies), badge (`Bd` in MediaFrame and VariantsFrame), empty state (MediaFrame and VariantsFrame), modal shell (AppFrame template and the JS shells).
Cancel goes through `ConfirmDialog` with `tone: 'danger'`: title "Cancel this invite?", the description names the address, the cancel button reads "Keep invite" and the confirm button "Cancel invite", pending label "Cancelling…", and a failure shows a notice inside the dialog.
## CodeSnippet
One component for every code or copyable value. Props: `tabs` (`{ k, label, code }[]`), `active`, `copied`. A header row (min 44 px) holds the tabs and the Copy button; selected tab has a 2 px `--ui-primary` underline and `--ui-text-highlighted` text. With one tab the label is plain muted text (no tab). Copy reads "Copy", then "Copied" with `check` for 2 s (`--ui-success`), announced politely. Body is `pre`, IBM Plex Mono 12/19, scrolls horizontally, `tabindex="0"`. No syntax colouring. Background `--ui-bg-elevated`, border `--ui-border`.
### SettingsPage
## Editor: link
- **Toolbar button** (icon `link`, after the list buttons): default `--ui-text-toned`; hover `--ui-bg-elevated` with tooltip "Insert link (Ctrl+K)" (macOS: "Insert link (⌘K)"); focus-visible 2 px `--ui-primary` outline; active when the cursor is inside a link (`aria-pressed`, `--rc-sand` and `--rc-on-sand`); disabled at 40% opacity when an image is selected. 28 px desktop, 40 px mobile.
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor." Helper text under the toggle: "Links open in the same tab unless you turn on a new tab."
- **Link popover** (desktop): under the link, 288 px, URL in mono on top, then "Edit", "Open", "Remove" (error colour). Opens on click or Enter on a focused link; Esc closes. **Touch**: bottom sheet with the link text, the URL and three 48 px rows "Edit link", "Open link", "Remove link". Remove needs no confirmation (undo restores).
- **Rendered link**: `--ui-text-highlighted`, 1 px underline, 3 px offset. Hover 2 px underline. Focus 2 px `--ui-primary` outline, offset 2. Selected: `--rc-sand` fill, `--rc-on-sand` text.
- **Toolbar at 390 px**: the full toolbar (22 buttons) does not fit. It scrolls sideways in one row, never wraps; a 24 px fade (`--ui-bg` to transparent) marks hidden content on each side. Buttons stay 40 px.
A page header (title "Settings", subtitle "Workspace information."), then one card holding a "Version" row. See [screens/settings.md](screens/settings.md).
## Paginator
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. Desktop layout, one row: the summary on the left (it takes the free space), the page-size select next to it, the page controls right-aligned; Sites, Authors, Articles and Media draw it the same way. Buttons 32 px (44 px mobile), current page `--rc-sand` with `--ui-primary` border, disabled controls 40% opacity, focus ring 2 px `--ui-primary`. Page size and page live in the URL as `size` and `page`.
## Added with Media
Variant for Sites and Authors (no new component): when everything fits on one page the page-size select is hidden together with the controls and the footer shows only the count line "N sites" or "N authors" in `--ui-text-muted`. Loading dims the whole pager to 40% and disables it; the summary reads "Loading page N…". Sizes are unchanged: 5, 25, 50, 100.
Exact values live in `source/MediaFrame.dc.html`. Copy and states are in [media](screens/media.md) and [media-picker](screens/media-picker.md).
## Activity export
Menu: "Export current view" (CSV, active filters, all pages) and "Export all activity…" (CSV, ignores filters; owner and admin only). Dialog: Format (read-only "CSV"; a CSV and JSON control appears when JSON ships, proposed), Date range (default "All time"). States: configure, "Preparing download…" (indeterminate bar, no percentage, Cancel left, "Preparing…" disabled right), "Download started" (Close), "Couldn't prepare the download" (Cancel, Retry), "Nothing to export" (Close), 403 "You can't export all activity" (Close). Synchronous: no job, no progress, no later notification.
| Component | Nuxt UI base | Props | Emits | Used in |
|---|---|---|---|---|
| MediaToolbar | UTabs, UInput, USelectMenu, UButtonGroup | tab, q, sort, view, counts | update:tab, update:q, update:sort, update:view | Media |
| MediaGrid | custom (buttons in a grid) | items, selected: Set, loading | open(id), toggle(id) | Media |
| MediaTable, MediaCardList | UTable, custom list | same as MediaGrid | same as MediaGrid | Media (md and up, below md) |
| MediaBulkBar | custom, UButton | count | delete, clear | Media |
| MediaDetailPanel | USlideover | id | close, rename, delete, copy-url, review-duplicates | Media |
| MediaUploadPanel | UProgress, UButton | rows | use-existing(row), upload-anyway(row), retry(row), cancel, dismiss | Media |
| DuplicateGroups | custom cards | groups | merge(group, keepId) | Media |
| MediaThumb | custom | item, size | none | Media, picker |
| MediaBadge | UBadge | kind: 'unused'\|'duplicate'\|'used'\|'online'\|'draft', label | none | Media, picker |
| RenameDialog, MergeDialog, DeleteMediaDialog | ConfirmDialog, UForm | item(s) | done | Media |
| MediaPicker | UTabs inside ImageUploader | articleId | picked(item, alt) | Editor |
## FilterCardToggle
Button with `aria-pressed`; pressed uses `--rc-sand` and `--rc-on-sand` with a `--ui-primary` border; pressing again clears. Synced with the Outcome segment and the chip.
ImageUploader (changed): it gains the Upload and Library tabs and the "Already in your library" card. The remove-image confirm becomes neutral and only detaches the image from the article.
## NavItem and NavGroup
Row 32 px (44 px in the mobile drawer). Parent selected: `--rc-sand`, `--rc-on-sand`, weight 500. Child selected: parent unfilled, `--ui-text-highlighted`, weight 500, chevron-down; child filled as above. Group label "Media" 11 px uppercase `--ui-text-muted`. Children indent 19 px with a 1 px `--ui-border` rail.
## Added with Updates
## Media badges (Media library, picker, Variants board)
`Unused` (warning soft, icon unlink), `Used in N` (outline), `Duplicate` (info soft, icon copy), `N variants` (info soft, icon layers; "1 variant"; none shown at 0). The Media grid card, the list row and the mobile row show all of them. Size column and "Size" meta use the original file size only; the "with variants" size and the sort are on the Variants board (see README, Not done).
Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). Copy and states are in [settings](screens/settings.md) and [home](screens/home.md).
## Other
- **PurgeDuplicatesDialog**: `UModal`, `sm:max-w-md`, sheet on mobile, cancel first, confirm `color="error"`. Props `open`, `preview`, `status` (loading, ready, purging, nothing, error, partial, conflict). Emits `purged`.
- **MediaCleanupCard**: square `USwitch`, `UInputNumber` with "days" suffix, saves on change.
- **MediaLinksCard**: rows "Manage image variants" and "Cache usage", 44 px min height.
- **StatesMatrix**: the board "States matrix: controls".
| Component | Nuxt UI base | Props | Emits | Used in |
|---|---|---|---|---|
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings |
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta, pendingTag?, action? (in-block Restart, Ready only) | none | UpdatesCard |
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard |
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) |
| RestartOverlay | custom full-page `role="status"` | title, text, startedAt, slow | reload | App shell |
| UpdateBanner | UAlert | state: 'available'\|'ready' | go-to-settings, dismiss | Home (owner) |
| UpdatePill | UBadge | none | none | DashboardShell Settings item (dot on mobile) |
## Sites and authors (pass 3)
## Added with Media cleanup
### PurgeDuplicatesDialog
Used by Settings ("Purge now") and Media, Duplicates tab ("Purge duplicates"). Built on `UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first, confirm is `color="error"`.
- Props: `open`, `preview` (`groups`, `copies`, `articles`, `list[]`, `more`), `status` (`loading | ready | purging | nothing | error | partial | conflict`).
- While `purging`: confirm shows `loader-circle` and reads "Purging…"; `dismissible` false, Cancel disabled.
- Group row: 40×30 thumbnail, filename, "Kept · used in N articles", right-aligned "N copies merged". Max 5 rows, then "and N more groups".
- Notices sit above the summary in a `UAlert` (soft): error for failure, warning for partial and conflict.
- Emits `purged` (with counts). The parent shows the toast.
Frames: `DirectoryFrame.dc.html` (Sites, Authors, editor section content; the list, slide-over, delete-with-reassign dialog, slug field and media field are defined once there and used by both Sites and Authors); `AppFrame.dc.html` changed in place (sidebar items, Articles columns and filter, editor section and mobile details sheet, Settings card, Profile card, role-based sidebar). Existing components are reused unchanged: dialogs, toasts, empty states, skeletons, paginator, media picker.
### MediaCleanupCard
Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" suffix, read-only lines, divider, outline button. Saves on change and on blur or Enter; shows "Saving…" then "Saved" in the header. Read-only mode disables controls, hides the button and shows an info `UAlert`.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| SitesTable, AuthorsTable | New | UTable; below 640 px a list of UCard | Same row heights as Users. Defined once as DirectoryList in DirectoryFrame and used by both. Read-only variant has no actions, chevron instead of the menu |
| SiteForm, AuthorForm | New | USlideover + UForm, UFormField, UInput, UTextarea, URadioGroup, USwitch | Sheet on mobile. Close with changes opens the discard UModal |
| SlugField | New | UInput + UButton (icon) + UBadge | Defined once in DirectoryFrame. Auto badge, pencil, "Reset to match the name". Locked read-only variant (lock icon, "Used in API paths, so it can't change.") for the site key after creation |
| SocialLinksRepeater | New | USelect + UInput + UButton, in UFormField | Max 5 rows. Placeholder follows the type |
| MediaField | New | UButton + UModal (existing picker) | Defined once in DirectoryFrame. Used for the social image and the avatar |
| AuthorViewPanel | New | USlideover, read-only | Detail for roles without author rights. Close is the only action |
| AuthorAvatar | New | UAvatar | Initials fallback, square, radius 0 |
| AuthorStack | New | UAvatarGroup max=3 | Shows "+N" |
| AuthorList | New | List of UButton, UAvatar, UBadge; sorting with useSortable | No Nuxt UI match for sortable rows. Keyboard reordering is custom |
| AuthorSelect | New | USelectMenu multiple with create-item | Footer item "Create author…" opens the dialog |
| SiteSelect | New | USelectMenu | Item slot shows name and host |
| UserSelect | New | USelectMenu | Only users without an author |
| ReassignDialog | New | UModal + USelectMenu + UAlert | Defined once in DirectoryFrame. Used for site and author deletion |
| MetaSection | New | UCollapsible | Desktop sidebar and mobile sheet. Site select for anyone who can edit the article. No "Create author…" row without author rights |
| AuthorProfileCard | New | UCard + UButton + UAlert | Profile screen. Defined once in AppFrame; every Profile frame shows it |
| CreateAuthorHint | New | UAlert closable | Dismissal stored per user |
| Default badge, Linked badge, Guest badge | New | UBadge | Sand fill, info soft fill, outline |
| Row menu with a disabled item | Existing + change | UDropdownMenu + UTooltip | The item stays focusable |
| SearchSharingSection | New | UCollapsible + UButton + USeparator | Editor sidebar and Details sheet, after Site and authors. Summary: Ready, N suggestions, Hidden from search, Checking… |
| SeoPanel | New | USlideover (desktop 960 px) / bottom sheet (mobile) + UFormField, UInput, UTextarea, USwitch, UCollapsible | Defined once in DirectoryFrame. Reuses SlugField, MediaField and the picker. Fallback chip: dashed outline, "Using …" |
| SoftCounter | New | text | Mono, "n / max". Amber (--ui-warning) outside the recommended range, never red, never blocks |
| SearchPreview | New | UCard | Desktop and Mobile toggle (UTabs, 2 items). Title cut at 60 (desktop) or 70 (mobile) characters including the template, description at 160 or 120 |
| SocialCardPreview | New | UCard | 1.91:1 image, host, title, 2-line description; fallback chips |
| SuggestionList | New | UAlert-style rows + UButton (link, ghost icon) | Calm elevated block. States: list, all good, checking. Per-article dismiss; "Show dismissed" |
| ApiPreviewDialog | New | UModal / bottom sheet + UTabs + UButton + UAlert | Formatted and Raw; Copy JSON; loading, error, draft note |
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
| TitleTemplateField | New | UInput + live example | Sites form |
### Media toolbar button
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
## Added with Activity
Source: `source/activity-spec.js` (component inventory) and `source/ActivityFrame.dc.html`. All built on existing Nuxt UI v4 components and tokens; no new token.
## Custom content types (pass 4)
| Component | Nuxt UI base | Props | Emits | Slots | Used on |
|---|---|---|---|---|---|
| ActivityPage | custom page | none (reads the session role) | none | none | Activity |
| ActivitySummaryStrip | UCard (as button) | summary, loading, activeFilter | apply-filter | none | Activity (owner, admin) |
| ActivityFilterBar | UInput, USelectMenu, UButton, UBadge | filters, people, canFilterPeople | update:filters, clear | none | Activity |
| ActivityTable | UTable (day separator rows), UAvatar, UBadge, UPagination, USelectMenu | items, total, page, size, loading | open, update:page, update:size | empty | Activity (md and up) |
| ActivityCardList | UCard | same as ActivityTable | open | empty | Activity (below md) |
| ActivityDetailsPanel | USlideover (right on desktop, bottom on mobile), UCard, UButton | item, index, total, open | previous, next, close | none | Activity |
| ActivityDiff | custom list | changes | none | none | ActivityDetailsPanel |
| ActivityFeedCard | UCard, UAvatar, UButton | items, loading, error, own | retry | none | Home |
| RetentionCard | UCard, USelectMenu, UAlert, UButton | settings, canEdit | saved | none | Settings (owner) |
Frames: `SchemaFrame.dc.html` (all schema, content and entry bodies; relation, permissions and import frames are Later), mounted in `AppFrame.dc.html`. Existing pieces are reused unchanged: dialogs and sheets, toasts, alerts, paginator, skeletons, empty states, SlugField, CodeSnippet, the single switch.
- Sparkline: seven `div` bars inside the strip card, no chart library. The strip cards are real `button`s with the card as their label.
- Day separators are full-width rows of the table. The "When" cell carries the exact time as its `title`.
- The sentence is composed from the `action` code and params with the i18n messages (English and French); the target name is user content and is not translated.
- Failed and security rows use `--rc-error-soft` / `--ui-error` and `--rc-warning-soft` / `--ui-warning` for emphasis, always paired with an icon or a word, never colour alone.
**AppFrame changes.** Sidebar: a "Schema" item (icon shapes) after Users, owner and admin only. A "Content" group under the main items, one row per custom type (default icon files, name truncated with the full name in the tooltip, selected row sand). Over 5 types it shows the first 5 and the selected one, then "Show all N". Mobile drawer: the group is one collapsed row "All content types" with the count; expanded it lists the types at 44 px. New props on the shell state: `content` (types), `contentOn`, `contentOpen`. The group is hidden when there are no custom types. No existing item changed.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| SchemaTable | New | UTable; below 640 px a list of UCard | Same pattern as SitesTable. Built-in rows add the lock and a Built-in badge |
| BuiltInBadge | New | UBadge outline + lock icon | Existing badges have no lock; same size as the Linked badge |
| FieldList | New | List of UButton rows, useSortable, UBadge | No Nuxt UI sortable list. Keyboard: the grip is a button; Space picks up, arrows move, Space drops, Esc cancels. Move up and Move down buttons on the selected row. Same approach as AuthorList |
| FieldInspector | New | UForm, UFormField, UInput, UInputNumber, UInputDate, USelect, UTextarea, USwitch | Composition only. Controls follow the field type |
| FieldTypePicker | New | URadioGroup variant="card" in a grid, icon in the label | Existing radio cards fit; only the icon and the lock state are added |
| KeyField | Existing | SlugField (UInput + UButton + UBadge) | Reused for the type key and field keys. Locked variant already exists |
| ChoicesEditor | New | UInput + UButton rows, useSortable | UInputTags can't reorder or flag a blank or duplicate per row |
| ApiPreview | New | UCollapsible + CodeSnippet + UButton | CodeSnippet is reused. Sheet on mobile |
| ChangeReview | New | UModal (sheet on mobile), UBadge, list rows | Dialog pattern unchanged. Rows reuse the UAlert-style row of SuggestionList |
| SchemaRefusal, DeleteTypeDialog | New | UModal + UAlert | Dialog pattern unchanged. Delete uses color="error" |
| ContentNavGroup | New | UNavigationMenu group "Content" (NavGroup) | Adds the 5 + selected cap, "Show all N" and the collapsed mobile row |
| EntryTable | New | UTable with column visibility | Cells per field type (FieldCell). Cards on mobile |
| ColumnPicker | New | UPopover + UCheckbox list; bottom sheet on mobile | UDropdownMenu closes on each pick and has no cap message |
| SortSelect | New | USelect + UButton icon | ID, Created and Updated only |
| FieldControl | New | UInput, UInputNumber, UInputDate, USelect, USwitch (the single toggle), MarkdownField | One switch per field type, generated from the schema |
| MarkdownField | New | UTextarea + UTabs (Write, Preview) + toolbar of UButton | The article editor is a full document editor. This is a compact one |
| FieldCell | New | text, UBadge outline (choice), icon + text (yes or no) | Renders one value per type, with the empty dash |
| EntryForm | New | UForm generated from the schema | Provenance footer, schema-changed alert |
| RelationPicker (later) | New | URadioGroup card + USelect | Sentence preview is text |
| RelationField (later) | New | UInputMenu multiple | Chips and search |
| PermissionsGrid (later) | New | UTable + UCheckbox; UCard list on mobile | |
| SchemaImportDialog (later) | New | UModal + UFileUpload | |
## Media field (pass 5)
Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field mode, usage). Full spec: `media-field-changes.md`.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| MediaFieldControl | New | UFormField, UButton outline and ghost, UModal (desktop) or bottom UDrawer (mobile) for the picker, UTooltip, UIcon (image, image-off, unlink, image-plus). The thumbnail is a plain button | Props label, required, modelValue (image id or null), image (filename, width, height, size, or missing), disabled, error. Emits update:modelValue. States: empty, filled, hover, focus, error, missing, saving, read-only |
| FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px |
| MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row |
## Roles and permissions (new)
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
Accessibility of the tri-state cell:
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
## Account (change email, export, delete)
| Component | Status | Nuxt UI mapping | Where |
|---|---|---|---|
| EmailChangeForm | New | UFormField + UInput (email, password) + UButton, inside the Account UCard | Profile |
| PendingEmailNotice | New | UAlert (info) + two UButton (outline) | Profile |
| DataExportCard | New | UCard + UButton; notices as UAlert | Profile |
| DangerZoneCard | New | UCard with border var(--ui-error), UButton color error | Profile |
| DeleteAccountDialog | New | UModal (existing destructive ConfirmDialog shell) + UInput x2 + list + UAlert info + ULink | Profile |
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
+12
View File
@@ -14,3 +14,15 @@ Previews: `source/RenewCMS Emails.dc.html`.
Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
## Account emails (change email, deletion, export)
Go `html/template` files built from `source/emails/verification.html` (same table layout, tokens, dark overrides, bulletproof button, URL fallback). English only.
| File | To | Subject | Variables |
|---|---|---|---|
| `source/emails/verify-new-email.html` | the new address | Confirm your new RenewCMS email address | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/email/confirm?c={{.VerificationCode}}` (expires in 2 hours, works once) |
| `source/emails/email-change-notice.html` | the old address, at request time | Your RenewCMS email address change was requested | `.Host`, `.Username`, `.NewEmail`, linking to `{{.Host}}/profile` |
| `source/emails/account-deleted.html` | the address the account had, sent once | Your RenewCMS account has been deleted | `.Host`, `.Username`, `.DeletedAt`; no button, generic text, no personal data |
| `source/emails/export-ready.html` | the account address, async flow only | Your RenewCMS data export is ready | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/export/{{.VerificationCode}}` (expires in 24 hours, requires a logged-in session) |
`export-ready.html` is only needed if the async export alternative is built. Previews are rows on `source/RenewCMS Emails.dc.html`.
+85 -165
View File
@@ -1,167 +1,87 @@
# Foundations
Token source files (copy them into `web/src/theme/` in the Nuxt UI foundation slice, which then becomes the live source):
- `source/tokens/renewcms.css`: `@theme`, `:root`, `.dark` and the `.article-content` scale
- `source/tokens/ui.config.ts`: Nuxt UI color aliases, icons and component `ui` overrides
- `source/tokens/vite.config.ts`: the `@nuxt/ui/vite` plugin setup
- `source/tokens/tokens.json`: the same values as data
- `source/brand/favicon.svg`: the mark, dark-aware
Extracted from `source/RenewCMS Foundations.dc.html`:
## Ledger foundations
Everything below is generated from one set of files: tokens/renewcms.css (Tailwind @theme , :root , .dark , article content styles), tokens/ui.config.ts (Nuxt UI colors, icons and component overrides) and tokens/vite.config.ts . The tables on this page read those files, so they can't drift.
### Logo
The mark is a white R on a black square, drawn on a 16-pixel grid so it stays sharp as a favicon. It keeps the same geometry at every size. In dark mode the black square gets a 1 px #444 edge so it doesn't disappear into the background. The wordmark pairs the mark with RenewCMS in Plex Sans Semibold, with the text set at 0.6× the mark's height.
RenewCMS
16
32
64
128, app icon source for 180 / 512
Articles · RenewCMS
RenewCMS
16
32
64
128
Articles · RenewCMS
Favicon file: brand/favicon.svg . The 1 px edge switches on with prefers-color-scheme: dark . Keep clear space of at least half the mark's height around it. The R is never recolored or placed on sand.
### Color scales
Seven Tailwind color scales, each mapped to a Nuxt UI alias. The ink, chalk and sand scales are built on your brand values. The four semantic scales are new and deliberately muted: evenly spaced oklch lightness steps with low chroma, so they sit comfortably next to ink and sand.
### Theme variables
Every --ui-* variable Nuxt UI reads, for both themes. Surfaces are white. The #fafafa brand background is the page canvas behind them. The theme follows the system setting until the owner picks Light or Dark from the user menu, and that choice is stored by Nuxt UI's color mode.
Variable Used for Light Dark
### Contrast checks
Pair Needs Light Dark
### Typography
The interface uses IBM Plex Sans in three weights: 400, 500 and 600. IDs, paths, counters and code are set in IBM Plex Mono. Sizes replace Tailwind's text-* defaults. On screens narrower than md , form fields use 16 px so iOS doesn't zoom in when a field gets focus.
Before and after: the Rue Verte kitchen
GET /v1/article/12 · 8 412 / 10 000
font-mono · 13px / 20px · 400 Code, paths, IDs, counters
Article content uses a separate scale in Source Serif 4, applied through the .article-content class. The editor surface and the preview both use it, so what the owner writes looks like what a public site would roughly show. The measure is capped at 42 rem, about 70 characters per line.
Choosing lime plaster for old stone walls
Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on .
What we use
Hot-mixed lime for the base coat
Hair or fibre for the scratch coat
A thin lime putty finish
Drying time
It sets slowly, and that is the point.
Before you start
Strip loose material back to sound stone.
Wet the wall the evening before.
Article image
The north wall after the second coat.
Element md and up Below md, spacing
### Spacing, radius and density
Spacing uses Tailwind's 4 px base unit unchanged. Radius is 0 everywhere: setting --ui-radius: 0rem flattens every component that derives its corners from it, and rounded-none overrides cover the few that hard-code rounded-full . The density is compact on desktop. On touch screens, every tap target is at least 44 px.
Control Height and use
size="sm" 28 px · table row actions, toolbar buttons in the editor
size="md" 32 px · default on desktop: buttons, inputs, selects, tabs
size="lg" 40 px · auth forms, primary actions in dialogs
size="xl" 44 px · everything below md, via a responsive size prop
Table row 44 px on desktop, 64 px cards on mobile
Gutters 16 px on mobile, 24 px on desktop; sidebar 224 px, collapses to 56 px
### Borders and elevation
Most surfaces are flat and separated by hairlines. Shadows appear only on things that float above the page.
Hairline 1 px --ui-border . Cards, table edges, sidebar and top bar dividers. Decorative, so no contrast requirement.
Search by title
Control edge 1 px --ui-border-accented . Inputs, selects, checkboxes, outline buttons. 3.6:1 on white.
New article
Focus ring 2 px --ui-primary outline with a 2 px offset, shown only on :focus-visible . Nuxt UI's default, kept.
Edit
Unpublish
Popover --shadow-popover . Dropdown menus, selects, toasts. In dark mode it becomes a darker shadow plus the hairline.
Modal --shadow-modal over a 40% chalk-950 overlay. Modals and slideovers.
Layer z Contents
Breakpoint Min What changes
### Motion
Motion is there to show where something came from or that something is working. It is short and never decorative.
120 ms --duration-fast · ease-standard Hover and pressed colors, focus ring, the counter turning to warning, the switch thumb.
180 ms --duration-base · ease-enter / ease-exit Dropdowns and tooltips (fade plus a 4 px shift), the tab indicator, toasts sliding in, SaveStatus crossfades.
240 ms --duration-slow · ease-enter / ease-exit Modals (fade plus scale from 0.98), slideovers (slide from the edge), sidebar collapse.
Never animates Route changes, table rows sorting or filtering, numbers counting up, the logo, editor text, and anything that runs on page load.
Reduced motion With prefers-reduced-motion: reduce , every transition and animation completes instantly, skeletons stop pulsing and become flat blocks, and toasts appear without sliding. The one exception is the spinner on the automatic verification screen. It keeps turning, slowed to 1.6 s per rotation, because it is the only sign that work is happening.
### Icons
Lucide through Iconify, using Nuxt UI's i-lucide-* classes. Icons are 16 px next to 13–14 px text and 20 px on mobile top bars. They are always paired with a visible label or an aria-label . Each icon maps to one meaning: Eye is only ever show password, and Preview uses Book Open.
### Where Nuxt UI defaults bend
### Files
Ready to paste into the repository. They follow its code style: tabs, no semicolons, single quotes and no comments. The CSS goes in src/assets/main.css and the config in src/theme/ui.config.ts . Add class="isolate" to the app root, as Nuxt UI requires.
Tokens only. No raw hex or rgb in any frame. The one exception is the brand mark SVG (`#111` and `#fff` fills). Radius is 0 everywhere (no `border-radius` is declared in any frame).
## New tokens (do not exist in the app yet)
`tokens/renewcms.css` defines no `--rc-*` token. The frames use the ones below. They are **new**. Add them to the app's CSS, light values in `:root` and dark values in `.dark`. No `--ui-*` token is changed.
| Name | Light | Dark | Used by |
|---|---|---|---|
| `--rc-sand` | #e8dfd1 | #d4c5b0 | Selected nav item (parent and child), selected table row and media card, current paginator page, pressed filter card, avatars, account chip, "In this article" tag, drag-drop fill |
| `--rc-on-sand` | #111111 | #111111 | Text and icons on `--rc-sand`. Never use `--ui-text-muted` or white on sand |
| `--rc-success-soft` | #eaefea | #1f241f | Success badge, success alert and toast icon ground |
| `--rc-info-soft` | #ebeef0 | #202428 | Info badge, info alert, "variants" badge, duplicate badge |
| `--rc-warning-soft` | #f0ede6 | #2a251e | Warning badge and alert, "Unused" badge |
| `--rc-error-soft` | #f3eae9 | #2c201f | Error badge and alert, failed-actions card |
| `--rc-overlay` | rgb(10 10 10 / 0.4) | rgb(0 0 0 / 0.6) | Dialog, sheet and slide-over scrim; crop mask |
| `--rc-shadow-pop` | 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06) | 0 4px 16px rgb(0 0 0 / 0.5) | Menus, popovers, toasts. Same as `--shadow-popover` |
| `--rc-shadow-modal` | 0 16px 48px rgb(17 17 17 / 0.18) | 0 16px 48px rgb(0 0 0 / 0.6) | Dialogs, sheets, slide-overs. Same as `--shadow-modal` |
| `--rc-canvas` | #ecebe8 | #1c1c1c | Design boards only. Not for the app |
Implementers may alias `--rc-shadow-pop` and `--rc-shadow-modal` to the existing `--shadow-popover` and `--shadow-modal`. The Nuxt UI `UAlert`/`UBadge` `soft` variants can map to the four `-soft` tokens.
## Contrast (computed, AA: text 4.5:1, UI components 3:1)
Light | dark. Computed with the WCAG relative luminance formula from the token values above and the `--ui-*` values in the board root.
| Pair | Light | Dark | Result |
|---|---|---|---|
| Success, info, warning, error on their `-soft` ground (badges, alerts) | 6.7 / 6.9 / 7.1 / 7.3 | 8.7 / 8.4 / 8.1 / 8.1 | Pass |
| Neutral badge: `--ui-text-toned` on `--ui-bg-elevated` | 8.7 | 10.9 | Pass |
| Chip, selected row, current page: `--rc-on-sand` on `--rc-sand` | 14.3 | 11.2 | Pass |
| Neutral chip: `--ui-text-highlighted` on `--ui-bg-accented` | 14.9 | 15.2 | Pass |
| Toast title on `--ui-bg` | 18.9 | 18.1 | Pass |
| Toast body `--ui-text-toned` / `--ui-text-muted` on `--ui-bg` | 9.7 / 5.7 | 11.8 / 6.6 | Pass |
| Banner (offline): `--ui-text-inverted` on `--ui-bg-inverted` | 18.9 | 18.1 | Pass |
| Table text `--ui-text` on `--ui-bg`; on hover `--ui-bg-elevated` | 15.9; 14.2 | 14.9; 13.7 | Pass |
| Table header and hover meta: `--ui-text-muted` on `--ui-bg-elevated` | 5.1 | 6.1 | Pass |
| Tab selected / unselected / count | 18.9 / 9.7 / 5.7 | 18.1 / 11.8 / 6.6 | Pass |
| Segmented: selected `--ui-bg` on `--ui-primary`; unselected `--ui-text-toned` on `--ui-bg` | 18.9; 9.7 | 18.1; 11.8 | Pass |
| Placeholder `--ui-text-muted` on `--ui-bg` | 5.7 | 6.6 | Pass |
| `--ui-text-muted` on `--rc-sand` | 4.3 | 1.7 | **Fails.** Never place muted text on sand. Frames use `--rc-on-sand` |
| `--ui-text-dimmed` on `--ui-bg` | 2.8 | 3.3 | **Fails.** Not used for text. Allowed for disabled icons and decorative strokes |
| Disabled controls at 40% opacity (label on `--ui-bg`) | 2.6 | 3.7 | Exempt (inactive component). Never carry information only in a disabled label |
| Disabled table row at 55% opacity | not computed | not computed | Exempt (inactive) |
| UI: `--ui-border-accented` on `--ui-bg` (input, checkbox, segmented, secondary button borders) | 3.6 | 3.3 | Pass (3:1) |
| UI: focus ring `--ui-primary` on `--ui-bg` | 18.9 | 18.1 | Pass |
| UI: `--rc-sand` fill against `--ui-bg` | 1.3 | 11.2 | **Fails in light** as the only indicator |
| UI: `--ui-border` on `--ui-bg` | 1.3 | 1.5 | Decorative only (card and table lines). Never the only boundary of a control |
Fixes applied (token swaps):
- Current page in the paginator: border changed from `--rc-sand` to `--ui-primary` (18.9 / 18.1) so the state does not rely on a 1.3:1 fill.
- Selected media cards and rows keep `--ui-primary` outline with the sand fill (already so).
- Selected nav items still rely on the sand fill plus weight 500 and `aria-current="page"`. The fill alone is 1.3:1 in light; the weight change and the programmatic state carry the information. This is accepted and recorded here, not "fixed".
- Rule added: `--ui-text-muted` is never used on `--rc-sand`.
Not computed: hover and active backgrounds of every control against every text colour; the states-matrix uses combinations already listed above.
## One value per role (spacing, radius, type, icons)
Sweep of the frames (`AppFrame`, `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `ApiKeysPage`, `CodeSnippet`, `AuthCard`). "Found" is what was drawn before pass 2.
| Role | Found | Value now (desktop / mobile) |
|---|---|---|
| Page padding | 32 px (Home, Users, Settings, Integration, API keys), 24 px (Media, Activity, Variants); mobile 20 16 or 16 | 24 px / 16 px. Extra bottom padding only to clear a fixed bar (88 or 96 px) |
| Section gap on a page | 20, 28 (Home, Settings), 16 elsewhere | 16 px |
| Card padding | 16 px and 20 px | 16 px |
| Card inner gap | 8, 12, 14, 16 | 12 px (6 px between a label and its control, 8 px between buttons) |
| Banner and alert padding | 10 12, 12 14 | 12 14 |
| Radius | none declared | 0 |
| Page title | 28/36 (Home, Integration, API keys), 24/32 (Variants), 20/28 (rest) | 20/28, weight 600, same on mobile |
| Section heading (reference pages) | 18/26 | 18/26 |
| Card title | 14/22, 15/22, 15/24 | 15/24, weight 600 |
| Sub heading | 14/22 | 14/22 |
| Dialog title | 16/24 | 16/24 |
| Stat figure | 28/36 (Home), 24/32 (Activity), 22/36 (Variants mobile) | 24/32, mobile 22/32 |
| Error and 404 page title | 24/32 | 24/32 (unchanged) |
| Editor article title | 18/26 | 18/26 (own role) |
| Body text | 13/20 | 13/20 |
| Small and meta text | 12/18 | 12/18 |
| Icon in text and buttons | 16 (91), 14 (87), 12 (20), 13 (1), 18, 20, 22, 28 | 16 px. 14 px inside compact controls and badges (12 px in badges). 20 px in the mobile top bar. 18 px in mobile list rows and sheet rows. The 13 px icon became 14. Auth card (22) and the empty-state glyph (28) stay as illustration sizes |
| Control height (button, input, select) | 28, 32, 40 (inputs in API keys, Integration, Images) | 32 px / 44 px. Inline row buttons 28 px on desktop, 44 px on mobile. The 40 px inputs became 32 / 44 |
| Dialog | top offset 120 and 140, centered (export), padding 20 | width 460 (560 wide, 640 crop), 140 px from the top, header 20 24 0, body 20 24 0, footer 20 24 |
| Table row | 40, 48, 56 | 48 px; media rows 56 px (thumbnail); mobile 56 px |
## Pairings
- Selected rows use `--rc-sand` with `--rc-on-sand` text and icon in both themes. Hover, focus-visible and active on a selected row keep sand. Focus adds a 2 px `--ui-primary` outline.
- Child selected: the parent row is not filled. It uses `--ui-text-highlighted`, weight 500, chevron down.
- Focus-visible: 2 px `--ui-primary`, offset 2 px (inset -2 px for tabs, segmented controls and rows).
- Disabled: 40% opacity (table rows 55%).
- Hover, active and the rest: board "States matrix: controls" (`StatesMatrix.dc.html`). Hover and active tokens: primary button hover `--ui-text`, active `--ui-text-toned`; secondary and ghost hover `--ui-bg-elevated`, active `--ui-bg-accented`; destructive hover opacity 0.85, active 0.75; input hover border `--ui-text-muted`, focus border `--ui-primary`.
+121
View File
@@ -0,0 +1,121 @@
# Media field type: change list for implementation
Board: `RenewCMS Screens Schema.dc.html`. Frames changed: `SchemaFrame.dc.html`, `MediaFrame.dc.html`. Spec data: `schema-spec.js` (states) and `media-field-tables.js` (tables, appended to the board). No new tokens, no new colors, radius 0.
## 1. Behaviour
- New field type `media`, name "Media", icon `lucide:image`, description "One image from your media library".
- Stored value: id of a library image, or null. The file is never copied. The field always shows the current library image.
- Options: label, key, required. No default, unique, min, max or choices. The inspector hides them.
- Required: the entry can't be saved while the field is empty (422 invalid_entry, rule required).
- A saved field's type never changes. Key and type are locked after the first save (existing rule).
- Adding a required media field to a type with entries is refused, because a media field can't have a default. The review step shows this before the server is asked.
- Image deleted while entries point at it: the entry keeps the id. The control shows the missing state. Saving is refused only if the field is required.
- Entries count as uses of an image in Media. Deleting a used image is allowed after a warning.
- Variants: the editor preview uses the original. No variant select in the field. Nothing about variants in the builder.
- Galleries (several images) are out of scope and not drawn.
## 2. New component: MediaFieldControl
Props: label, required, modelValue (image id or null), image (filename, width, height, size, or `missing: true`), disabled, error. Emits `update:modelValue`. Opens the picker.
Nuxt UI v4: `UFormField` (label, help, error, required), `UButton` outline ("Pick an image", "Change image", "Choose another image"), `UButton` ghost ("Remove", "Clear"), `UModal` on desktop and bottom `UDrawer` on mobile for the picker, `UTooltip` for the list placeholder and the badge, `UIcon` lucide:image, image-off, unlink, image-plus. The thumbnail is a plain `button`.
States and measurements (SchemaFrame `MCtl`):
| State | Drawing |
|---|---|
| Empty | Dashed box (1px dashed `--ui-border-accented`), padding 12, 40 px icon square (`--ui-bg-elevated`), "No image selected.", button "Pick an image" (icon image-plus). Mobile: stacked, 44 px button |
| Filled | Solid 1px `--ui-border-accented` box, padding 12. Thumbnail 160 × 120 (mobile: full width, 16:9). Filename weight 500, then "1600 × 900 · 240 KB" 12/18 muted. Buttons "Change image" (outline, icon image) and "Remove" (ghost, icon x). Help line "One image from your library." |
| Hover (desktop) | Border `--ui-text-muted`. Bottom bar 28 px, `--ui-bg-inverted` with `--ui-text-inverted`, icon image 14 and "Change image". No hover on mobile |
| Focus | 2 px `--ui-primary` outline, offset 2, on the thumbnail button |
| Error | Box border `--ui-error` plus inset 1px `--ui-error`, error line with circle-alert 14: "Pick an image for Thumbnail." Focus ring on the first button (Pick an image, or Choose another image) |
| Missing | Dashed 160 × 120 (mobile 16:9) placeholder with image-off 24. Text "This image is no longer in the library" (weight 500). Help: optional "You can still save. The entry keeps the link until you change it." / required "Thumbnail is required. Choose another image to save." Buttons "Choose another image" (outline) and "Clear" (ghost) |
| Saving | Thumbnail at 60%, buttons at 40%, all form fields disabled, Save reads "Saving…" |
| Read-only (fields changed) | Same as saving, plus warning alert "The fields of Events changed" and button "Reload form" |
Label is the field label, plus " *" when required (existing form rule; "(optional)" is not used).
## 3. Per screen
### 3.1 Schema list (group 1), state "Custom type: read-only view"
Fields table of a custom type (Label, Key, Type, Rules) in the built-in table style. Thumbnail row: image icon, key `thumbnail`, type "Media", rules "One image from the library". Header "Events" with badge "Read only" (lock). Alert: "You can read these fields here" / "Only an owner or admin can change a content type." The built-in Media type in Schema is unchanged. Decision needed: no route shows a custom type read-only today.
### 3.2 Schema builder (group 2), 3 states
- "Media field selected": saved Thumbnail row selected (index 7). Inspector: Label, Key (locked, "Used in the API, so it can't change."), Type tiles (seven, Media selected, others dimmed with "Can't change after saving"), Required switch ("Required" / "An entry can't be saved with this field empty."). Hidden for media: Unique values, Default value, limits, choices. Hint (info-soft block, info icon): "Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."
- "New media field, seven types": new row with "New" badge, key editable ("The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."), type tiles all enabled. State chip "Unsaved changes".
- "Media field, required": row shows the existing Required marker.
- Field row: grip, 28 px icon square (image), label, key (mono), badges. Mobile: inspector in the existing 92% sheet (title "Field: Thumbnail", footer "Done").
- Type tile grid now has seven tiles (3 columns desktop inspector, 2 in sheets).
### 3.3 Review (group 3), 2 states
Dialog "Review changes to Events" / "Apply them to save the content type." Summary "Add 1 field" / "Events has 12 entries. Each one is checked against the new fields when you apply." The info bar in the builder behind reads "12 entries use this content type…".
- Refused: row "Add Thumbnail", warning flag "Can't be applied", text "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it." Note "Back to editing keeps your other changes." Buttons "Back to editing", "Apply changes" (disabled). This departs from the existing review (Apply is never disabled): decision.
- Clean: row "Add Thumbnail", "Media, optional. 12 entries are unaffected." Apply enabled.
### 3.4 Field types (group 4), state "Media"
Per-type frame like the others: header icon image, "Media", "One image from your media library", `type: "media"`. Columns: inspector options, entry form control (filled with hover, then empty), list cell (Filled thumbnail, Empty "–", Image missing), errors by rule, filter (later).
Errors by rule: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options, unique: "Not used for media." Filter widget: "Thumbnail: has an image" with Any / Has an image / No image, tagged Later.
### 3.5 Entry list (group 5), 3 states
Columns shown: Title, Thumbnail, Starts on, Category (user choice). Header "Thumbnail", no sort icon, column 80 px. Cell: 40 px square thumbnail; "–" when empty; dashed 40 px square with unlink icon, accessible name "Image no longer in the library", when the image is gone. Row height stays 48 px (thumbnail fits with 4 px above and below). Mobile card: 32 px thumbnail leads the card (empty: "–", missing: dashed square), card min height 68 px. Columns menu lists Thumbnail with the image icon ("4 of 6", "Choose up to 6 fields to show. 4 selected." on mobile). Tooltip on the missing placeholder (desktop): "Image no longer in the library".
### 3.6 Entry editor (group 6), 10 states
Control sits after Description, at the end of the form (schema order; see conflicts). States: empty optional, empty required, filled, hover, focus, validation error (status chip "Not saved"), image no longer in the library, missing + required (error), saving, read-only. The board scrolls the form so the control is visible in the frame; the real form is not scrolled.
### 3.7 Picker (group 10), 8 states, MediaFrame field mode
Existing picker (Upload and Library tabs, single selection, search "Search by filename", "Newest first · 48 images", Load more, 4 columns desktop and 3 on mobile, square tiles). Differences:
- Title "Choose an image".
- Primary "Use this image" (disabled until a tile is chosen). When "No image" is chosen it reads "Remove image".
- No alt text field, no variant select. Selected strip shows thumbnail, filename, "dimensions · size".
- Current image tile badge "Current" (replaces "In this article").
- Optional fields only: row "No image" / "Leave this field empty." at the top of the Library tab. Selected: sand fill, primary border, check.
- Empty: "Your library is empty" / "Images you upload appear here, ready to use in any article or entry." / "Upload an image".
- Failed: "Couldn't load your library" / "Check your connection and try again." / "Try again".
- Loading: skeleton tiles after 150 ms.
- Upload tab: unchanged flow (crop, resize); the new image lands in the library and is selected.
- Desktop dialog 640 wide, mobile bottom sheet.
### 3.8 Media library (group 11), 4 states
- Badge "Used in N" = articles + entries. The tooltip (hover or focus) reads "Used in entries: 3" and, if articles also use it, "Used in articles: 2" on the first line. Example: bathroom-tiles.png, 3 entries, "Used in 3".
- List view: same badge in the list row and mobile card.
- Detail panel (lime-plaster.jpg, 2 articles, 5 entries): preview is shorter when the image has entries; existing "Used in 2 articles" list; new section "Used in entries" with up to 3 rows (type name muted, entry title as link, arrow-up-right) and "and 2 more". When there are entries the empty-articles line reads "No article shows it." (not "safe to delete").
- Delete: title "Delete an image that's in use?" (existing in-use dialog), body `"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can't be undone.` with the entries and articles lists; "Cancel" and destructive "Delete image".
## 4. Copy added (exact)
Media · One image from your media library · Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version. · No image selected. · Pick an image · Change image · Remove · Choose another image · Clear · One image from your library. · This image is no longer in the library · You can still save. The entry keeps the link until you change it. · Thumbnail is required. Choose another image to save. · Pick an image for Thumbnail. · Thumbnail must be an image from the library. · Not used for media. · Image no longer in the library · Choose an image · Use this image · Remove image · Current · No image · Leave this field empty. · Used in entries · and 2 more · Used in entries: 3 · Can't be applied · Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it. · Media, optional. 12 entries are unaffected. · The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.
Sentence case, "image" never "asset", buttons are verbs.
## 5. API and backend
- Schema API accepts type `media` with label, key, required. Value: image id or null.
- 422 invalid_entry: required → "Pick an image for Thumbnail."; type (id not in the library on a changed value) → "Thumbnail must be an image from the library."
- Entry and list responses include, per media field, filename, width, height, size and a thumbnail URL, or `missing: true`.
- GET /v1/media/{id}/usage returns articles and entries (content type name, key, entry id, title), a total and the first few items. GET /v1/media/ list items carry the combined use count.
- DELETE /v1/media/{id} stays allowed for used images. Entries keep the id.
- A new required media field on a type with entries is refused (schema_incompatible). The client shows the refusal first.
HTTP to copy (picker and field), from states.md: 400 "That request wasn't valid. Check the fields and try again."; 401/403 global; 404 "That item no longer exists."; 409 "This changed somewhere else. Reload and try again."; 413 "That file is too large."; 429 "Too many requests. Try again in a few minutes."; 500/network "Something went wrong on our side. Nothing was changed. Try again in a moment."
## 6. Accessibility
- Control is `role="group"` labelled by the field label. Required: `aria-required` on the main button.
- Names: "Pick an image for Thumbnail"; thumbnail "Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB"; "Remove image from Thumbnail"; "Clear Thumbnail"; missing placeholder `role="img"` "Image no longer in the library".
- Error: error text has an id. The main button gets `aria-invalid="true"` and `aria-describedby` (error id, then help id). Focus moves to the first invalid control on a refused save.
- Order: label, thumbnail, Change image, Remove, help or error. The thumbnail duplicates Change image; use `tabindex="-1"` on it if one stop is preferred.
- One polite live region: "Image selected: lime-plaster-workshop.jpg", "Image removed from Thumbnail.", "Thumbnail cleared."
- Focus: Use this image → Change image. Remove or Clear → Pick an image. Cancel or Esc → the opener.
- Picker: tiles are buttons with `aria-pressed`, names "stone-steps.jpg, 310 KB, Current". "No image" has `aria-pressed`. "Use this image" uses `aria-disabled`.
- Contrast: Current badge and selected rows use `--rc-on-sand`; hover bar 18.9 (inverted pair). 44 px targets on mobile. No hover on mobile.
## 7. Conflicts to decide
1. Row height: design is 48 px (56 px media rows, 68 px cards), not 32 px. Drawn at 48 and 68.
2. Thumbnail position: existing form has Description last. Drawn after Description (end of form).
3. "Used in N" counted articles. Now articles + entries.
4. Delete title: existing in-use title kept; "Delete this image?" stays for unused images.
5. "Clear" vs glossary "Remove".
6. "1600 × 900 · 240 KB" (design separator) instead of "1600 x 900 - 240 KB".
7. Review step disables Apply for the certain refusal; existing review never does.
8. Picker button "Use this image" vs article picker "Insert image"; "Remove image" when No image is chosen is my addition.
9. "Pick an image" also on optional empty fields.
10. No read-only route for custom types.
11. Two tab stops for the same picker (thumbnail and Change image).
12. Disabled opacity: form 60%, foundations 40%.
13. The picker frame shows a stand-in entry form, not the real editor.
## 8. Not done
README.md, states.md and the screens/*.md files are not updated with the new counts (group 2 +3, 3 +2, 4 +1, 5 +3, 6 +10, 1 +1, new groups 10 (8) and 11 (4): 32 states). Light/dark and mobile frames were checked by reading the DOM, not state by state on screen.
+80 -25
View File
@@ -1,28 +1,83 @@
# Route map
# Routes
A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it resolves the session with `GET /v1/auth/me` and caches the result in the session store. `useApi` turns any 401 into `/login?redirect=<path>&reason=expired` and any 403 from an unverified account into `/register/pending`.
| Route | Screen | Query | API |
|---|---|---|---|
| /settings | Settings (Version, Media card, Media cleanup, Retention) | none | GET/PATCH /v1/media/purge-settings, GET/PATCH /v1/settings |
| /settings/media/variants | Image variants (admin) | none | GET/PATCH/DELETE /v1/image-variants, POST /v1/image-variants/{name}/purge |
| /settings/media/cache | Cache (admin) | none | GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} |
| /media | Media library | page, size, q, filter, sort, view | GET /v1/media/?page=&size=&q=&filter=&variants=&sort= (page, size; total in the `X-Total-Count` header) |
| /media (detail) | Variants tab | none | GET /v1/media/{id}/variants, POST and DELETE /v1/media/{id}/variants/{name} |
| /articles | Articles list | page, size, tab, q | GET /v1/article/?page=&size=&q=&status= (page, size; total in the `X-Total-Count` header; array body unchanged) |
| /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) |
| /integration | Integration | none | none |
| /settings/sites | Sites (owner only; other roles get a 403 page and no sidebar entry): list, add and edit slide-over, delete with reassignment. The key is read-only after creation | none | GET/POST /v1/site/, PATCH/DELETE /v1/site/{id} (DELETE takes `reassign_to`) |
| /authors | Authors, all roles. Owner and admin: list, search, add and edit slide-over, delete with reassignment. Other roles: read-only list and detail | q, page, size | GET/POST /v1/author/, PATCH/DELETE /v1/author/{id}, POST /v1/author/me |
| /articles | Articles list gains Authors column; Site column and filter when more than one site | site | GET /v1/article/?site={key} |
| /article/:id/edit | Editor gains the "Site and authors" section (Site select and authors, any role that can edit the article) | none | PATCH /v1/article/{id} {site_id, author_ids[]} |
| /profile | Profile gains the "Author profile" card, all roles | none | POST /v1/author/me |
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
| /schema | Schema list, custom and built-in types (owner or admin; others get a 403 page and no sidebar entry). Available now | none | GET /v1/content-type |
| /schema/new | Schema builder, new type (owner or admin). Available now | none | POST /v1/content-type |
| /schema/:key | Schema builder for a custom type, read-only view for a built-in (owner or admin). Review step on save when the type has entries. Available now | none | GET/PUT/DELETE /v1/content-type/{key} |
| /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= |
| /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
| /settings/permissions | Retired. 301 to /settings/roles (see Roles and permissions below) | none | not built |
| Path | Layout | Auth | Screen | API calls |
|---|---|---|---|---|
| `/` | none | any | Redirect | none; goes to /home or /login |
| `/login` | AuthShell | public | LoginPage | GET /v1/auth/registration · POST /v1/auth/login |
| `/register` | AuthShell | public | RegisterPage | POST /v1/auth/register |
| `/register/pending` | AuthShell | session | PendingPage | DELETE /v1/auth/register · POST /v1/auth/logout |
| `/register/validate` | AuthShell | public | VerifyPage | POST /v1/auth/verify (+ POST /login inline on 401) |
| `/register/reset/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/activity?size=5 · GET /v1/system/update (owner, for the banner) |
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
| `/activity` | DashboardShell | verified | ActivityPage + ActivityDetailsPanel | GET /v1/activity · GET /v1/activity/summary (owner, admin) · GET /v1/activity/export (owner, admin) |
| `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · PATCH /v1/settings (owner, retention) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
## Pagination in the URL (Articles, Media)
`/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
**API:** `GET /v1/article/` accepts `page` (default 1) and `size` (5, 25, 50, 100; default 25), as `GET /v1/activity` does. The body stays a JSON array so existing consumers keep working; the total comes back in `X-Total-Count` (the response shape is to be confirmed with the specifier). Anonymous callers only see online articles, and the total counts only those. Invalid `page` or `size` answers 400, the same as Activity.
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `any` applies to redirects and the catch-all.
**Media API:** `GET /v1/media/` takes the same `page` and `size`, with the same 400 and the total in `X-Total-Count`; the array body stays. The earlier `cursor` and `limit` parameters are replaced by them.
**Page size memory:** the size chosen on Articles, Activity or Media is remembered for the user and used when the URL has no `size`; an explicit `size` in the URL wins.
**SPA:** never shows that 400. A `size` outside 5, 25, 50, 100 or a `page` that is not a positive integer is replaced in the URL by the default (`history.replace`), silently. A `page` past the last page is replaced by the last page and the notice "That page doesn't exist. Showing the last page." appears.
## Activity pagination
`page` and `size`, allowed sizes 5, 25, 50, 100, default 25 (as implemented).
## Activity export
`GET /v1/activity/export`: implemented. CSV only, synchronous (one streamed download, no job, no progress, no later notification). Accepts the list filters q, actor, category, outcome, security, range, from, to. Owner and admin only; others get 403. "Export current view" calls it with the active filters. "Export all activity" calls it with no filters and an optional `from`/`to` range. JSON (`format=json`) is **proposed**: new backend feature.
## Public image request
`/media/photo.png?v=<variant-name>`. Unknown or disabled variant: 400 `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "<name>" }`. Any other query parameter: 400 `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "<parameter>" }`. Missing source image: 404.
## Sites and authors in the URL
`/articles?site=journal` filters by site key; it combines with `page`, `size`, `tab` and `q`, and resets `page` to 1 when it changes. An unknown key is dropped from the URL with no error. `/authors?q=camille&page=2&size=25` follows the same pagination rules as Articles. Deep links: `/authors?edit={slug}` and `/settings/sites?edit={key}` open the slide-over for owner and admin (authors) and the owner (sites); an unknown value shows the info toast "That author no longer exists" or "That site no longer exists". For other roles `/authors?edit={slug}` opens the read-only detail, and `/settings/sites` and its `?edit=` link show the 403 page "You don't have access to Sites".
Sites pagination: `/settings/sites?q=&page=&size=` with the same rules as Articles (sizes 5, 25, 50, 100; default 25; defaults omitted; search and size changes reset to page 1; a page past the end falls back to the last page). API: GET /v1/site/?q=&page=&size=, total in `X-Total-Count` (design assumption). Authors: `/authors?q=&page=&size=` as above.
## SEO
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
## Custom content types in the URL
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
## Roles and permissions
| Route | Purpose | Access |
|---|---|---|
| /settings/roles | Roles list | Roles: view |
| /settings/roles/new | New role | Roles: manage |
| /settings/roles/:id | Role editor (read-only without manage) | Roles: view |
| /settings/permissions | Retired. 301 to /settings/roles | n/a |
API: GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me · GET /v1/permission. Errors: 403 missing_permission, 403 role_locked, 409 role_has_members, 409 role_name_taken, 409 role_conflict.
Needs backend:
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). |
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. |
## Account: change email, export, delete
| Route | Screen | Auth | API (assumed) |
|---|---|---|---|
| /account/email/confirm?c=CODE | Verify new email (AuthCard). States in screens/account.md | Session required; without one, inline log in (as /register/validate) | POST /v1/auth/email/confirm |
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
| /login?reason=account-deleted | One-time message after deletion | Public | none |
+26 -103
View File
@@ -1,114 +1,37 @@
# Accept invite
- **Route:** `/invite/accept?c=CODE`
- **API:** GET /v1/auth/invite?c=CODE (pending) · POST /v1/auth/invite/accept (pending)
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Accept invite" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/invite/accept?c=CODE`. API: GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending).
Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint, with no confirm field. On success the person is logged in and sent to /home with a toast. The account is already verified, because the invite went to their email, so it skips the pending and verify screens.
Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.
Neither endpoint exists yet:
Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Accept invite". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
- `GET /v1/auth/invite?c=CODE` returns `{ email }`, or 404 or 410.
- `POST /v1/auth/invite/accept` takes `{ code, username, password }`. It returns 200 with the session cookie, 409 for a taken username, or 404 or 410 as above.
## Copy per state (generated from the board source, pass 2)
Registration stays closed to everyone else: [register.md](register.md) "Registration closed" still applies to `/register`. The board does not draw the case of a logged-in person opening the link; it is not specified.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
## States
### Base copy
- Accept your invite
- Choose a username and password to finish setting up your account.
- Accept invite
- Already have an account?
- Log in
### Checking the link
### Accept invite (group "Accept invite", 10 states)
**Trigger:** On load, while the invite is looked up.
Route: `/invite/accept?c=CODE`. API: GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending).
- Full-card spinner: "Checking your invite…"
Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.
### Default
**Trigger:** The code is valid and unused.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; counter "0 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: password with show/hide; checklist "At least 8 characters" (unmet)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Validation errors
**Trigger:** Checked on blur and on submit, using the same rules as Register. Nothing is sent.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "ma"; counter "2 / 20" in error; **error "Use 3 to 20 characters."**
- Field `Password`: value "lime"; password with show/hide; checklist "At least 8 characters" (err)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Filled, valid
**Trigger:** Every rule is met and the checklist item turns green.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; password shown; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Submitting
**Trigger:** Fields and button are disabled.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Creating account…", loading
- Footer: Already have an account? [Log in]
### Username taken (409)
**Trigger:** Another user has that username. The value is kept and the field gets focus.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "marta"; counter "5 / 20"; focused; **error "That username is taken. Try another."**
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Invalid or used link (404)
**Trigger:** The code is wrong, was already accepted, or is missing from the URL. No form is shown.
- ResultPanel (error, link-2-off): **This invite link doesn't work**; text "It may be incomplete, or it may already have been used. If you already set up your account, log in instead."; note "Open the link from your email again, or copy the full address into your browser."; actions "Go to log in" (primary)
### Expired link (410)
**Trigger:** The invite is older than 3 days.
- ResultPanel (error, clock): **This invite has expired**; text "Invites work once, for 3 days. Ask the person who invited you to send a new one."; actions "Go to log in" (secondary)
### Server error
**Trigger:** A 500 or no response. The values are kept.
- Alert (error, circle-x): **Couldn't create the account** "Something went wrong on the server. Try again in a moment."
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Success
**Trigger:** 200 sets the session cookie → /home after 800 ms, with a toast. No verification step.
- ResultPanel (success, circle-check): **Your account is ready**; text "Taking you to your dashboard…"
- Toast (circle-check): **Welcome to RenewCMS** "You're logged in as lea."
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Checking the link | On load, while the invite is looked up. | "Checking your invite…" |
| 2 | Default | The code is valid and unused. | |
| 3 | Validation errors | Checked on blur and on submit, using the same rules as Register. Nothing is sent. | "Use 3 to 20 characters." |
| 4 | Filled, valid | Every rule is met and the checklist item turns green. | |
| 5 | Submitting | Fields and button are disabled. | "Creating account…" |
| 6 | Username taken (409) | Another user has that username. The value is kept and the field gets focus. | "That username is taken. Try another." |
| 7 | Invalid or used link (404) | The code is wrong, was already accepted, or is missing from the URL. No form is shown. | "This invite link doesn't work" / "It may be incomplete, or it may already have been used. If you already set up your account, log in instead." / "Open the link from your email again, or copy the full address into your browser." / "Go to log in" |
| 8 | Expired link (410) | The invite is older than 3 days. | "This invite has expired" / "Invites work once, for 3 days. Ask the person who invited you to send a new one." / "Go to log in" |
| 9 | Server error | A 500 or no response. The values are kept. | "Couldn't create the account" / "Something went wrong on the server. Try again in a moment." |
| 10 | Success | 200 sets the session cookie → /home after 800 ms, with a toast. No verification step. | "Your account is ready" / "Taking you to your dashboard…" / "Welcome to RenewCMS" / "You're logged in as lea." |
+72
View File
@@ -0,0 +1,72 @@
# Account: change email, export my data, delete account
Route: `/profile` (extends the Account card, adds "Your data" and "Danger zone" cards). Verification landing: `/account/email/confirm?c=CODE`. API calls are assumed, not specified.
Placement: Profile already holds the read-only Email in the Account card and the Password card, so the email change extends that card; the two other features are cards at the bottom. No new navigation. Order: Display name, Account, Your role, Author profile, Password, Your data, Danger zone.
Boards: `RenewCMS Screens Account.dc.html` (Profile states), `RenewCMS Screens Auth.dc.html` (groups "Verify new email" and "Account deleted"). Frames: `AppFrame`, `AccountCards`, `AuthCard`. Copy source: `account-copy.js`. English only.
## Change email
| State | Copy |
|---|---|
| Default | "Email" field, link "Change email" |
| Form | "New email address" · "Current password" · "We'll send a verification link to the new address. Your email stays the same until you open it." · "Cancel" · "Send verification link" |
| Invalid format | "Enter a full email address, like name@example.com." |
| Same as current | "That's already your email address." |
| Password missing | "Enter your current password." |
| Wrong password (403) | "That password isn't correct. Try again." |
| Sending | "Sending…" |
| Pending | "Waiting for verification" · "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example." · "Resend link" · "Cancel change"; toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." |
| Resending | "Sending…" |
| Resent | toast "Link sent again" / "The previous link no longer works." |
| Resend rate limited (429) | "Resend in 4:30" (disabled) · "Too many requests. Try again in a few minutes." |
| Cancelled | toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
| Rate limited (429) | "Too many requests. Try again in a few minutes.", Send disabled |
| Server or network | "Something went wrong on our side. Nothing was changed. Try again in a moment." |
Rules: the old email stays active for login until the link is opened. The old address gets the notice email at request time. A request for an address already in use answers like success, so the UI and the response never reveal that an account exists.
## Verification landing (AuthCard)
| State | Copy |
|---|---|
| Confirming | "Confirming your new email…" |
| Success | "Your email address has changed" · "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in." · "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example." |
| Invalid or expired (403) | "This link doesn't work" · "It may have expired (links last 2 hours), been cancelled or already been used." · "Request a new link from your profile." · "Go to profile" |
| Address unavailable (409) | "We couldn't change your email address" · "This address can't be used for your account. Go back to your profile and try a different one." · "Go to profile" (same copy whether the address is taken or rejected) |
| No session (401) | "Log in to confirm your new email" · "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after." · "Log in and confirm" |
| Missing code | "This link is incomplete" · "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser." · "Go to log in" |
| Server error | "Couldn't confirm the change" · "Something went wrong on our side. Nothing was changed. Try again in a moment." · "Try again" |
## Your data
Contents listed in the card: Profile (Username, display name, email, role and author profile.); Authored content (Your articles and entries.); Uploaded media (References to the files you uploaded.); Account activity (Your log-ins and the actions recorded under your name.). Card copy: "Your data" · "Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.".
| State | Copy |
|---|---|
| Direct (recommended): default | "Download my data" · "Ready in a few seconds." |
| Preparing | "Preparing download…" |
| Started | toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads." |
| Rate limited (429, one per 15 minutes, clock-sensitive) | "You can export once every 15 minutes" · "Your last export was at 09:41. Try again in 12 minutes." · "Available in 12:00" |
| Error | "Couldn't prepare your data" · "Nothing was changed. Try again in a moment." |
| Alternative, emailed link: default | "Request my data" · "Larger exports are prepared in the background. We'll email a download link to marta@renew.example when it's ready." |
| Requested | "We're preparing your export" · "This can take a few minutes. We'll email a link to marta@renew.example when it's ready." · "Export requested"; toast "Export requested" / "Check marta@renew.example in a few minutes." |
| Already in progress | "An export is already being prepared" · "We'll email a link to marta@renew.example when it's ready." |
Recommendation: direct download; async is the documented alternative and uses the export-ready email.
## Delete account
Danger zone: "Danger zone" · "Delete your account and your personal data. This can't be undone." · "Delete my account".
| State | Copy |
|---|---|
| Dialog | "Delete your account?" · "This permanently deletes your account. It can't be undone." · list: "Your username, display name and email address", "Your author profile and API keys", "Your sessions: you'll be logged out everywhere" · "Your articles and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from them." · "Export my data first" · "Current password" · "Type DELETE to confirm" · "Keep my account" · "Delete my account" |
| Empty, typing | Confirm disabled until password is filled and the word equals "DELETE" (exact, case-sensitive) |
| Wrong password (403) | "That password isn't correct. Try again." |
| Deleting | "Deleting…"; fields and Cancel disabled; then sign out and redirect to /login?reason=account-deleted |
| Rate limited (429) | "Too many requests. Try again in a few minutes." |
| Server error | "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
| Blocked: last Owner or administrator | "You can't delete your account yet" · Owner: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account." · Administrator: "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account." · "Back to profile" · "Go to Users" |
| Blocked: cannot be deleted | "This account can't be deleted" · "RenewCMS can't delete this account right now. Ask an administrator to help, then try again." · "Back to profile" |
| Login after deletion | "Your account has been deleted" · "Your articles and media were kept without your name." |
Blocked dialogs have no close button, do not close on Escape or backdrop click, and always give a next step.
Authored content: anonymized to "Deleted user", stated in the dialog. Export-ready email: the link needs a logged-in session, stated in the email and the card hint. Account-deleted email: generic, no personal data, sent to the address the account had at deletion.
+114 -172
View File
@@ -1,183 +1,125 @@
# Activity
- **Route:** `/activity`
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
Route: `/activity`, `Row click`. API: GET /v1/activity · GET /v1/activity/summary · GET /v1/activity?outcome=failed · ?type=security · GET /v1/activity/export (shipped: CSV only, synchronous streamed download, owner and admin only, 403 otherwise). Same filters as the list: q, actor, category, outcome, security, range, from, to. JSON is proposed. · Uses the item from GET /v1/activity · GET /v1/activity (server scoped to the user).
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.
Other groups of the same board:
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Activity", "Activity: filter cards", "Activity: export", "Activity details", "My activity". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
| Group | What it draws | Written up in |
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Activity, owner and admin (group "Activity", 9 states)
Route: `/activity`. API: GET /v1/activity · GET /v1/activity/summary.
New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default, large dataset | Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time. | |
| 2 | Loading | Skeleton strip and table while both requests are in flight. | |
| 3 | Zero-value summary strip | Quiet period: Failed and Security show a calm 0. | |
| 4 | Filtered, card applied | Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters. | |
| 5 | Mobile filters sheet | Filters open as a bottom sheet (desktop uses the inline bar above). | "Custom" |
| 6 | Empty, no activity yet | New site. Export is disabled. | |
| 7 | Empty, filters match nothing | Offers Clear filters. | "Media" / "Failed" |
| 8 | Error with retry | Request failed. The summary strip still shows if its call succeeded. | |
| 9 | Forbidden (403) | API returned 403 for this role or filter. | |
### Activity: failed-action cards as toggles (group "Activity: filter cards", 10 states)
Route: `/activity`. API: GET /v1/activity?outcome=failed · ?type=security.
The Failed actions and Security events cards are toggle buttons with aria-pressed. Pressing one applies its filter and shows it in the chip row; pressing the active card again removes it. Three controls always agree: the card, the chip with its × and "Clear all". Removing the filter with any of them returns the card to its default look, removes the chip and sets the Outcome segment back to All. The active card uses --rc-sand with --rc-on-sand; the hint line changes from "Filtering. Click to clear" to "Click to clear this filter" on hover.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default, no filter | Both cards unpressed, no chips. | |
| 2 | Card hover | Pointer over Failed actions. Background --ui-bg-elevated, hint "Click to filter the list". | |
| 3 | Card focus-visible | Keyboard focus on the card: 2 px --ui-primary outline, 2 px offset. Enter or Space toggles. | |
| 4 | Failed card active | aria-pressed="true". Outcome chip, Outcome segment on Failed, list reduced to failed events. | |
| 5 | Active card hover | Hint reads "Click to clear this filter" with an × icon. | |
| 6 | Security card active | Chip "Type: Security events". The Outcome segment stays on All. | |
| 7 | Chip × hover | Dismissing the chip also unpresses the card. | |
| 8 | Clear all hover | Clears the chips, the card and every other filter together. | |
| 9 | Clear all focus-visible | Keyboard focus on Clear all. | |
| 10 | Card plus other filters | Failed card with Category and Dates. Unpressing the card removes only its chip. | "Authentication" / "30 days" |
### Activity: export menu and dialogs (group "Activity: export", 15 states)
Route: `/activity`. API: GET /v1/activity/export (shipped: CSV only, synchronous streamed download, owner and admin only, 403 otherwise). Same filters as the list: q, actor, category, outcome, security, range, from, to. JSON is proposed..
Export is one menu button with two items. Export current view: the same endpoint with the active filters, across all pages. Export all activity: the same endpoint with no filters and an optional date range (from, to), default all time. There is no server job, progress or later notification. On mobile the menu and dialog are bottom sheets. Dialogs: secondary left, primary right.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Menu open | Export pressed. Both items available. Owners and admins only; other roles never see the button. | |
| 2 | Menu, item hover | Pointer over Export all activity. | |
| 3 | Menu, filtered view | Failed card applied. The first item states the matching count. | |
| 4 | Menu, nothing to export | Filters match nothing. Export current view is disabled with the reason. | "Media" / "Failed" |
| 5 | Menu, no permission | The role was lowered while the page was open. Item disabled with the reason; the API answers 403. | |
| 6 | Export button focus-visible | 2 px --ui-primary outline, 2 px offset. Enter or ArrowDown opens the menu; Esc closes it. | |
| 7 | Dialog: configure, CSV only | Export all activity…. CSV is the only format. Date range defaults to All time. | |
| 8 | Dialog: configure, CSV or JSON (proposed) | Needs the JSON backend feature. Format control appears only when it ships. | |
| 9 | Dialog: preparing download | Request in flight. Indeterminate bar, no percentage: the server streams one file and reports no progress. | |
| 10 | Dialog: download started | The browser received the file. Only Close is offered. | |
| 11 | Dialog: failed | 500 or network failure. Retry restarts the export. | |
| 12 | Dialog: nothing to export | The chosen date range has no events. Only Close. | |
| 13 | Dialog: not allowed (403) | Owner or admin only. | |
| 14 | Toast: current view | The response started streaming and the browser saved the file. | "Download started" / "activity-2026-10-06.csv is downloading." |
| 15 | Toast: export all | Dialog closed after the download started. | "Download started" / "activity-all-2026-10-06.csv is downloading." |
### Activity: details slide-over and bottom sheet (group "Activity details", 6 states)
Route: `Row click`. API: Uses the item from GET /v1/activity.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Edit with a diff | Title change and "Body edited (+312 characters)". No body text. | |
| 2 | Settings diff, long text | Long values clamp to two lines with Show more. | |
| 3 | Failed action, very long target title | Failure reason shown, target title wraps. | |
| 4 | Deleted target, no diff | Struck-through target with a Deleted badge. | |
| 5 | System-initiated, no human actor | Scheduler with a System badge. IP and browser not applicable. | |
| 6 | Security event, failed login | Actor not signed in. Restrained shield marker. | |
### My activity, regular user (group "My activity", 3 states)
Route: `/activity`. API: GET /v1/activity (server scoped to the user).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
| 2 | Empty | The user has not done anything yet. | |
| 3 | Details | Same slide-over, own event. | |
## Roles sync (pass 6): event names, failed sentences, categories
Event names are present tense, as the backend emits them: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership. The outcome is a field; a failed event keeps its name and uses the failed sentence.
| Event | Sentence | Failed sentence |
|---|---|---|
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
| role.create | Role Contributor created | Failed to create the role Contributor |
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
| role.delete | Role Intern deleted | Failed to delete the role Intern |
| role.reassign | Moved 3 members from Intern to Writer (one member: Moved 1 member from Intern to Writer) | Failed to move 3 members from Intern to Writer |
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
| user.remove | Removed Ines Duarte | Failed to remove Ines Duarte |
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
## Roles
Failed template: "Failed to {verb} {object}". A failed row shows the Failed badge after the sentence; the details panel shows "Why it failed" with the reason from the API.
Owner and admin see every event, the summary strip, the Person filter and Export. Any other role sees only its own events: the title becomes "My activity", the strip, the Person filter and Export are hidden, and the server scopes the list to that user (asking for another actor answers 403).
Category filter values: Articles, Media, Users and invites (was "Users & invites"), Roles (new), API keys, Authentication, System. French and the failed sentences for every event are in screens/roles.md, group 8 and the table "Activity events: failed sentences, EN and FR". Frames: Roles board, group "8 Activity".
Today the product has only `owner` and `admin` (see `screens/users.md`, "Roles"), so "My activity" and the scoped answer cannot be reached by a real account yet. They are specified and tested now so a future role needs no rework. The board's sample "Editor" role and the nav it draws for a regular user (Home, Articles, Media, Activity) are illustrative.
## Sidebar
### Fallback wordings (app sync)
| Event | Condition | Sentence |
|---|---|---|
| role.reassign | target role unknown | Moved 3 members from Intern to another role |
| role.reassign | count unknown | Moved the members of the role Intern |
| role.reassign | failed, count unknown | Failed to move the members of the role Intern |
| role.reassign | failed, one member | Failed to move 1 member |
| user.remove | name unknown | Removed a user (proposal, not in the app) |
"Activity" (icon `history`) sits between Users and Integration for owner and admin: Home, Articles, Media, Users, Activity, Integration, Settings. The board draws the order that way. It is visible to every verified user. The mobile drawer gets the same item.
## The page
Title "Activity" (regular users: "My activity"). Subtitle "Who did what, and when, across your site." (regular users: "Everything you've done on this site."). Page padding 24 px desktop, 16 px mobile; the sidebar and top bar are the existing DashboardShell.
1. **Header action** (owner and admin): outline `UButton` with icon `download`, "Export CSV". While the file is prepared: "Preparing file…" and disabled. Success toast "Export started. Your file will download shortly." Error toast "Couldn't export activity. Try again." Disabled in "Empty, no activity yet".
2. **Summary strip** (owner and admin): four `UCard`, each a `button`. Labels: "Total actions", "Active people", "Failed actions", "Security events". Each shows the number, a sub-line and a seven-bar sparkline (seven `div` bars, no chart library, oldest left).
- Total: "12% vs previous 7 days" with `trending-up` / `trending-down` (the comparison is against the equal-length period just before the selected range). Click clears the filters.
- Active people: "7 people, 2 API keys". Click does nothing (no equivalent filter).
- Failed: "0.5% of all actions". Emphasis with `--rc-error-soft` and `--ui-error`, icon `circle-x`. Click applies Outcome = Failed.
- Security: "5 failed logins". Emphasis with `--rc-warning-soft` and `--ui-warning`, icon `shield-alert`. Click applies the security filter (auth, role changes, API key create and revoke).
- Zero: Failed shows "0" with "Nothing failed" and `circle-check`; Security shows "0" with "Nothing to flag" and `circle-check`; both drop the emphasis fill. No data at all: every card reads "0" with the sub-line "No data yet" (Failed and Security keep their calm lines).
- The strip follows the date range filter. Mobile: a 2×2 grid.
3. **Filter bar**: `UInput` (icon `search`, clearable, "Search activity"), Person `USelectMenu` (grouped: users, API keys, "System"; "All people"), Category `USelectMenu` ("All categories": Articles, Media, Users and invites, API keys, Authentication, System), Outcome button group (All / Succeeded / Failed; `soft` is the selected one), date presets (Today / 7 days / 30 days / Custom; Custom opens a date-range popover). Mobile: the bar collapses to a search field and a "Filters" button with a count badge; the controls move to a bottom sheet.
4. **Active filter chips**: `UBadge` (subtle) with a trailing ghost `x` button: "Search: spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Dates: 1 – 30 Sep 2026", then a `link` button "Clear all". The default date range (7 days) is not a chip.
5. **Timeline table**: newest first, grouped by day with full-width separator rows ("Today", "Yesterday", "3 Oct 2026"). Columns: When (relative within the day, clock time before; the full time is the cell's `title`), Actor (`UAvatar` with initials, a key icon for API keys or a cog for System, plus the name; badges "API key" / "System"; a signed-out actor reads "Not signed in"), Action (category icon and the sentence with the target name in semibold; a `shield` marker on security rows), Target (a link when it still exists; struck through with a "Deleted" badge when it does not), Outcome (`UBadge` "Succeeded" / "Failed"). Row click and Enter open the details.
6. **Pagination**: "Showing 1–25 of 1,284", `UPagination`, "Rows per page" select (25, 50, 100). Mobile: "Page 1 of 52" with previous and next.
7. **Mobile list**: below `md` the table is a stacked list of `UCard` rows (actor, sentence, time, outcome, "Target deleted" when relevant), 44 px touch targets.
Sentences are composed in the SPA from a stable `action` code and the event params, in the user's language (English and French), never stored as text. The target name and the typed values in a diff stay as the user wrote them.
## States
### Default, large dataset
**Trigger:** Both requests resolved with 1,284 events over 52 pages. Newest first, day separators, a security row. Hover on the first row shows its exact time.
### Loading
**Trigger:** Both requests in flight. Skeleton strip and table rows after 150 ms.
### Zero-value summary strip
**Trigger:** A quiet period. Failed and Security show a calm 0.
### Filtered, card applied
**Trigger:** The Failed actions card was clicked. Chips plus "Clear all"; mobile shows the count on Filters. Six results.
### Mobile filters sheet
**Trigger:** Filters open as a bottom sheet on mobile. The desktop frame keeps the inline bar. Sheet: title "Filters", Person (owner and admin), Category, Outcome, Dates (two date fields when Custom), footer "Clear all" and "Show 23 results".
### Empty, no activity yet
**Trigger:** A new site. `UEmpty` with icon `history`, title "No activity yet", description "Actions on articles, media, users and API keys will appear here." Export disabled.
### Empty, filters match nothing
**Trigger:** Filters with zero results. Title "No activity matches these filters", description "Try a different search or a wider date range.", action "Clear filters".
### Error with retry
**Trigger:** The list request failed (500 or network). `UEmpty` in the table area: "Couldn't load activity", "Check your connection and try again.", action "Try again". The strip still shows if its own call succeeded.
### Forbidden (403)
**Trigger:** The API answered 403 for this role or filter. "You can't view this activity.", "Your role doesn't include this page. Ask an owner or admin if you need access.", action "Back to home". The 401 is global.
## Details slide-over
`USlideover`, right side on desktop (about 440 px wide), bottom sheet on mobile. Opens from a row (click or Enter), closes with Escape, the close button or the scrim; focus returns to the row. Title "Activity details".
Body, in `UCard` sections:
- The full sentence, with the target name in semibold.
- When: the user's local time, then a muted "UTC" line.
- Actor (with the same badges), Source ("Web app", "API key", "System task"), IP address, Browser. For a system event, IP address and Browser read "Not applicable".
- Target: name (struck through with a "Deleted" badge, plus "This item was deleted. Its name is kept for reference." when it no longer exists), and a link when it exists.
- Why it failed (failed events only): the plain-language reason from the API, never a stack trace.
- Changes (edits only): rows of field, Before, After. Values longer than two lines clamp with "Show more" / "Show less". A body edit is a single row "Body edited (+312 characters)" and never shows text.
- Footer: "Previous event" and "Next event" buttons and "Event 2 of 1,284". They move through the current filtered list, across page boundaries.
### Edit with a diff
**Trigger:** A title change and a body edit. Title Before and After, then "Body edited (+312 characters)".
### Settings diff, long text
**Trigger:** Long values clamp to two lines with "Show more".
### Failed action, very long target title
**Trigger:** A failed publish. The reason is shown and the long target title wraps.
### Deleted target, no diff
**Trigger:** A deleted image. Struck-through target, "Deleted" badge, no Changes section.
### System-initiated, no human actor
**Trigger:** A scheduled task. "System" badge, source "System task", IP and Browser "Not applicable".
### Security event, failed login
**Trigger:** The actor was not signed in. "Not signed in", a restrained `shield` marker, the reason, the IP and browser.
## My activity
The same screen for a role that is not owner or admin. No summary strip, no Person filter, no Export. The list is the user's own events (server scoped).
### Default
**Trigger:** The user has events. Title "My activity".
### Empty
**Trigger:** No events yet: "No activity yet", "Your actions will appear here once you start working on the site."
### Details
**Trigger:** The slide-over on one of the user's own events.
## API contract
401 and 403 on every route follow `states.md`, "HTTP status to copy". The board's table in `source/activity-spec.js` is the source; this section fixes the parts it leaves open.
**`GET /v1/activity`**
- Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, `api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `custom`), `from`, `to` (ISO dates, with `custom`), `page` (from 1), `size` (25, 50 or 100; default 25). Sorted by `created_at` descending, then `id` descending. Invalid values answer 400 "The request cannot be processed due to a mismatch in the format of the body."
- `security=true` is what the Security card applies; it is a flag on the event, not a category.
- Response: `{ items, total, page, size }`. An item:
```json
{
"id": 4821,
"created_at": "2026-10-06T10:42:18Z",
"action": "article.publish",
"category": "articles",
"actor": { "type": "user", "id": 3, "name": "Marta Alves" },
"target": { "type": "article", "id": "8", "name": "Spring release", "exists": true },
"outcome": "success",
"failure_reason": null,
"security": false,
"source": "web",
"ip": "203.0.113.12",
"user_agent": "Chrome 129 on Windows",
"changes": [{ "field": "online", "kind": "text", "before": "false", "after": "true" }]
}
```
- `actor.type` is `user`, `api_key`, `system` or `anonymous` (a failed login). `actor.name` is a snapshot taken when the event was recorded, so a renamed or deleted user still reads correctly. `target.name` is a snapshot too; `target.exists` is resolved when the list is read.
- `action` is a stable dotted code. The SPA owns the wording, in English and French. `params` holds extra values a sentence needs (for example `{ "merged": 3, "articles_updated": 5 }` on a media purge); they are never secrets.
- `changes[].kind` is `text` or `body`. A `body` change has `delta` (a signed number of characters) and no `before` or `after`. Only allowlisted fields ever appear in `changes`, and never a password, hash, API key, token or article body.
- `source` is `web`, `api_key` or `system`. `ip` and `user_agent` are `null` for `system`.
- A caller who is not owner or admin is scoped to its own events; `actor` other than itself answers 403.
**`GET /v1/activity/summary`** (owner and admin; 403 otherwise)
Same `range`, `from`, `to`. Response: `total`, `previous_total`, `active_people: { users, api_keys }`, `failed`, `security`, and `series` with seven daily counts for `total`, `active`, `failed` and `security`. The previous period is the equal-length period immediately before the range.
**`GET /v1/activity/export`** (owner and admin; 403 otherwise)
The same filters without `page` or `size`. Streams `text/csv; charset=utf-8` with `Content-Disposition: attachment`. Columns: `when` (RFC3339 UTC), `actor`, `action` (the code), `target`, `outcome`, `source`, `ip`. No changes, no user agent, no secrets. Cells that start with `=`, `+`, `-` or `@` are prefixed with a quote so a spreadsheet does not evaluate them.
**`GET /v1/settings` and `PATCH /v1/settings`**: see `screens/settings.md`, "Activity retention".
These routes are session only: with an API key they answer 403 "This action needs a real session." (A key never reads the audit trail.)
## What is recorded
Every row of the board is a sample. The real catalogue is the set of actions that exist in the product, in these categories:
| Category | Events |
|---|---|
| `articles` | create, edit (title, publish state, body size delta), publish, unpublish, delete |
| `media` | upload, rename, delete, merge, purge (manual; the scheduled run is a `system` event) |
| `users` | register, verify account, update profile (display name), invite create, cancel, renew, accept |
| `api_keys` | create, revoke |
| `auth` | login succeeded, login failed (security), logout, password reset requested (security), password reset completed (security) |
| `system` | update checked, update downloaded, restart requested, scheduled media purge, activity retention changed, activity cleanup |
Not drawn as real events: the "Editor" role and role changes, a "Site description" or "Comments" setting, "Ran the nightly backup" (backups are a step of the update job, not a schedule), "Removed 3 expired invites" and an administrator resetting another user's password. They stay in the board as sample data and are only recorded if the matching feature exists.
Read requests are not recorded (no "article viewed"), and neither are public API reads.
Event names match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
+74 -95
View File
@@ -1,122 +1,101 @@
# Articles
- **Route:** `/article`
- **API:** GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Articles" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
## States
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Articles", "Articles: pagination". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default
## Copy per state (generated from the board source, pass 2)
**Trigger:** The list loaded, sorted by last updated.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Articles (mobile title "Articles")
### Fixed copy in the frame
- Articles
- Everything you've written, online and in draft.
- New article
- Retry
- Title
- Status
- Created
- Updated
- Online
- Draft
- Edit
- Copy API URL
- Delete…
- Previous
- Next
### Row menu open
### Articles (group "Articles", 14 states)
**Trigger:** The ellipsis button, or Enter on the focused menu button.
Route: `/article`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}.
- Breadcrumbs: Articles (mobile title "Articles")
- Row "Winter checklist for timber windows": menu true, online false, updated "5 days ago"
Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
### Loading
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | The list loaded, sorted by last updated. | |
| 2 | Row menu open | The ellipsis button, or Enter on the focused menu button. | |
| 3 | Loading | The first load, or a retry. | |
| 4 | Empty | The list is empty. | "No articles yet" / "Write your first one. It stays a draft until you publish it." / "New article" |
| 5 | No results | Search matched nothing. The tab counts still show the full list. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 6 | No drafts | Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state. | "Everything you've written is online." / "New article" |
| 7 | Action in progress | Publish chosen from the row menu. That row is disabled. | "Publishing…" |
| 8 | Published | 200: the row updates in place and a toast confirms. Clock-sensitive: shows a relative time or a countdown. | "Just now" / "Article published" / ""Winter checklist for timber windows" is now online." |
| 9 | Action failed | A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed. | "Couldn't unpublish" / "The server didn't respond. Nothing changed." / "Retry" |
| 10 | Row vanished (404) | Any row action gets a 404. | "That article no longer exists" / "It was deleted somewhere else, so we removed it from the list." |
| 11 | Delete: confirm | Delete… from the row menu. | |
| 12 | Delete: in progress | DELETE in flight. The dialog can't be dismissed. | |
| 13 | Delete: failed | 500, a partial failure where the article is kept. The error shows inside the dialog. | "Couldn't delete the article, so it's still there. Try again in a moment." |
| 14 | Deleted | 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up. | "Article deleted" / ""Winter checklist for timber windows" and its images are gone." |
**Trigger:** The first load, or a retry.
### Articles: pagination (group "Articles: pagination", 11 states)
- Breadcrumbs: Articles (mobile title "Articles")
- List: loading skeleton
Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
### Empty
Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.
**Trigger:** The list is empty.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /articles. Previous and First are disabled. Summary "Showing 1–25 of 312". | |
| 2 | Middle page | /articles?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges. | |
| 3 | Last page | /articles?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312". | |
| 4 | Single page | Total fits in one page. Paginator hidden; summary "Showing 1–6 of 6" and the page-size select stay. | |
| 5 | Loading next page | Next was pressed. Skeleton rows keep the table height, controls are disabled, summary reads "Loading page 7…". | |
| 6 | Error loading a page | 500 or network failure on page change. Previous rows stay; Retry sits in the notice. | "Couldn't load page 7. Showing page 6." |
| 7 | Page out of range | /articles?page=99 is replaced with ?page=13 and this notice is shown. | "That page doesn't exist. Showing the last page." |
| 8 | Search resets to page 1 | Searching "plaster" from page 7 goes to /articles?q=plaster (page omitted). Same for tab, sort and size changes. | |
| 9 | Empty after search | Zero results. Paginator and summary are hidden. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 10 | Page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 11 | Focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
- Breadcrumbs: Articles (mobile title "Articles")
- Tab counts (All, Online, Drafts): 0, 0, 0
- EmptyState (file-text): **No articles yet** "Write your first one. It stays a draft until you publish it.", action "New article" (primary)
## Changed in pass 3: authors, site column and site filter
### No results
### Articles: sites and authors (group "Articles: sites and authors", 3 states)
**Trigger:** Search matched nothing. The tab counts still show the full list.
Route: `/articles?site=journal`. API: GET /v1/article/?site={key}&page=&size=&q=&status=.
- Breadcrumbs: Articles (mobile title "Articles")
- Search query: "tiles"
- EmptyState (search): **No articles match "tiles"** "Check the spelling, or search for a shorter part of the title.", action "Clear search"
Every Articles frame has the Authors column (avatars, at most 3 plus "+N"; "—" when none; on mobile avatars with the first name). When more than one site exists, a Site column and a Site filter appear, read-only text for every role; with one site they are not rendered.
### No drafts
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Several sites | More than one site exists. | Authors · Site · All sites · Renew · Journal · Projects |
| 2 | Site filter open | The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1. | All sites · Renew · Journal · Projects · Archive |
| 3 | No articles on a site | The chosen site has no articles. | No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites |
**Trigger:** Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state.
All existing Articles frames show the Authors column (avatars, at most 3 plus "+N", "—" when none).
- Breadcrumbs: Articles (mobile title "Articles")
- Active tab index: 2 (0 All, 1 Online, 2 Drafts)
- Tab counts (All, Online, Drafts): 4, 4, 0
- EmptyState (pencil-line): **No drafts** "Everything you've written is online.", action "New article"
## Articles: search status (SEO round)
### Action in progress
Route: `/articles?seo=suggestions`. API: GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions).
**Trigger:** Publish chosen from the row menu. That row is disabled.
A quiet indicator in the Status cell on desktop and under the title on mobile cards: a lightbulb chip with a count when there are suggestions, an eye-off chip "Hidden" when noindex is on, nothing when the article is ready. The toolbar gets a "Needs attention" toggle with a count next to the search field. Tabs and their counts are unchanged. On a single-site install nothing changes: the Site filter and column stay hidden and the toggle works the same, over all articles.
- Breadcrumbs: Articles (mobile title "Articles")
- Row "Winter checklist for timber windows": busy "Publishing…", online false, updated "5 days ago"
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Status chips | Row 2 has 2 suggestions, row 3 is hidden, row 4 has 3, row 5 has 1. | Needs attention · 3 · Hidden · 2 suggestions (tooltip) · Hidden from search engines (tooltip) |
| 2 | Needs attention on | The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included. | Needs attention · 3 |
| 3 | Needs attention: none | Nothing to show with the toggle on. | Nothing needs attention · Every article has a clean check. Suggestions are optional, so this is just a bonus. · Show all articles |
### Published
**Trigger:** 200: the row updates in place and a toast confirms.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article published** ""Winter checklist for timber windows" is now online."
### Action failed
**Trigger:** A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (error, circle-x): **Couldn't unpublish** "The server didn't respond. Nothing changed." with action "Retry"
### Row vanished (404)
**Trigger:** Any row action gets a 404.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (info, info): **That article no longer exists** "It was deleted somewhere else, so we removed it from the list."
- Tab counts (All, Online, Drafts): 5, 4, 1
### Delete: confirm
**Trigger:** Delete… from the row menu.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
### Delete: in progress
**Trigger:** DELETE in flight. The dialog can't be dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Deleting…" (danger), loading, not dismissible
### Delete: failed
**Trigger:** 500, a partial failure where the article is kept. The error shows inside the dialog.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- Alert: "Couldn't delete the article, so it's still there. Try again in a moment."
- cancel "Cancel"
- confirm "Delete article" (danger)
### Deleted
**Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" is gone."
- Tab counts (All, Online, Drafts): 5, 4, 1
Filter: "Needs attention" sets `?seo=suggestions` and resets `page` to 1. Tab counts do not change. Single-site installs behave the same; the Site filter stays hidden.
+82
View File
@@ -0,0 +1,82 @@
# Authors
Route: `/authors`. API: GET /v1/author/ · POST /v1/author/ · PATCH /v1/author/{id} · DELETE /v1/author/{id}?reassign_to={id|none} · POST /v1/author/me.
An author is a public byline, not an account. It may be linked to one CMS user; guest writers have no link. The slug is globally unique, generated from the name and editable. An article has zero or more authors in a set order; the first is the primary byline. Zero authors is allowed.
**Roles.** Owner and admin can create, edit and delete any author. Other roles see the Authors screen read-only: the list and a read-only detail, with no Add author button, no row menu, no slide-over form and no delete. They can pick authors in the article editor. Any role can use "Create my author profile" and edit their own linked author: the form opens with the linked user locked and has no delete action.
**Shared pieces.** The list, slide-over, delete-with-reassign dialog, slug field and media field are defined once in `DirectoryFrame.dc.html` and referenced by both Authors and Sites. The media picker is drawn once, in "Authors: form". The server-fallback 409 dialog is drawn once, in "Authors: delete".
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Authors", "Authors: form", "Authors: delete". Every state is drawn in light and dark (theme switch) at 1440×900 and 390×844. Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order.
### Authors (group "Authors", 8 states)
Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles; its states are drawn in "Authors: pagination". List, panel and dialogs come from DirectoryFrame and are shared with Sites.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | Owner or admin. The list loaded, newest first. | Authors · The names readers see on your articles. An author doesn't need an account. · Add author · Search by name or slug · Author · Slug · Account · Articles · Linked · Guest · 9 articles · 1 article · No articles |
| 2 | Loading | The first load, or a retry. | |
| 3 | Error | 500 or network failure. | Couldn't load authors · Check your connection and try again. · Try again |
| 4 | Empty, first run | Zero authors. Toolbar hidden, one call to action. Other roles see the same text without the button. | No authors yet · Authors are the names readers see on articles. They don't need an account. · Add author |
| 5 | No results | Search matched nothing. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 6 | Row menu | Owner or admin. The ellipsis button. Mobile: bottom sheet. | Edit · Delete… |
| 7 | Read-only list (other roles) | Any role except owner and admin. No Add author button, no row menu and no bottom button on mobile. Search still works. A row opens the read-only detail. | Authors · The names readers see on your articles. Only an owner or admin can change authors. · Search by name or slug · Author · Slug · Account · Articles |
| 8 | Read-only detail | A row was opened by a role without author rights. Same panel width as the form, no fields, Close as the only action. The account email is not shown. | Inès Duarte · Linked · 9 articles · Bio · Website · Only an owner or admin can change this author. · Close |
### Authors: add and edit (group "Authors: form", 11 states)
Owner and admin. A slide-over, because the form has a live preview (the byline). Fields: avatar (media picker), name, slug (auto, editable), bio with counter, website, social links (X, LinkedIn, GitHub, Mastodon, Custom; up to 5), linked user (only users without an author). Slug field, media field and picker are the shared ones from DirectoryFrame. Saving shows "Saving…" in the button, as in the other forms.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Add: empty | "Add author". Owner or admin. | Add author · Avatar (optional) · Choose image · Name · e.g. Camille Roche · Slug · Generated from the name · Bio (optional) · A sentence or two about the author. · 0 / 280 · Website (optional) · https://example.com · Social links (optional) · Add link · 0 of 5 · Linked user (optional) · No linked user · Link a CMS user if this author has an account. Guest writers need no link. · Byline preview · Author name · The bio appears here. · Cancel · Add author |
| 2 | Slug from the name | Typing a name fills the slug. Auto badge and "Edit slug" pencil. | camille-roche · Auto · Generated from the name. Used in public author URLs and API paths. Unique across all authors. |
| 3 | Slug collision | 409 on submit, or on blur after a check. | The slug "camille-roche" is already used by another author. · Use camille-roche-2 · Reset to match the name |
| 4 | Validation | On submit or blur. Nothing is sent. | Enter a name. · Shorten the bio to 280 characters. · 291 / 280 · Enter a full URL starting with https://. |
| 5 | Social links | Add link adds a row with a type select and a URL. One link is invalid. | X · LinkedIn · GitHub · Mastodon · Custom · https://x.com/username · https://www.linkedin.com/in/username · https://github.com/username · https://mastodon.social/@username · https://example.com · Enter a full URL starting with https://. · You can add up to 5 links. |
| 6 | Linked user: choose | The select is open. Only users without an author. When none are left it reads "Every user already has an author." | Choose a user · Users without an author · Marta Alves · marta@renew.example · Rui Santos · rui@renew.example |
| 7 | Edit: linked author | Edit on an author linked to a user. | Edit Inès Duarte · Linked user (optional) · ines.duarte@renew.example · Unlink · New articles by this user start with this author. · Save changes |
| 8 | Avatar: picker | "Choose image". The existing media picker; shown as a square. The site social image uses this same picker. | Choose an image · The avatar for Camille Roche. · Shown as a square. Choose an image with the face or logo near the middle. · Use image |
| 9 | Save failed | 500 or network failure. | Couldn't save the author. Your changes are still here. Try again in a moment. |
| 10 | Unlink: confirm | "Unlink" next to the linked user, owner or admin. On 200 the field returns to "No linked user" and a toast reads "User unlinked" · "Inès Duarte is now a guest author." | Unlink Inès Duarte? · Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author. · Cancel · Unlink user |
| 11 | Re-link | "Link user" on a guest author in the row menu or the form. Same list as above. | Link Camille Roche to a user? · Only users without an author are listed. · Cancel · Link user |
### Authors: delete (group "Authors: delete", 6 states)
Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody" as the last option, and shows the count in the button. Zero authors are allowed, so Nobody is always valid. The 409 dialog is the server fallback and is drawn once.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Reassign (recommended) | "Delete…" on an author with articles, owner or admin. The button reads "Reassigning…" while the request runs. On 204 a toast reads "Author deleted" · "3 articles now credit Inès Duarte." A linked author also shows "Linked to ines.duarte@renew.example. The user account isn't affected." | Delete this author? · "Camille Roche" will be deleted and is credited on 3 articles. This can't be undone. · Credit these articles to · Inès Duarte · Where an article has several authors, Camille Roche is removed and the others stay. · Cancel · Delete and reassign 3 articles |
| 2 | Choose who | The select is open. The deleted author is not listed. | Nobody · Remove the byline |
| 3 | Nobody | "Nobody" chosen. | The articles keep any other authors. Articles with only this author will have no byline. |
| 4 | Failed (shared by site and author delete) | 500 or network failure. Both delete dialogs use this notice. | Couldn't delete the author · Nothing was changed. Try again in a moment. |
| 5 | Server fallback (409) | Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow. | Can't delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles |
| 6 | No articles | An author with zero articles: plain confirm. | Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can't be undone. · Delete author |
### Authors: pagination (group "Authors: pagination", 10 states)
Route: `/authors?page=3&size=50`. API: GET /v1/author/?q=&page=&size= (total in `X-Total-Count`, array body unchanged; `q` already exists). Owner, admin and read-only roles see the same pager. Drawn with 128 authors (6 pages) and generated rows.
**URL.** `/authors?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/authors?q=mar&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
**Pager.** The same component as Articles and Media, in the same footer position (below the list, summary on the left, page-size select next to it, right-aligned controls; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N authors" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /authors. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 128 · 25 per page · Page 1 of 6 |
| 2 | Middle page | /authors?page=3. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 51–75 of 128 · Page 3 of 6 |
| 3 | Last page | /authors?page=6. Next and Last are disabled. | Showing 126–128 of 128 · Page 6 of 6 |
| 4 | Single page | Everything fits on one page (6 authors). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 6 authors |
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 4…" and the page number shown is still 3. | Loading page 4… |
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 3 stay, the pager keeps "Page 3", and the inline error with Retry sits above the pager. Retry repeats the request for page 4. | Couldn't load page 4. Showing page 3. · Check your connection and try again. · Retry |
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /authors and page 1. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 9 | Search resets to page 1 | Searching "mar" from page 5 goes to /authors?q=mar (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 31 |
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 12 of 52" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 276–300 of 1,284 · Page 12 of 52 |
## SEO round
No change to the Authors screen. Name, profile URL (website) and social links feed `authors[].name`, `authors[].url`, `authors[].same_as` and the JSON-LD author (`sameAs`) in GET /v1/article/{id}/seo. They are visible in the editor's "What the frontend gets" dialog.
+17 -9
View File
@@ -1,16 +1,24 @@
# App boot
# Boot
- **Route:** `any route, first load`
- **API:** GET /v1/auth/me (pending)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Boot" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `any route, first load`. API: GET /v1/auth/me (pending).
While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.
## States
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Boot". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Checking session
## Copy per state (generated from the board source, pass 2)
**Trigger:** The first paint on any back-office URL, and usually visible for well under a second.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Home (mobile title "Home")
- boot: true
### Fixed copy in the frame
- API pending · GET /v1/auth/me
### App boot (group "Boot", 1 states)
Route: `any route, first load`. API: GET /v1/auth/me (pending).
While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Checking session | The first paint on any back-office URL, and usually visible for well under a second. | |
+26
View File
@@ -0,0 +1,26 @@
# Content
Route: `/content/:key`. API: GET /v1/content-type/{key} · GET /v1/content-type/{key}/entry?page=&size=&sort=. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Content and entry list". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 5 Content navigation and entry list (group "5 Content and entry list", 9 states)
Route: `/content/:key`. API: GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry. Auth: Any verified user.
The sidebar gets a "Content" group, one item per custom type (a default icon: types have no icon in the API). With more than 5 types it shows the first 5 and the selected one, then "Show all N"; long names truncate with the full name in a tooltip. On mobile the group is one collapsed row in the drawer. List: ID, up to 6 chosen field columns (default: first 4), Created, Updated, Updated by. Sort: ID, Created, Updated, either direction (default Updated, newest first). Page size 5, 25, 50, 100 (default 25). No filter or search. URL: /content/events?page=2&size=50&sort=created_at:desc (defaults omitted). The response carries total, page, size and sort; the summary, the pager and the review dialog use them.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Populated | /content/events. 7 entries, one page: no pager, only the count. | Content · Events · events · 7 fields · New entry · Sort by · Last updated · Columns · Edit content type · ID · Title · Starts on · Ends on · Online · Created · Updated · Updated by · 7 entries | Available now |
| 2 | Paginated, 5 per page | /content/events?size=5. 37 entries. | Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile) | Available now |
| 3 | Columns menu | The Columns button. Popover on desktop, sheet on mobile. Created and updated always show. | Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done | Available now |
| 4 | Many types in the sidebar | 14 custom types. Long names truncate. Mobile shows the drawer with the group expanded. | Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed) | Available now |
| 5 | Loading | First load or retry. | (skeleton) | Available now |
| 6 | Empty for this type | Total is 0. The hint points to New entry. | No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry | Available now |
| 7 | Error | 500 or network failure. | Couldn't load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again | Available now |
| 8 | Forbidden (403) | The API answers 403. Unverified accounts are redirected to /register/pending before this. | You don't have access to Events · Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin. · Go to Home | Available now |
| 9 | Type not found | 404 content_type_not_found on /content/unknown. | Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.5. Board groups: 5 Content and entry list.
+172 -214
View File
@@ -1,219 +1,177 @@
# Editor
- **Route:** `/article/:id/edit`
- **API:** GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Editor" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/article/:id/edit`. API: GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE.
On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".
## States
### Loading
**Trigger:** GET in flight. Skeletons for the title, content and details.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saved", loading true
- Mobile: leading arrow-left button, trailing ellipsis
### Saved
**Trigger:** Just opened or just saved. Save is disabled.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saved"
- Mobile: leading arrow-left button, trailing ellipsis
### Unsaved changes
**Trigger:** Any edit. Publish becomes "Save and publish". Leaving asks for confirmation.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Saving
**Trigger:** Save, or Ctrl/⌘ S.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saving"
- Mobile: leading arrow-left button, trailing ellipsis
### Save failed
**Trigger:** 500 or network failure. The changes are kept and Retry resends them.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "error"
- Mobile: leading arrow-left button, trailing ellipsis
### Near the limit
**Trigger:** 90% of 10 000 HTML characters.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "dirty", len 9240
- Mobile: leading arrow-left button, trailing ellipsis
### Too long
**Trigger:** Over 10 000. Save and Publish are disabled until it's shorter.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "dirty", len 10214
- Mobile: leading arrow-left button, trailing ellipsis
### Preview
**Trigger:** The Preview switch. Shows the title and body in the content scale, the way a public site might.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saved", preview true
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: choose
**Trigger:** The image button in the toolbar, or a file dropped on the editor.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Add an image**
- upload state: state "idle"
- cancel "Cancel"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop and resize
**Trigger:** A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "Original", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop to a ratio
**Trigger:** 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "16:9", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop narrower than the width
**Trigger:** The crop is smaller than the chosen width, so it keeps its own size.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "1:1", width "1600 px"
- note: "The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: in progress
**Trigger:** Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Add an image**
- upload state: state "uploading" ("Uploading 180 of 420 KB")
- cancel "Cancel upload"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: rejected
**Trigger:** Checked in the browser, so nothing is sent.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Add an image**
- upload state: state "rejected"
- cancel "Cancel"
- confirm "Choose another file"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: inserted
**Trigger:** 201: the image goes in at the cursor, and the article now has unsaved changes.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Toast (success, circle-check): **Image added** "Save to keep it in the article."
- Editor: status "draft", save "dirty", image true
- Mobile: leading arrow-left button, trailing ellipsis
### Publishing
**Trigger:** "Save and publish": the PATCH body, then the publish request.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saving", pending "Saving and publishing…"
- Mobile: leading arrow-left button, trailing ellipsis
### Published
**Trigger:** 200: the badge flips, Unpublish is offered and a toast confirms.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Toast (success, circle-check): **Article published** ""How we price a bathroom renovation" is now online."
- Editor: status "online", save "saved"
- Mobile: leading arrow-left button, trailing ellipsis
### Unpublishing
**Trigger:** Unpublish doesn't save pending edits.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "online", save "saved", pending "Unpublishing…"
- Mobile: leading arrow-left button, trailing ellipsis
### Delete: confirm
**Trigger:** Delete… from the overflow menu. After a 204, it goes to /article with the "Article deleted" toast.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Delete this article?**
- text ""How we price a bathroom renovation" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
- Editor: status "draft", save "saved"
- Mobile: leading arrow-left button, trailing ellipsis
### Leave with unsaved changes
**Trigger:** Back, a nav link or closing the tab.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Leave without saving?**
- text "Your changes to "How we price a bathroom renovation" haven't been saved."
- cancel "Discard changes"
- confirm "Keep editing"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Offline
**Trigger:** The connection dropped. Writing continues, and Save and Publish wait.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "offline"
- offline: true
- Mobile: leading arrow-left button, trailing ellipsis
### Not found (404)
**Trigger:** Unknown id, or the article was deleted elsewhere.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saved", notfound true
- Mobile: leading arrow-left button, trailing ellipsis
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Editor". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Fixed copy in the frame
- This article doesn't exist
- It may have been deleted, or the link is wrong.
- All articles
- Online
- Draft
- Retry
- Preview
- Save
- Delete
- Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and
- write down what stays
- We also check the
- timber windows
- /articles/winter-checklist
- Edit
- Open
- Remove
- before any tiling starts.
- What goes into the price
- Stripping out and making good
- Plumbing moves, if any
- Tiles, fittings and a day for silicone
- /static/uploadedImages/3f9c…e1.jpg
- We don't add a margin on materials you choose yourself.
- Details
- Created
- 12 Mar 2026, 09:14
- Updated
- Today, 11:02
- ID
- Images
- Public API URL
- /v1/article/12
- Title
- Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.
### Editor (group "Editor", 41 states)
Route: `/article/:id/edit`. API: GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE.
On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Loading | GET in flight. Skeletons for the title, content and details. | |
| 2 | Saved | Just opened or just saved. Save is disabled. | |
| 3 | Unsaved changes | Any edit. Publish becomes "Save and publish". Leaving asks for confirmation. | |
| 4 | Saving | Save, or Ctrl/⌘ S. | |
| 5 | Save failed | 500 or network failure. The changes are kept and Retry resends them. | |
| 6 | Very long article | No length limit. The count stays neutral and correct at 120,000 characters. Save and Publish stay enabled. | |
| 7 | Very long article: saving | Save with large content. The status shows the spinner and the page stays editable. | "Saving 120,000 characters. This can take a few seconds." |
| 8 | Very long article: saved | 200. Status reads Saved with the time. The count is unchanged. | |
| 9 | Very long article: save failed | 500, 413 or network failure. Nothing is lost; Retry resends the same content. | "Couldn't save. Your text is kept. Retry." |
| 10 | Preview | The Preview switch. Shows the title and body in the content scale, the way a public site might. | |
| 11 | Link: create from selection | Ctrl/⌘ K or the link button with text selected. Link text is prefilled from the selection; URL has focus. | "Link text" / "timber windows" / "URL" |
| 12 | Link: create with no selection | Nothing selected. Link text is empty and has focus. | "Link text" / "What the reader sees" / "URL" |
| 13 | Link: edit existing | Edit in the link popover, or Ctrl/⌘ K inside a link. Prefilled; "Remove link" is offered. | "Edit link" / "Save link" / "Link text" / "timber windows" / "URL" |
| 14 | Link: invalid URL | On submit or blur. Nothing is inserted. | "Link text" / "timber windows" / "URL" / "Enter a valid URL. Use http(s), mailto, a path or #anchor." |
| 15 | Link: empty text | On submit with empty link text. | "Link text" / "Enter the link text." / "What the reader sees" / "URL" |
| 16 | Link: long URL | The field truncates in the middle of the line; the full value is available by scrolling the field and in the title attribute. | "Edit link" / "Save link" / "Link text" / "planning guidance" / "URL" |
| 17 | Link button: default | Toolbar button, icon link, after the list buttons. Desktop 28 px, mobile 40 px. Text colour --ui-text-toned. The toolbar scrolls sideways on mobile (see "Toolbar overflow"). | |
| 18 | Link button: hover and tooltip | Pointer or keyboard focus after 400 ms. Tooltip "Insert link (Ctrl+K)"; on macOS it reads "Insert link (⌘K)". Touch has no tooltip. | |
| 19 | Link button: focus-visible | 2 px --ui-primary outline, 1 px offset inside the toolbar. Enter or Space opens the link dialog. | |
| 20 | Link button: active (cursor in a link) | The cursor or selection is inside a link: aria-pressed true, --rc-sand fill and --rc-on-sand icon. Pressing it opens the dialog in edit mode. | |
| 21 | Link button: disabled | An image is selected in the editor. The button is at 40% opacity, aria-disabled, no tooltip. Ctrl/⌘ K does nothing. | |
| 22 | Link popover and sheet | Click a link, or Enter on a focused link. Desktop: popover under the link with the URL and Edit, Open and Remove. Mobile (touch): bottom sheet with the link text, the URL and Edit link, Open link, Remove link at 48 px. Esc or a tap outside closes it. Remove asks for no confirmation; undo restores the link. | |
| 23 | Rendered link: default | Same as the published page: --ui-text-highlighted, 1 px underline, 3 px offset. | |
| 24 | Rendered link: hover | Underline thickens to 2 px. | |
| 25 | Rendered link: focus | Tab or arrow keys into the link: 2 px --ui-primary outline, 2 px offset. | |
| 26 | Rendered link: selected | Text selected across the link: --rc-sand fill, --rc-on-sand text. | |
| 27 | Toolbar overflow (390 px) | The full toolbar does not fit at 390 px. It scrolls sideways in one row (no wrap); a 24 or 32 px fade marks each hidden side. This frame is scrolled to the end, so the link, remove-formatting and Image buttons are visible. Desktop is unchanged. | |
| 28 | Upload: choose | The image button in the toolbar, or a file dropped on the editor. | "Add an image" / "Cancel" |
| 29 | Upload: crop and resize | A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step. | |
| 30 | Upload: crop to a ratio | 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it. | |
| 31 | Upload: crop narrower than the width | The crop is smaller than the chosen width, so it keeps its own size. | |
| 32 | Upload: in progress | Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload. | "Add an image" / "Cancel upload" |
| 33 | Upload: rejected | Checked in the browser, so nothing is sent. | "Add an image" / "Cancel" / "Choose another file" |
| 34 | Upload: inserted | 201: the image goes in at the cursor, and the article now has unsaved changes. | "Image added" / "Save to keep it in the article." |
| 35 | Publishing | "Save and publish": the PATCH body, then the publish request. | "Saving and publishing…" |
| 36 | Published | 200: the badge flips, Unpublish is offered and a toast confirms. | "Article published" / ""How we price a bathroom renovation" is now online." |
| 37 | Unpublishing | Unpublish doesn't save pending edits. | "Unpublishing…" |
| 38 | Delete: confirm | Delete… from the overflow menu. After a 204, it goes to /article with the "Article deleted" toast. | "Delete this article?" / ""How we price a bathroom renovation" and its 3 images will be deleted. This can't be undone." / "Cancel" / "Delete article" |
| 39 | Leave with unsaved changes | Back, a nav link or closing the tab. | "Leave without saving?" / "Your changes to "How we price a bathroom renovation" haven't been saved." / "Discard changes" / "Keep editing" |
| 40 | Offline | The connection dropped. Writing continues, and Save and Publish wait. | |
| 41 | Not found (404) | Unknown id, or the article was deleted elsewhere. | |
## Changed in pass 3: site and authors
Every existing Editor frame that has the details sidebar shows the "Site and authors" section collapsed, as the SPA renders it: one summary line ("Inès Duarte") because the install has one site and the article has an author, so there is no Site select. In the Loading frame the header is shown with a skeleton summary. The Not found frame has no editor, so it has no section. On mobile the section lives in the Details sheet, closed by default, so the mobile editor frames do not show it until the sheet is opened. The group below holds only the states specific to the feature.
### Editor: site and authors (group "Editor: site and authors", 16 states)
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {site_id, author_ids[]} · GET /v1/site/ · GET /v1/author/ · POST /v1/author/.
States specific to this feature. The collapsed default (one site, the article has an author) and the Loading and Not found states are drawn in the existing Editor frames, which show the section in the same place; they are not repeated here. The section is open when the install has more than one site or the article has no authors, and collapsed to one summary line otherwise; the choice is remembered per browser. On mobile it is closed by default. With one site the Site select is not rendered. Anyone who can edit the article can set its site and pick authors; "Create author…", the Create author dialog and its "This is me" checkbox are for owner and admin only. For any other role, the "Create my author profile" hint (state 4) creates the profile directly with POST /v1/author/me, with no dialog, then shows the author in the list with a toast. Authors are a reorderable list; the first is the primary byline.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Open: one site, one author | The section was opened. No Site select. | Authors · Add an author |
| 2 | Open: several sites | Site select above the authors, with the canonical host as helper text. | Site · Journal · journal.renew.example · Canonical host: journal.renew.example |
| 3 | Site select open | The select is open. Options show name and host; the default site is first. | Renew · renew.example · Journal · journal.renew.example · Projects · Archive |
| 4 | No authors, with hint | The article has no authors and the user has no author profile. The hint is dismissible and does not return once dismissed. "Create my author profile" creates the profile directly for every role (POST /v1/author/me, no dialog), then the author appears in the list and a toast confirms. | You don't have an author profile yet · Create one to be credited on your articles. · Create my author profile · No authors. This article will have no byline. |
| 5 | Many authors | Four authors. The first shows "Primary". Each row has a drag handle and a remove button. Long names truncate with an ellipsis and carry the full name in title and aria-label. | Primary · Drag to reorder. The first author is the primary byline. · Dr. Alexandra Konstantinopoulou-Whitfield |
| 6 | Add author: open | The Add an author combobox is open. Authors already on the article are checked and dimmed. "Create author…" is shown only to owner and admin. | Search authors · Create author… |
| 7 | Add author: no match | Typing a name that matches nobody. "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". · Create author "Zoe Lambert"… |
| 8 | Add author: open (no create) | A role without author rights. The combobox lists the authors with no "Create author…" row, because that row is shown only to owner and admin. | Search authors |
| 9 | Add author: no match (no create) | A role without author rights types a name that matches nobody. Only the message shows; there is no create link, because "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". |
| 10 | Add author: error | GET /v1/author/ failed. The article keeps its authors. | Couldn't load authors · Check your connection and try again. · Try again |
| 11 | Reorder: keyboard | Space or Enter on the handle grabs the row. Arrow keys move it. | Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel. |
| 12 | Reorder: pointer | Dragging a row. The row lifts and a line shows the drop position. | |
| 13 | Create author: dialog | "Create author…" in the dropdown, owner and admin only. The writer stays in the editor. | Create author · Adds a byline for this article. You can add a bio and avatar later in Authors. · Name · Slug · Zoe Lambert · zoe-lambert · Cancel · Create author |
| 14 | Create author: for me | Owner and admin only. When the user has no author profile, a checkbox links the new author to them. | This is me · Links the author to marta@renew.example. |
| 15 | Create author: slug taken | The slug exists. The dialog suggests a free one. | The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2 |
| 16 | Create author: failed | 500 or network failure. | Couldn't create the author · The article is unchanged. Try again in a moment. |
## Editor: search and sharing (SEO round)
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed[]}.
RenewCMS is headless: it never renders pages or adds meta tags. This section helps the writer fill in SEO data and shows what the frontend will receive. Everything is optional and each empty field shows its fallback. Placement: a "Search and sharing" section under "Site and authors" in the sidebar and in the mobile Details sheet. Collapsed by default with a one-line summary (Ready, N suggestions, Hidden from search), remembered per browser. The fields and live previews open in a slide-over (desktop, 960 px) or a 92% sheet over the Details sheet (mobile). Anyone who can edit the article can edit these fields. Suggestions never block saving or publishing: publishing is allowed regardless. Counters are soft guides and turn amber, never red, outside the recommended range. Light and dark follow the theme switch above.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Collapsed: ready | The default. Nothing to suggest. | Search and sharing · Ready |
| 2 | Collapsed: suggestions | Summary in the warning colour. No count badge elsewhere. | Search and sharing · 2 suggestions |
| 3 | Collapsed: hidden | noindex is on. | Search and sharing · Hidden from search |
| 4 | Open: ready | Address, the all-good message and two actions. | Address · renew.example/how-we-price-a-bathroom-renovation · All good · Nothing to suggest for this article. · Edit search and sharing · What the frontend gets |
| 5 | Open: suggestions | At most three suggestions here, then a count. Each links to its field. | The meta title is 74 characters. Search engines may cut it off after about 60. · Go to meta title · The cover image has no alt text. Add a short description of it. · Go to cover image · The body has no headings. H2 headings help readers scan the article. · Go to body · +1 more in Search and sharing |
| 6 | Open: checking | While the checks run after the editor loads. The rest of the section stays usable. | Checking this article… |
| 7 | Open: hidden | A line explains the flag. | Hidden from search engines. This only sets a flag in the API. |
| 8 | Open: other site | The address uses the article's site. | journal.renew.example/how-we-price-a-bathroom-renovation |
| 9 | Panel: defaults | A writer who changed nothing. Every value is a fallback and says so. | Search and sharing · URL slug · Generated from the title until you edit it. · Meta title · Using article title · Recommended 30 to 60 characters. This is a guide, not a limit. · Meta description · Using first sentence of the body · Recommended 70 to 160 characters. · Excerpt · A short summary. It is also sent as the article teaser. · Cover image · Using the site social image (north-wall.jpg). · Social sharing · Optional · Uses the meta values · Advanced · Canonical URL: automatic · Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. · Search result · Social card · Suggestions · What the frontend gets · Cancel · Save changes |
| 10 | Panel: filled in | Own values everywhere. The fallback chips are gone and the suggestion list shows All good. | All good · Nothing to suggest for this article. |
| 11 | Panel: outside the soft ranges | Counters turn amber. The preview shows the cut-off. Nothing is blocked. | The meta title is 74 characters. Search engines may cut it off after about 60. · The description is 41 characters. About 70 to 160 reads best in results. · Cut off here, the way a search engine would. |
| 12 | Panel: slug edited | The Auto badge is gone. "Reset to match the title" brings it back. | Reset to match the title |
| 13 | Panel: slug taken | Checked on blur and on save. Nothing is saved until a free slug is chosen. | The slug "bathroom-costs" is already used on Renew. · Use bathroom-costs-2 |
| 14 | Panel: cover without alt | Alt text is required before the cover counts as ready. It is a suggestion, not an error. | Alt text · Needed before the cover counts as ready. Sent as image alt in the API. · Describe the image for people who can't see it · The cover image has no alt text. Add a short description of it. |
| 15 | Panel: choose cover | The shared Media picker. | Choose a cover image · The cover for this article. · Use image |
| 16 | Panel: social sharing open | Optional overrides. Each falls back to the meta values. | Social sharing · Optional · Social title · Using meta title · Social description · Using meta description · Social image · Using the cover image. |
| 17 | Panel: social customised | The card shows the overrides, so the Using chips disappear. | Customised |
| 18 | Panel: advanced, invalid canonical | Collapsed by default. The address must be absolute. | Advanced · Canonical URL · Only change this if the same content lives at another address. · Enter a full address starting with https:// |
| 19 | Panel: hidden from search | The switch only sets a flag in the API. | Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. |
| 20 | Panel: mobile search preview | The Desktop and Mobile toggle changes the width and where the title is cut. | Search result · Desktop · Mobile |
| 21 | Panel: checking | Checks run on open and after each pause in typing. | Checking this article… |
| 22 | Panel: saving | Fields are disabled while the request runs. | Saving… |
| 23 | Panel: save failed | 500 or network failure. The values stay. | Couldn't save search and sharing. Your changes are still here. Try again in a moment. |
| 24 | Saved | The panel closes. The summary updates. | Search and sharing saved · Your search and sharing settings are up to date. |
Mobile: the section sits under "Site and authors" in the Details sheet. "Edit search and sharing" opens a 92% sheet on top of the Details sheet, with suggestions first, then fields, then the two previews. Publishing is allowed regardless of suggestions; Save and Publish are never disabled by them.
## Editor: what the frontend gets (SEO round)
Route: `/article/:id/edit`. API: GET /v1/article/{id}/seo.
A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved SEO object exactly as the API returns it. Opened from the sidebar section or the panel. Each value that came from a fallback carries a chip. Formatted shows a compact key and value list, Raw shows the JSON. Copy JSON copies the raw object. Author name, profile URL and social links come from the Authors screen and surface in authors[] and in the JSON-LD author, so Authors itself needs no change.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Formatted | Default view. | What the frontend gets · The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does. · Formatted · Raw JSON · Copy JSON · Close |
| 2 | Raw JSON | The JSON as the API returns it. | Raw JSON |
| 3 | Copied | The button reads Copied for 2 s and this is announced. | Copied |
| 4 | Draft article | Drafts answer 404 to anonymous callers. | This article is a draft. The API returns this data only to signed-in callers until it is published. |
| 5 | Loading | Copy is disabled. | – |
| 6 | Error | GET failed. Nothing in the article changes. | Couldn't load the SEO data · GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment. · Try again |
+30
View File
@@ -0,0 +1,30 @@
# Entry editor
Route: `/content/:key/new`, `/content/:key/:id`. API: POST /v1/content-type/{key}/entry · GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Entry editor". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 6 Entry editor (group "6 Entry editor", 13 states)
Route: `/content/:key/new, /content/:key/:id`. API: POST /v1/content-type/{key}/entry, GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: Any verified user.
The form is generated from the schema, one control per field in schema order. Required fields carry * and the line "Fields marked * are required." Defaults are pre-filled on create and tagged Default. Saving replaces the whole entry.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | New entry | /content/events/new. Online is pre-filled from its default. | New entry · Events · Fields marked * are required. · Default · Each entry needs a different title. · Between 1 and 5000. · Choose an option · Pick a date · Save · Cancel · New entry · Not saved yet | Available now |
| 2 | Editing | /content/events/42. Save is disabled until something changes. | Lime plaster workshop · Events · Entry 42 · Saved · Delete · Cancel · Save · Created by · Updated by · Inès Duarte · 12 Oct 2026, 09:14 · Marta Alves · 15 Oct 2026, 11:02 | Available now |
| 3 | Unsaved changes | Any edit. | Unsaved changes · 32 / 80 | Available now |
| 4 | Markdown preview | The Preview tab of a rich text field. | Write · Preview | Available now |
| 5 | Saving | PUT or POST in flight. Form dims. | Saving… | Available now |
| 6 | Saved | 200. | Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date. | Available now |
| 7 | Save failed | 500 or network failure. Values stay. | Save failed · Couldn't save the entry · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
| 8 | Required fields empty | 422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus. | Fill in Title. · Pick a date for Starts on. · Not saved | Available now |
| 9 | Rule errors | 422 invalid_entry for max, type, max and options. One rule per field is shown at a time. | Title can be up to 80 characters. · 91 / 80 · Ends on must be a date, like 2026-11-21. · Capacity must be at most 5000. · Choose one of: Talk, Workshop or Meetup. · Not saved | Available now |
| 10 | Duplicate unique value (409) | 409 invalid_entry: title: unique. | Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one. | Available now |
| 11 | Entry not found | 404 entry_not_found, for example after a delete elsewhere. | Entry not found · It may have been deleted by someone else. · Back to Events | Available now |
| 12 | Schema changed since load | 422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded. | The fields of Events changed · A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields. · Reload form | Available now |
| 13 | Delete entry: confirm | Delete. | Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can't be undone. · Cancel · Delete entry | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.6. Board groups: 6 Entry editor.
+20 -56
View File
@@ -1,68 +1,32 @@
# Forgot password
- **Route:** `/register/reset/request`
- **API:** POST /v1/auth/password-reset
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Forgot password" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/register/reset/request`. API: POST /v1/auth/password-reset.
The sent state reads the same whether or not the email exists, so the page doesn't reveal which emails have accounts.
## States
Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Forgot password". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default
## Copy per state (generated from the board source, pass 2)
**Trigger:** From "Forgot password?" on log in.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"
- Submit button: "Send reset link"
- Below the card: [Back to log in]
### Base copy
- Reset your password
- Enter the email for your account and we'll send you a link to choose a new password.
- Send reset link
- Back to log in
### Validation error
### Forgot password (group "Forgot password", 6 states)
**Trigger:** Invalid email on submit. Nothing is sent.
Route: `/register/reset/request`. API: POST /v1/auth/password-reset.
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew"; **error "Enter a full email address, like name@example.com."**
- Submit button: "Send reset link"
- Below the card: [Back to log in]
The sent state reads the same whether or not the email exists, so the page doesn't reveal which emails have accounts.
### Submitting
**Trigger:** Request in flight.
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example"
- Submit button: "Sending…", loading
- Below the card: [Back to log in]
### Sent
**Trigger:** Any 200, whether or not the account exists.
- ResultPanel (success, mail-check): **Check your email**; text "If an account exists for that email, a password reset link has been sent."; note "The link expires in 1 hour."; actions "Back to log in" (secondary)
### Too many attempts (429)
**Trigger:** Rate limit with Retry-After.
- Relied on an API marked pending in the design; that API now exists.
- Alert (info, timer): **Too many requests** "Wait a moment before asking for another link."
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example"
- Submit button: "Try again in 0:58", disabled
- Below the card: [Back to log in]
### Server error
**Trigger:** 500 or no response.
- Alert (error, circle-x): **Couldn't send the link** "Check your connection and try again."
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example"
- Submit button: "Send reset link"
- Below the card: [Back to log in]
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | From "Forgot password?" on log in. | |
| 2 | Validation error | Invalid email on submit. Nothing is sent. | "marta@renew" / "Enter a full email address, like name@example.com." |
| 3 | Submitting | Request in flight. | "marta@renew.example" / "Sending…" |
| 4 | Sent | Any 200, whether or not the account exists. | "Check your email" / "If an account exists for that email, a password reset link has been sent." / "The link expires in 1 hour." / "Back to log in" |
| 5 | Too many attempts (429) | Rate limit with Retry-After. | "Too many requests" / "Wait a moment before asking for another link." / "marta@renew.example" / "Try again in 0:58" |
| 6 | Server error | 500 or no response. | "Couldn't send the link" / "Check your connection and try again." / "marta@renew.example" |
+13 -28
View File
@@ -1,39 +1,24 @@
# Global states
- **Route:** `any route`
- **API:**
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Global" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `any route`. API: .
A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.
## States
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Global". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### 404 inside the shell
## Copy per state (generated from the board source, pass 2)
**Trigger:** An unknown route while logged in. Logged out, the same content appears inside AuthShell.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Not found (mobile title "Not found")
- err: {"code":"404","title":"Page not found","text":"The link may be mistyped, or the page may have moved.","primary":"Go to Home","secondary":"All articles"}
### Global states (group "Global", 4 states)
### Generic error
Route: `any route`. API: none.
**Trigger:** An unexpected error while rendering a route.
A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.
- Breadcrumbs: Home (mobile title "Home")
- err: {"code":"Error","title":"Something went wrong","text":"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.","primary":"Try again","secondary":"Go to Home"}
### Offline banner
**Trigger:** navigator.onLine is false. The banner is shown on every back-office page.
- Breadcrumbs: Articles (mobile title "Articles")
- offline: true
### Toast stack
**Trigger:** Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article published** ""Winter checklist for timber windows" is now online."
- Toast (info, info): **That article no longer exists** "It was deleted somewhere else, so we removed it from the list."
- Toast (error, circle-x): **Couldn't unpublish** "The server didn't respond. Nothing changed." with action "Retry"
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | 404 inside the shell | An unknown route while logged in. Logged out, the same content appears inside AuthShell. | "Not found" / "Page not found" / "The link may be mistyped, or the page may have moved." / "Go to Home" / "All articles" |
| 2 | Generic error | An unexpected error while rendering a route. | "Home" / "Error" / "Something went wrong" / "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server." / "Try again" / "Go to Home" |
| 3 | Offline banner | navigator.onLine is false. The banner is shown on every back-office page. | |
| 4 | Toast stack | Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast. | "Article published" / ""Winter checklist for timber windows" is now online." / "That article no longer exists" / "It was deleted somewhere else, so we removed it from the list." / "Couldn't unpublish" / "The server didn't respond. Nothing changed." / "Retry" |
+38 -55
View File
@@ -1,66 +1,49 @@
# Home
- **Route:** `/home`
- **API:** GET /v1/article/
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Home" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/home`. API: GET /v1/article/ · GET /v1/activity?size=5.
The greeting reads "Good morning, {name}", where the name is the display name when the user has saved one and the username otherwise. The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor.
The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user's own (regular users), and links to Activity.
## States
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Home". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default
## Copy per state (generated from the board source, pass 2)
**Trigger:** The articles loaded.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Stats: 6 articles, 4 online, 2 drafts
- Recent: the 5 most recently updated articles, with status
### Fixed copy in the frame
- New article
- Couldn't load your articles
- Check your connection and try again.
- Try again
- Write your first article
- It starts as a draft, so nothing appears on your site until you publish it.
- Recently updated
- All articles
- Online
- Draft
- View all
- Couldn't load recent activity
- No activity yet
- Failed
- Connect your website
- Your site reads published articles from RenewCMS. Share this guide with whoever builds it.
- Open the guide
### Loading
### Home (group "Home", 10 states)
**Trigger:** The list request is in flight. Skeletons appear after 150 ms.
Route: `/home`. API: GET /v1/article/ · GET /v1/activity?size=5.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Stats and recent list: skeletons
The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user's own (regular users), and links to Activity.
### Empty, first run
**Trigger:** Zero articles: a single call to action, and no stats.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Welcome to RenewCMS."
- First-run empty state instead of stats and recent list
### Error
**Trigger:** A 500 or network failure on the list request.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Error state for the list request
### Update available (owner)
**Trigger:** The owner only. A quiet banner above the content, and an "Update" pill on the Settings sidebar item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Banner: `role="region"` labelled "Update available", info tone, `circle-arrow-up` icon, title "Version 2.3.0 is available", text "Update it from Settings. It takes about 2 minutes and the site stays online.", button "Go to Settings", and a dismiss button
### Update ready to install (owner)
**Trigger:** The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
## Recent activity card
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | The articles loaded. | "Here's where things stand on your site." |
| 2 | Loading | The list request is in flight. Skeletons appear after 150 ms. | "Here's where things stand on your site." |
| 3 | Empty, first run | Zero articles: a single call to action, and no stats. | "Welcome to RenewCMS." |
| 4 | Error | A 500 or network failure on the list request. | "Here's where things stand on your site." |
| 5 | Update available (owner) | The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither. | "Here's where things stand on your site." / "Version 2.3.0 is available" / "Update it from Settings. It takes about 2 minutes and the site stays online." |
| 6 | Update ready to install (owner) | The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting. | "Here's where things stand on your site." / "Version 2.3.0 is ready to install" / "Restart from Settings to finish. The site is unavailable for about 10 seconds." |
| 7 | Recent activity: loading | The activity request is in flight. Five skeleton rows. | "Here's where things stand on your site." |
| 8 | Recent activity: error | A failed request shows an inline alert in the section only. The rest of Home is unaffected. | "Here's where things stand on your site." |
| 9 | Recent activity: empty | No events yet. | "Here's where things stand on your site." |
| 10 | Regular user | Own events only, titled "Your recent activity". The sidebar has no Users, Integration or Settings. | "Here's where things stand on your site." |
+85
View File
@@ -0,0 +1,85 @@
# Image variants
Route: `/settings/media/variants`, `Slide-over on desktop · full page on mobile`, `/settings/media/cache`, `/settings · /settings/media/variants · /settings/media/cache`. API: GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge · POST /v1/image-variants · PUT /v1/image-variants/{name} · GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image}.
Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Variants list", "Add edit variant", "Cache panel", "Settings navigation". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### 1. Settings → Media → Variants (group "Variants list", 12 states)
Route: `/settings/media/variants`. API: GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge.
Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Loaded | Six variants, one of them disabled (print). | |
| 2 | Loading | Skeleton rows appear after 150 ms. | |
| 3 | Empty | No variants. Two templates pre-fill the Add variant form. | |
| 4 | Error loading | A 500 or network failure on the list request. | |
| 5 | Forbidden | A signed-in user who is not an admin opens the URL. | |
| 6 | Row menu | The ellipsis button on a row. | |
| 7 | Row action in progress | Purge cache was confirmed on hero. The row is dimmed and its controls are disabled. | |
| 8 | Purge confirm | Purge cache in the row menu. | "purgeVariant" |
| 9 | Delete confirm | Delete in the row menu. | "deleteVariant" |
| 10 | Delete in progress | Delete was confirmed. The button shows a spinner. | "deleteVariant" |
| 11 | Entry: sidebar | From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer. | |
| 12 | Request reference | Mobile scrolled to the reference card. On desktop it is visible below the table. | |
### 2. Add / edit variant (group "Add edit variant", 11 states)
Route: `Slide-over on desktop · full page on mobile`. API: POST /v1/image-variants · PUT /v1/image-variants/{name}.
Form on the left, live preview on the right (stacked on mobile). The preview uses a sample image with a before/after divider. The divider is a slider: arrow keys move it by 1%, Shift+arrow by 10%, Home and End jump to the ends. Name is locked in edit mode. The edit warning is always visible in edit mode. Preview images are placeholders.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Create: pristine | Add variant just opened. Save is disabled. | "Original" |
| 2 | Create: dirty | Valid values. The divider has keyboard focus. Mobile is scrolled to the preview. | |
| 3 | Field errors | On blur and on submit. Name format, width above 8000, quality above 100. | "Hero banner" / "WebP" / "Use lowercase letters, numbers and dashes, 2 to 32 characters." / "Enter a value from 1 to 8000." / "Enter a value from 1 to 100." |
| 4 | No dimension | Width and height are both empty on submit. | "WebP" / "Set a width, a height, or both." |
| 5 | Name already taken | POST returns 409. | "18 KB" / "A variant named “thumb” already exists. Choose another name." |
| 6 | Saving | Request in flight. Fields and buttons are disabled. | |
| 7 | Save error | A 500 or network failure. Values are kept. | |
| 8 | Edit: pristine | Opened from Edit. Name is locked. Save is disabled. | |
| 9 | Edit: dirty | Quality changed from 82 to 70. | "71 KB" |
| 10 | Unsaved changes | Close, Cancel or Escape while dirty. | "71 KB" |
| 11 | PNG: no quality | Quality is hidden when the format is PNG. | "PNG" / "310 KB" |
### 5. Settings → Media → Cache (group "Cache panel", 10 states)
Route: `/settings/media/cache`. API: GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image}.
Stat tiles, a per-variant bar list (bar length is the disk size relative to the largest variant) and the ten images with the most variant disk usage. Purge all needs the word "purge" typed. Purge unused removes files of enabled variants not requested in 30 days.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Loaded | Default view. | |
| 2 | Loading | Tiles and lists show skeletons. | |
| 3 | Empty cache | Nothing generated. Purge buttons are disabled. | |
| 4 | Purging | Progress for a purge in progress. Content is dimmed. | |
| 5 | Purge done | The cache is empty and a toast reports the result. | "Purged 1,284 files, freed 212 MB" |
| 6 | Purge failed | Part of the purge did not complete. | "Purge didn't finish" / "640 of 1,284 files were removed. Try again." / "Try again" |
| 7 | Error loading | A 500 or network failure. | |
| 8 | Purge all: confirm | The word is not typed yet. The button is disabled. | "purgeAll" |
| 9 | Purge all: confirm, typed | The input matches "purge". | "purgeAllOk" |
| 10 | Purge unused: confirm | Variants of enabled variants not requested for 30 days. | "purgeUnused" |
### Settings navigation: parent and child selected (group "Settings navigation", 5 states)
Route: `/settings · /settings/media/variants · /settings/media/cache`. API: none.
One sidebar for the whole Settings area. Settings is the parent row and expands to a Media group with Variants and Cache. Parent selected: the Settings row uses --rc-sand with --rc-on-sand. Child selected: the Settings row is not filled (--ui-text-highlighted, weight 500, chevron down) and only the child row uses --rc-sand with --rc-on-sand. Use the theme switch for dark. Hover, focus-visible and active on a selected row keep --rc-sand and --rc-on-sand; see foundations.md.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | N1 · Parent selected | /settings. Settings row filled, Media group visible, children unselected. | |
| 2 | N2 · Child selected: Variants | /settings/media/variants. Settings row unfilled and bold, Variants filled. | |
| 3 | N3 · Child selected: Cache | /settings/media/cache. Settings row unfilled and bold, Cache filled. | |
| 4 | N4 · Mobile drawer, parent selected | Menu button on /settings. Drawer rows are 44 px tall. | "Settings" |
| 5 | N5 · Mobile drawer, child selected | Menu button on /settings/media/variants. | "Settings" / "Media" / "Variants" |
+40 -30
View File
@@ -2,7 +2,7 @@
Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`.
- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Visual reference: `RenewCMS Screens API keys.dc.html`.
## API
`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session."
@@ -16,7 +16,7 @@ Under Integration, as a sub-page linked from the Authentication section ("Manage
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only.
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
- One-time reveal: warning "You won't be able to see this key again.", the key in a CodeSnippet with one tab ("API key") and Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
- Note: "Resetting a user's password revokes all of their keys."
@@ -25,34 +25,44 @@ Under Integration, as a sub-page linked from the Authentication section ("Manage
On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card).
## States
Names are matched exactly by the visual-check script.
### Owner: list
### Admin: own keys only
### Loading
the list request is in flight. Skeleton rows appear only after 150 ms.
### Empty
### Error
a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
### Create key
### Create key: validation
### Create key: name too long
### One-time reveal
### One-time reveal: copied
### Revoke: confirm
### Revoked
- Owner: list
- Admin: own keys only
- Loading: the list request is in flight. Skeleton rows appear only after 150 ms.
- Empty
- Error: a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
- Create key
- Create key: validation
- Create key: name too long
- One-time reveal
- One-time reveal: copied
- Revoke: confirm
- Revoked
## Component inventory (Nuxt UI v4)
`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
`UTable`, `UBadge`, `UModal` (dialog on desktop, bottom sheet on mobile, footer secondary left and primary right) with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, CodeSnippet for the key.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens API keys.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### API keys (group "API keys", 12 states)
Route: `/integration/api-keys`. API: GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} (session only).
Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Owner: list | Owners see every user's keys and a "Created by" column. Expired keys keep their Revoke action. | "Integration" / "API keys" |
| 2 | Admin: own keys only | Admins see only the keys they created, with no "Created by" column. | "Integration" / "API keys" |
| 3 | Loading | The list request is in flight. Skeleton rows appear after 150 ms. | "Integration" / "API keys" |
| 4 | Empty | No keys yet. | "Integration" / "API keys" |
| 5 | Error | A 500 or network failure on the list request. | "Integration" / "API keys" |
| 6 | Create key | "Create key" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional. | "Integration" / "API keys" |
| 7 | Create key: validation | On submit or blur. Nothing is sent. The name is empty and the expiry is in the past. | "Integration" / "API keys" |
| 8 | Create key: name too long | Over 60 characters. Checked as you type. | "Integration" / "API keys" |
| 9 | One-time reveal | 201: the full key is shown once. Closing the dialog discards it from the screen. | "Integration" / "API keys" |
| 10 | One-time reveal: copied | Copy was pressed. The button reads "Copied" for 2 s and this is announced. | "Integration" / "API keys" |
| 11 | Revoke: confirm | Revoke on an active or expired row. | "Integration" / "API keys" |
| 12 | Revoked | 204: the key stays in the list, greyed, with no Revoke action. A toast confirms. | "Integration" / "API keys" |
+157 -30
View File
@@ -2,7 +2,7 @@
Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Visual reference: `RenewCMS Screens Back-office.dc.html`, group "Integration".
## Navigation decision
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
@@ -10,55 +10,40 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
## Page copy
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced).
- Base URL: label "Base URL", a CodeSnippet with one tab ("Base URL") and the button "Copy" / "Copied" (2 s, announced).
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
## EndpointCard states
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response in one CodeSnippet with Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Chips: access "Public", "Verified user", "Needs permission" (routes gated by a role permission; the permission name is in the route description), "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## States
Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script.
### 1. Default
### 2. Group scrolled, one card expanded
### 3a. Copied: base URL
### 3b. Copied: CodeBlock
### 4. Authentication
### 5. Session only
### 6. Errors
### 7. Navigation
Desktop 1440×900, mobile 390×844, light and dark.
1 Default · 2 Media group, POST /v1/media/ expanded with curl tab · 3a Base URL copied · 3b CodeSnippet copied · 4 Authentication · 5 Session only · 6 Errors · 7 Navigation (index active item Media; mobile select open).
## Error messages
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future."
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
- 401: "You must be logged in to access this resource."
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
- 403: "This action needs a real session." (session-only routes called with a key) / `missing_permission` (the role of the user, or of the key's creator, lacks the permission the route needs) / "You must verify your account to access this resource." Note under the 403 row: "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error"
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000).
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, optional, no length limit).
- `PATCH /v1/article/{id}`: `body` (string, optional, no length limit).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required).
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
@@ -67,7 +52,7 @@ Plain text, one sentence. Used in the table and in the per-endpoint error captio
- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`.
## Component inventory and Nuxt UI v4
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock.
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), CodeSnippet (fetch, curl, Response). CodeSnippet is the only code block in the product: Integration, Integration images, API keys (one-time key) all use it. Selected tab: 2 px `--ui-primary` underline. Syntax colouring was dropped so there is one rendering.
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
@@ -80,3 +65,145 @@ Plain text, one sentence. Used in the table and in the per-endpoint error captio
## Tokens
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Integration: API reference (group "Integration", 8 states)
Route: `/integration`. API: none (static). Replaces "Connect your website"..
Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | 1. Default | Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the "Jump to" select above the content. | "Integration" |
| 2 | 2. Group scrolled, one card expanded | POST /v1/media/ opened in the Media group: multipart body with 1 file field, curl tab shown. Only one example block is visible per card. Other cards stay collapsed. | "Integration" |
| 3 | 3a. Copied: base URL | The base URL was copied. The button reads "Copied" for 2 s and this is announced. | "Integration" |
| 4 | 3b. Copied: CodeBlock | The Copy button in the API key example after a click. Back to "Copy" after 2 s. | "Integration" |
| 5 | 4. Authentication | The API key card with its example, the "Manage API keys" link (goes to the API keys board) and the two notes. | "Integration" |
| 6 | 5. Session only | Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error. | "Integration" |
| 7 | 6. Errors | Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. | "Integration" |
| 8 | 7. Navigation | Desktop: the sticky index with the active item (Media). Mobile: the "Jump to" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list. | "Integration" |
### Integration: images and ?v= (group "Integration: images", 9 states)
Route: `/integration#images`. API: GET /v1/image-variants (existing).
New section "Images and variants" on the Integration page, after Media and before Errors. It explains the ?v=<variant-name> parameter, shows one CodeSnippet with curl, HTML and JavaScript tabs, a table of enabled variants, a URL builder and the response codes. When there are no variants, admins see a link to /settings/media/variants and other users see plain information. A removed variant is not replaced by the original: the request returns 400 with the body shown in the builder.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Many variants | Twelve enabled variants. The table scrolls at 264 px. On mobile the table and snippet scroll horizontally. | |
| 2 | One variant | A single enabled variant. The snippets use its name. | |
| 3 | No variants, admin | Empty state with a link to /settings/media/variants. | |
| 4 | No variants, not an admin | Plain information, no link. | |
| 5 | Variant removed | The builder holds "hero", which was deleted. Warning with the exact 400 body. | |
| 6 | Loading | Skeleton blocks while variants load. Snippets are hidden until the list is known. | |
| 7 | Error loading | 500 or network failure. Snippets still render with the placeholder name. | |
| 8 | Snippet copied | Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely. | |
| 9 | URL builder copied | Copy pressed on the built URL. | |
## Pass 2 notes
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
- Page padding 24 px (16 px on mobile), page title 20/28, section headings 18/26, card titles 15/24, sub headings 14/22 (foundations.md).
## Search and sharing (SEO round)
New group "Search and sharing" after Articles, in the sidebar index and the mobile Jump to select. Two cards in the existing endpoint style (collapsed, method badge, auth badge "Public", example, 401 and 404 rows, copy buttons):
- GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers.
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
## Search (jump-to palette)
Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working.
### Behaviour
- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts.
- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger.
- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows.
- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first.
- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep-<id> so the result can be shared.
- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row.
- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count.
### Row anatomy
- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted.
- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description.
- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated.
- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2.
### Copy per state
| # | State | Copy |
|---|---|---|
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "10 routes"); count and live "17 results, showing 7" |
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "N more endpoints. Keep typing to narrow the list."; count "22 results"; live "22 results, showing 7" |
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "35 routes, not available to API keys"; "2 results" |
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
### Nuxt UI v4 mapping
- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }).
- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling.
- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite".
- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only.
### Assumptions to confirm
- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples.
- Matching is substring per term, not fuzzy ("mdia" does not match "media").
- The palette shows the first sentence of each endpoint description, not the full text.
- Endpoints are capped at 6 rows with a count line; sections are never capped.
- Recent searches are left out. Open question: store the last 3 queries in localStorage?
- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm).
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
## App sync notes
- Schema routes show the permission key as their access chip: schema.view (GET), schema.edit (POST, PUT), schema.delete (DELETE). Purge routes show "Needs permission" until the app names the key.
- 403 body for these routes: "Your role doesn't include this action. Ask an owner or admin." (app string, messages.ts).
- Entries intro: "Writing entries needs the entry permissions for that type." (proposal; the app has no string yet).
- Remaining "owners and admins" wording (activity export, content-types notice) stays as the app ships it.
- Article list body: {items, total, page, size, counts} (no X-Total-Count). counts keys: all, online, draft, suggestions.
- Users group has 7 routes: list, PATCH me, invite, cancel invite, renew invite, DELETE /v1/user/{id}?expected_role_id=N (permission, 409 target_role_not_found), GET /v1/auth/me. Profile routes use /v1/auth/me.
- 404 adds site_not_found (sitemap, unknown key). The 403 note sits under its row at full row width. Every 400 uses the generic body-mismatch text ("The request cannot be processed due to a mismatch in the format of the body."); "Invalid page or size." is gone.
## Custom content types round
Board: group "Integration", states 9a to 9j. Source: `IntegrationPage.dc.html`, `IntegrationSearch.dc.html`. Every route an API key can call is now documented.
### New groups
- Content types (`ctypes`): description "Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes." A "Field types" table (Type, Value in an entry, Rules) and a sample field object sit above the routes (anchor `ct-fields`).
- `GET /v1/content-type/` and `GET /v1/content-type/{key}`: Verified user. 404 `content_type_not_found`.
- `POST /v1/content-type/`: Owner or admin. Body `key` (2 to 40, lowercase letters, digits, hyphens), `name` (max 100), `icon` (optional), `fields` (1 to 50). 409 `content_type_key_taken`.
- `PUT /v1/content-type/{key}`: Owner or admin. Body `name`, `icon`, `fields`. 409 `schema_incompatible`.
- `DELETE /v1/content-type/{key}`: Owner or admin. 204. 409 `content_type_has_entries`, 409 `content_type_referenced`.
- Entries (`entries`): `GET` and `POST /v1/content-type/{key}/entry`, `GET`, `PUT` and `DELETE /v1/content-type/{key}/entry/{id}`. Verified user. List query `page`, `size` (5, 25, 50, 100), `sort` (`id`, `created_at`, `updated_at`, then `:asc` or `:desc`); the response is `{ items, total, page, size }`, not a bare array. Body `data` (object keyed by field key). `media` in a response is keyed by field key. 409 and 422 bodies are both `invalid_entry: <reason>`; 409 only when a unique field clashes. Id 400: "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
- Built-in types (`builtin`): `GET /v1/builtin-type/` and `/{key}`, Owner or admin. 404 `builtin_type_not_found`.
- Sites and authors (`sites`): `GET /v1/site/` and `GET /v1/author/` (paged, `{ items, total, page, size }`), Verified user.
- Media adds `GET` and `PATCH /v1/media/purge-settings` (`auto_purge_enabled`, `auto_purge_interval_days` 1 to 365; response also `last_purge_at`, `last_purge_merged`), `GET /v1/media/purge-preview` and `POST /v1/media/purge` (optional `fingerprint` from the preview). Owner or admin.
- PUT uses the PATCH badge colours.
### Access and errors
- 403 rows: "Only owners and admins can do this." (owner-or-admin routes) and "Only the owner can do this.", both listed in the 403 row; the existing note stays.
- 404 adds `content_type_not_found`, `entry_not_found`, `builtin_type_not_found`. 409 and 422 are real rows now (earlier text said there is no such row). Code-style text is the literal body.
- Authentication copy says a key can also manage content types and entries, and owner or admin routes answer 403 to a key whose creator is neither.
### Search
The index covers the new endpoints, body fields (`data`, and the events sample keys such as `capacity`) and error codes. "entry" also matches "entries". "N more endpoints" is singular for one.
### States 9a to 9j
9a create a content type, curl tab · 9b entries list, Response tab · 9c entry replace with the 409 and 422 errors · 9d Errors table · 9e Session only, 6 sub-groups · 9f search "entry" · 9g search "capacity" (Body field) · 9h search "schema_incompatible" (Error) · 9i search "content type" (Sections) · 9j navigation with scroll-spy on Content types. 8c now announces "17 results, showing 7" and 8d "22 results, showing 7" (the copy table above predates this; the board is right).
### Final round
- `POST /v1/media/purge` and the built-in type samples now match the API (no `icon`; keys `article`, `author`, `site`, `media`, `user`). Built-in types are read-only through the API.
- `GET /v1/media/` takes `page` (default 1) and `size` (5, 25, 50, 100; default 25), returns a JSON array and sends the total in `X-Total-Count`; 400 "Invalid page or size."
- Counts: Articles 7, Search and sharing 2, Content types 5, Entries 5, Built-in types 2, Media 10, Sites and authors 2, Users and invites 6, Settings 1, Session only 35. Search index: 75 endpoints (40 documented, 35 session-only).
- Announced: 8c "17 results, showing 7", 8d "22 results, showing 7", 8f "2 results", 9f "8 results, showing 7", 9g "2 results", 9h "1 result", 9i "12 results, showing 8".
- Illustrative only: built-in sample fields, purge-preview sample, the reason after `invalid_entry:`, session-only row labels, entry `created_by`/`updated_by`, `cover` media data.
- Not confirmed by the design tool: dark mode, the search palette on mobile and the Session only list on mobile. `task visual:check` is the gate.
- `POST /v1/content-type/` and `POST …/entry` answer 201; POST cards show only the JSON body.
+22 -95
View File
@@ -1,107 +1,34 @@
# Log in
- **Route:** `/login`
- **API:** GET /v1/auth/registration (pending) · POST /v1/auth/login
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Log in" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/login`. API: GET /v1/auth/registration (pending) · POST /v1/auth/login.
After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open.
## States
Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Log in". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default
## Copy per state (generated from the board source, pass 2)
**Trigger:** Registration is closed, which is the usual case.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"
- Field `Password`: password with show/hide; link "Forgot password?"
- Submit button: "Log in"
### Base copy
- Log in
- Welcome back. Log in to manage your articles.
- Log in
### Fresh install
### Log in (group "Log in", 9 states)
**Trigger:** GET /v1/auth/registration returns { open: true }.
Route: `/login`. API: GET /v1/auth/registration (pending) · POST /v1/auth/login.
- Relied on an API marked pending in the design; that API now exists.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"
- Field `Password`: password with show/hide; link "Forgot password?"
- Submit button: "Log in"
- Footer: First time here? [Create the owner account]
After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open.
### Validation errors
**Trigger:** Submit with a short username and an empty password. Nothing is sent.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "ma"; **error "Use at least 3 characters."**
- Field `Password`: password with show/hide; link "Forgot password?"; **error "Enter your password."**
- Submit button: "Log in"
### Submitting
**Trigger:** The request is in flight. Fields and button are disabled.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash2026"; password with show/hide; link "Forgot password?"
- Submit button: "Logging in…", loading
### Wrong credentials (403)
**Trigger:** The API doesn't say which field is wrong, so neither does the message. The password is cleared and gets focus.
- Alert (error, circle-x): **That username and password don't match** "Check both and try again. Passwords are case-sensitive."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: password with show/hide; focused; link "Forgot password?"
- Submit button: "Log in"
### Too many attempts (429)
**Trigger:** Retry-After is counted down. The button re-enables at zero.
- Relied on an API marked pending in the design; that API now exists.
- Alert (info, timer): **Too many attempts** "For your security, wait a moment before trying again."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash"; password with show/hide; link "Forgot password?"
- Submit button: "Try again in 0:42", disabled
### Session expired
**Trigger:** Redirected from a 401 with ?redirect=/article/12/edit&reason=expired. Unsaved edits are kept in the browser.
- Alert (info, clock): **Your session expired** "Log in again to pick up where you left off. Unsaved changes are kept on this device."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: password with show/hide; focused; link "Forgot password?"
- Submit button: "Log in"
### Password changed
**Trigger:** Arrived from a successful reset with ?reason=password-changed.
- Alert (success, circle-check): **Password changed** "Log in with your new password."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"
- Field `Password`: password with show/hide; link "Forgot password?"
- Submit button: "Log in"
### Server or network error
**Trigger:** A 500 or no response. The fields keep their values.
- Alert (error, circle-x): **Couldn't reach RenewCMS** "Check your connection and try again."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash2026"; password with show/hide; link "Forgot password?"
- Submit button: "Log in"
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | Registration is closed, which is the usual case. | |
| 2 | Fresh install | GET /v1/auth/registration returns { open: true }. | "First time here?" / "Create the owner account" |
| 3 | Validation errors | Submit with a short username and an empty password. Nothing is sent. | "Use at least 3 characters." / "Enter your password." |
| 4 | Submitting | The request is in flight. Fields and button are disabled. | "Logging in…" |
| 5 | Wrong credentials (403) | The API doesn't say which field is wrong, so neither does the message. The password is cleared and gets focus. | "That username and password don't match" / "Check both and try again. Passwords are case-sensitive." |
| 6 | Too many attempts (429) | Retry-After is counted down. The button re-enables at zero. | "Too many attempts" / "For your security, wait a moment before trying again." / "Try again in 0:42" |
| 7 | Session expired | Redirected from a 401 with ?redirect=/article/12/edit&reason=expired. Unsaved edits are kept in the browser. | "Your session expired" / "Log in again to pick up where you left off. Unsaved changes are kept on this device." |
| 8 | Password changed | Arrived from a successful reset with ?reason=password-changed. | "Log in with your new password." |
| 9 | Server or network error | A 500 or no response. The fields keep their values. | "Couldn't reach RenewCMS" / "Check your connection and try again." |
+35 -41
View File
@@ -1,56 +1,50 @@
# Media picker (editor)
# Media picker
- **Route:** `/article/:id/edit` (inside the editor's existing "Add an image" dialog)
- **API:** GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media picker", drawn by `source/MediaFrame.dc.html`.
Route: `/article/:id/edit`, `/articles/{id}/edit`. API: GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page) · Inserted URL: /media/{file}?v={name}.
The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.
This supersedes the upload-only "Add an image" dialog described in [editor](editor.md), and the "Remove this image?" confirmation from `features/editor_image_removal.feature`.
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media picker", "Editor picker". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Dialog
## Copy per state (generated from the board source, pass 2)
Title "Add an image", `x` to close, 640 px wide on desktop (bottom sheet on mobile). Two tabs under the title: **Upload** (`upload` icon) and **Library** (`images` icon). The dialog opens on Library when the library has images and on Upload when it is empty; dropping a file onto the editor opens Upload with the file provided, as before. Footer: "Cancel", and on Library a primary "Insert image", disabled until an image is selected.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
**Upload tab.** The current flow unchanged (drop zone "Drop an image here, or choose a file", help "PNG, JPEG, WEBP, SVG · max 10 MB", crop and resize, rejected, uploading and failed states). Two changes: the new image lands in the library and is inserted as before, and a chosen file whose content matches an existing image sends nothing and shows an info-bordered card: thumbnail, "Already in your library", `north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.`, with "Use existing" (primary) and "Upload anyway". "Use existing" goes to the alt text step for the existing image; "Upload anyway" continues to crop and upload.
### Picker in the editor (group "Media picker", 13 states)
**Library tab.** Search field ("Search by filename"), the line "Newest first · 48 images", a grid of square tiles (4 columns on desktop, 3 on mobile; scrolls inside the dialog) with the filename under each, and "Load more" below (24 images per page). Tiles are buttons labelled with filename, size and use; Enter or Space selects (single selection). A tile of an image already in this article carries a sand "In this article" tag. The selected tile gets a primary outline and a check. Once one is selected, the grid shrinks and a panel appears below it: 56 px thumbnail, filename, `dimensions · size`, and a field "Alt text (optional)" with placeholder "e.g. North wall of the kitchen before plastering" and help "Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration." Alt text lives only in the article, never on the image.
Route: `/article/:id/edit`. API: GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page).
| Library state | Copy |
|---|---|
| Empty | `images` icon, "Your library is empty" / "Images you upload appear here, ready to use in any article." / "Upload an image" (switches to Upload) |
| No results | `search` icon, `No images match "wall"` / "Check the spelling, or search for a shorter part of the filename." / "Clear search" |
| Failed | error alert "Couldn't load your library" / "Check your connection and try again." / "Try again" |
| Loading more | button reads "Loading…" with a loader, skeleton tiles at the end |
The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.
Insert (either tab): the image goes in at the cursor with the chosen alt text (empty alt means decorative), the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image added" / "Save to keep it in the article." The dialog then closes.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Upload tab | The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged. | "Upload" |
| 2 | Upload tab: already in your library | A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual. | "Upload" |
| 3 | Library: loading | The Library tab was opened, or a search changed. Skeleton tiles after 150 ms. | |
| 4 | Library: nothing selected | Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled. | |
| 5 | Library: selected | One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on. | |
| 6 | Library: alt text entered | Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image. | "A kitchen with new oak worktops and white tiles" |
| 7 | Library: loading more | "Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile. | |
| 8 | Library: empty | Zero images. "Upload an image" switches to the Upload tab. | |
| 9 | Library: no results | Search matched nothing. "Clear search" resets it. | |
| 10 | Library: failed to load | A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally. | |
| 11 | Inserted | Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes. | "Image added" / "Save to keep it in the article." |
| 12 | Remove image: confirm | Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?" | |
| 13 | Removed | Confirmed. The image leaves the article body, which now has unsaved changes. | "Image removed" / "It's still in your library." |
## Removing an image from the article
### 6. Editor image picker (group "Editor picker", 5 states)
The "Remove image" button on a selected image in the editor (and Backspace or Delete on it) now opens a neutral (not red) confirm, because nothing is deleted:
Route: `/articles/{id}/edit`. API: Inserted URL: /media/{file}?v={name}.
- Title: "Remove this image from the article?"
- Description: "It's removed from this article only. The file stays in your library and in any other article that uses it."
- Body: thumbnail, filename, "Stays in the library · 3.9 MB"
- Buttons: "Cancel" / "Remove image"
- Done: the image leaves the body, the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image removed" / "It's still in your library."
The Library tab of the picker gets a Variant select. Options are Original plus the enabled variants, each with its dimensions and format. Once inserted, a block whose variant was deleted or disabled shows an inline warning with a way to fix it. Disabled variants do not appear in the select.
No API call is made. Removing is a body edit only.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Variant select | An image is selected. The URL line shows what will be inserted. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 2 | Select open | Original plus five enabled variants. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 3 | No variants defined | Only Original is offered. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 4 | Variant deleted | The block keeps its URL. Pick another variant opens the select. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' no longer exists." |
| 5 | Variant disabled | Same block, different cause. Proposed copy. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' is disabled." |
## States
| State | Trigger and behaviour |
|---|---|
| Upload tab | The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged. |
| Upload tab: already in your library | A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual. |
| Library: loading | The Library tab was opened, or a search changed. Skeleton tiles after 150 ms. |
| Library: nothing selected | Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled. |
| Library: selected | One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on. |
| Library: alt text entered | Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image. |
| Library: loading more | "Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile. |
| Library: empty | Zero images. "Upload an image" switches to the Upload tab. |
| Library: no results | Search matched nothing. "Clear search" resets it. |
| Library: failed to load | A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally. |
| Inserted | Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes. |
| Remove image: confirm | Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?" |
| Removed | Confirmed. The image leaves the article body, which now has unsaved changes. |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.7. Board groups: 10 Media picker for a field.
+99 -135
View File
@@ -1,160 +1,124 @@
# Media
- **Route:** `/media` (verified users)
- **API:** GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
Route: `/media`, `/media?page=7&size=50`, `/media (detail panel)`, `/media?filter=duplicates`. API: GET /v1/media/?page=&size=&q=&filter= (total in `X-Total-Count`) · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage · GET /v1/media?variants=with|without&sort=total_size · GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} · owner and admin only.
New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
## Layout
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media library", "Image detail", "Media duplicates entry". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected).
## Copy per state (generated from the board source, pass 2)
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. On the Duplicates tab, owners and admins also get a secondary "Purge duplicates" button (`merge` icon) after the sort menu (full width, 44 px, in the stacked toolbar on mobile), hidden while there are no groups; it opens the PurgeDuplicatesDialog described in [settings](settings.md#media-cleanup-second-card). Below md the toolbar stacks: search, tabs, then sort and toggle.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid.
### Media (group "Media", 56 states)
| Badge | Look |
|---|---|
| Unused | warning-soft fill, `unlink` icon |
| Duplicate | info-soft fill, `copy` icon |
| Used in N | outline, toned text |
Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage.
List view: table from md up, columns Filename (with 40 px thumbnail and Duplicate badge), Dimensions, Size, Type, Used in (underlined count, opens the detail panel), Uploaded by, Uploaded (sorted, `arrow-down`); 56 px rows, 36 px header, first column is the checkbox. Below md it is a card list with a 44 px checkbox column, a 56 px thumbnail, filename, `size · dimensions · type`, badges plus `uploader · date`, and a trailing `chevron-right`.
New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
Selection: selected items take the sand fill (`--rc-sand`) with a primary outline. The checkbox shows on hover, on keyboard focus, on mobile, and whenever anything is selected. The bulk bar is sticky at the bottom (400 px wide on desktop, full width above the bottom edge on mobile): inverted fill, "3 selected", a danger "Delete" button (`trash-2`) and an inverted "Clear".
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. Below the grid: "Showing 1–12 of 12" and the page-size select (same paginator as Articles). | |
| 2 | Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. | |
| 3 | List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. | |
| 4 | Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. | |
| 5 | Empty library, first run | Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image". | |
| 6 | No results | Search or filter matched nothing. "Clear filters" resets the search and goes back to All. | |
| 7 | Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid. | "aria-label: kitchen-before.webp, 1.1 MB, used in 1 article" |
| 8 | Selection and bulk bar | Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it. | |
| 9 | Bulk delete: confirm | Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count. | |
| 10 | Bulk delete: in progress | Requests in flight. The dialog can't be dismissed. | |
| 11 | Bulk delete: partly failed | Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed. | "Couldn't delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." / "Retry" |
| 12 | Bulk delete: done | 204 for all. Focus moves to the next item. | "3 images deleted" / "They're gone from your library." |
| 13 | Detail panel: in use | Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close. | |
| 14 | Detail panel: unused | Used in nothing. The section reads "Not used in any article" and says the image is safe to delete. | |
| 15 | Detail panel: duplicate | The image has identical copies. An info alert links to the duplicates view. | |
| 16 | Copy URL | Copy URL. Clipboard write succeeded. Success toasts last 5 s. | "URL copied" / "Paste it wherever you need the image address." |
| 17 | Rename: dialog | Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same. | |
| 18 | Rename: saving | PATCH in flight. Field and buttons disabled, dialog can't be dismissed. | |
| 19 | Rename: validation | Empty or only symbols, on submit or blur. Nothing is sent. | "Enter a filename." |
| 20 | Rename: failed | 500 or network failure. The error shows inside the dialog. | "Couldn't rename the image. Your text is still here. Try again in a moment." |
| 21 | Rename: done | 200: the panel title and list update in place. | "Image renamed" / "It's now "north-wall-01.jpg". The URL is the same." |
| 22 | Delete: unused image | Delete in the panel. Plain confirm. Says what happens and that it can't be undone. | |
| 23 | Delete: image in use | Stronger confirm. Lists the affected articles and states that they will show a broken image. | "delUsed" |
| 24 | Delete: in progress | DELETE in flight. The dialog can't be dismissed. | "delUsed" |
| 25 | Delete: failed | 500 or network failure. The error shows inside the dialog, as in the article delete. | "Couldn't delete the image, so it's still there. Try again in a moment." |
| 26 | Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item. | "Image deleted" / ""window-frame.jpg" is gone from your library." |
| 27 | Image already gone (404) | Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list. | "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." |
| 28 | Upload: drag over the page | Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page. | |
| 29 | Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. | "2.2 MB" / "2.6 MB" / "1.1 of 2.6 MB" / "1.9 MB" |
| 30 | Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. | "2.2 MB" / "2.6 MB" / "Upload finished" / "5 images" / "2 uploaded · 1 already in your library · 2 need attention" |
| 31 | Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. | "210 KB" / "notes.pdf isn't an image" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." / "3.0 MB" / "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." / "Nothing was uploaded" / "3 files were rejected" |
| 32 | Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. Clock-sensitive: shows a relative time or a countdown. | |
| 33 | Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. | |
| 34 | Purge duplicates: confirm | "Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board. | |
| 35 | Duplicates: no purge button (member) | Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged. | |
| 36 | Duplicates: empty | No groups. The Duplicates tab loses its count badge. | |
| 37 | Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. | |
| 38 | Merge: in progress | Request in flight. The dialog can't be dismissed. | |
| 39 | Merge: failed | 500 or network failure. Nothing is switched or deleted. | "Couldn't merge the copies. Nothing was changed. Try again in a moment." |
| 40 | Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded. | "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again." |
| 41 | Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. | "Copies merged" / "2 copies merged. 5 articles updated." |
| 42 | List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. | |
| 43 | Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. | |
| 44 | Pagination: first page | /media. Previous and First are disabled. Summary "Showing 1–25 of 312". The paginator sits under the grid or list and uses the Articles controls: 5, 25, 50 or 100 per page, default 25; page and size live in the URL as page and size (/media?page=7&size=50); search, tab, sort, view and size changes reset to page 1. API: GET /v1/media/?page=&size=&q=&filter= with the total in X-Total-Count. | |
| 45 | Pagination: middle page | /media?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges. | |
| 46 | Pagination: last page | /media?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312". | |
| 47 | Pagination: list view | Same paginator under the table. On mobile: Previous, "Page 2 of 13", Next, with the 44 px targets. | |
| 48 | Pagination: loading next page | Next was pressed. Skeleton cards keep the grid height, controls are disabled, summary reads "Loading page 7…". | |
| 49 | Pagination: error loading a page | 500 or network failure on page change. Previous images stay; Retry sits in the notice. | "Couldn't load page 7. Showing page 6." |
| 50 | Pagination: page out of range | /media?page=99 is replaced with ?page=13 and this notice is shown. | "That page doesn't exist. Showing the last page." |
| 51 | Pagination: page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 52 | Pagination: focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
Detail panel: right-hand slideover 460 px wide on desktop (`x` to close), full screen on mobile with an `arrow-left`. Title is the filename. Body: 16:10 preview (4:3 on mobile), actions Copy URL (`link`), Rename (`pencil`), Delete (`trash-2`, error text), an info alert for duplicates, a metadata list (Type, Size, Dimensions, Uploaded, Uploaded by, URL in mono), then the "Used in" section.
### 3. Media library (group "Media library", 6 states)
Detail panel copy:
Route: `/media`. API: GET /v1/media?variants=with|without&sort=total_size.
| Where | Copy |
|---|---|
| Duplicate alert | "Identical to 2 other images" / "floorboards-copy.jpg and IMG_2208.jpg have the same content." / action "Review duplicates" (`arrow-right`) |
| Used | "Used in 2 articles", then one row per article: title (underlined link to the editor), badge "Online" (`globe`, inverted) or "Draft" (`pencil-line`, outline), `arrow-up-right`. Footnote: "Counted from the text of your articles each time you open this page." |
| Not used | "Not used in any article" / "This image is safe to delete. No article shows it." |
The existing grid with three additions: a "N variants" badge on cards that have cached files, an All / With variants / Without filter, and the sort option "Largest total size (original + variants)". When sorted by total size, the meta line shows the total.
## Dialogs
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Grid with badges | Default view. Images with no cached variants have no badge. | "New: badge" |
| 2 | Sort menu | The sort select is open. The new option is last. | "New: sort option" |
| 3 | Filter: With variants, sorted by total size | The meta line becomes total · original + variants. | "With variants" |
| 4 | Filter: Without variants | Images no variant has been generated for. | "Without" |
| 5 | Filter: nothing to show | With variants, and the cache is empty. | "With variants" |
| 6 | Delete confirm | Delete on an image that has cached variants. | "deleteImage" |
All dialogs use the existing ConfirmDialog look (`UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first). While a request runs the confirm button shows a `loader-circle`, both buttons are disabled and the dialog cannot be dismissed. A failure shows an error alert at the top of the dialog body and the dialog stays open.
### 4. Image detail: Variants tab (group "Image detail", 8 states)
| Dialog | Title | Description | Body | Cancel / Confirm (pending) |
|---|---|---|---|---|
| Rename | "Rename image" | "Only the filename changes. The URL stays the same, so articles that use this image keep working." | Field "Filename" with the extension fixed as a mono suffix (`.jpg`); help "Letters, numbers, dashes and underscores. The .jpg ending stays."; error "Enter a filename." on empty or only symbols | "Cancel" / "Rename" ("Renaming…") |
| Delete, unused | "Delete this image?" | ""window-frame.jpg" will be deleted from your library. No article uses it. This can't be undone." | none | "Cancel" / "Delete image" ("Deleting…"), danger |
| Delete, in use | "Delete an image that's in use?" | ""north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone." | list of affected articles with Online or Draft badges | "Cancel" / "Delete image" ("Deleting…"), danger |
| Bulk delete | "Delete 3 images?" | "2 of them are used in 4 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone." | one row per image: filename and "Used in N" or "Unused" | "Cancel" / "Delete 3 images" ("Deleting…"), danger |
| Merge | "Merge into floorboards.jpg?" | "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone." | the kept image: thumbnail, filename, "Kept · used in 4 articles now" | "Cancel" / "Merge 2 copies" ("Merging…"), danger |
Route: `/media (detail panel)`. API: GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name}.
Dialog errors: rename "Couldn't rename the image. Your text is still here. Try again in a moment."; delete "Couldn't delete the image, so it's still there. Try again in a moment."; merge (500) "Couldn't merge the copies. Nothing was changed. Try again in a moment."; merge (409) "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again."
The detail panel gets a Variants tab with one row per enabled variant. On desktop the row actions are icon buttons with tooltips and accessible names; on mobile they are labeled buttons on their own line. Only actions that apply are shown: generated rows offer Copy URL, Preview and Purge; others offer Copy URL and Generate now. The tab shows output dimensions as they will be generated, capped at the source size.
## Upload panel
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Partly generated | Four of five generated. Saving is shown against the original. | |
| 2 | Nothing generated yet | No cached files for this image. | |
| 3 | Generating | Generate all or Generate now was pressed. Rows show a spinner. | |
| 4 | Generation failed | The row shows the cause and Retry. Both causes are shown together for reference. | "Unsupported source format" / "Image too large" |
| 5 | No variants defined | There are no enabled variants. | |
| 6 | Source is an SVG | Variants aren't generated for vector images. | "SVG images are served as they are." |
| 7 | Source is an animated GIF | Same rule as SVG. | "Animated GIFs are served as they are." |
| 8 | Preview dialog | Preview on a generated row. Both at actual size. | |
A panel above the images while files are in flight and until dismissed. Header: "Uploading 6 images" with "2 of 6 done" and "Cancel all" (after it settles: "Upload finished" / "5 images" and "Dismiss"; if nothing was added: "Nothing was uploaded"). Files upload two at a time, one row each: 40 px thumbnail, filename, size, then per status:
### Media: Duplicates tab entry point (group "Media duplicates entry", 2 states)
| Status | Row |
|---|---|
| Waiting | "Waiting" |
| Uploading | "Uploading 1.1 of 2.6 MB" with a 4 px progress bar and a cancel `x` |
| Added | `circle-check` "Added to your library" |
| Duplicate | "Already in your library" / `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.` / "Use existing" and "Upload anyway" |
| Rejected | `file-x` title, then "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." Titles: `scan.tif is 14.2 MB` (too large), `notes.pdf isn't an image` (type), `broken.png couldn't be read` with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." (400) |
| Failed | `circle-x` "Upload failed" / "The server didn't accept the file. Nothing was added to your library." / "Try again" (`rotate-cw`) |
Route: `/media?filter=duplicates`. API: owner and admin only.
Summary line when settled: "2 uploaded · 1 already in your library · 2 need attention". Type and size are checked in the browser before anything is sent (PNG, JPEG, WEBP, SVG; 10 MB), by MIME type and by extension, exactly as the editor's uploader does. Dropping files onto the page shows a dashed primary-bordered area "Drop images to upload" / "PNG, JPEG, WEBP, SVG · max 10 MB each".
A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. The dialog and the member state are listed with the Media states above. Per-group "Merge into this one" is unchanged.
## Duplicates view
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Duplicates tab with the button | Owner or admin. | |
| 2 | Duplicates tab: offline | The button is disabled with the existing banner. | |
Groups stacked with 16 px gap. Each group is a bordered card: header "Group 1" and "3 identical images · 2.4 MB each · 3600 × 2400"; one column per copy (stacked on mobile) with thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", underlined "Used in 4 articles", and a "Merge into this one" button (`merge` icon). The suggested copy has the sand fill, a primary outline, a "Suggested" tag and a primary button; the others are outline. Footer: "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it." Empty: `copy` icon, "No duplicates" / "Every image in your library is different." / "Back to all images".
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.8. Board groups: 11 Media library usage.
## Other copy
| Where | Copy |
|---|---|
| Empty library | `images` icon, "No images yet" / "Upload your first image. You can use it in any article." / primary "Upload an image"; under it "PNG, JPEG, WEBP, SVG · max 10 MB each" |
| No results | `search` icon, `No images match "wall"` / "Nothing matches in Unused. Check the spelling, or clear the filters." / "Clear filters" |
| List error | error alert "Couldn't load your images" / "Check your connection and try again." / "Try again" |
| Rate limited | info alert `timer` "Too many uploads" / "For your security, wait a moment before uploading again."; the Upload button reads "Try again in 0:42" and is disabled |
| Offline banner | `wifi-off` "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection." |
Toasts (success 5 s unless noted):
| Trigger | Icon, title, description |
|---|---|
| Copy URL | `circle-check` "URL copied" / "Paste it wherever you need the image address." |
| Rename | `circle-check` "Image renamed" / `It's now "north-wall-01.jpg". The URL is the same.` |
| Delete | `circle-check` "Image deleted" / `"window-frame.jpg" is gone from your library.` |
| Bulk delete | `circle-check` "3 images deleted" / "They're gone from your library." |
| Bulk delete, partial | error, stays until retried or dismissed: `circle-x` "Couldn't delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." / "Retry" |
| Merge | `circle-check` "Copies merged" / "2 copies merged. 5 articles updated." |
| 404 | info `info` "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." |
## Status to copy
| Status | Where | Copy |
|---|---|---|
| 200 / 201 | Upload, rename, merge | Upload: row reads "Added to your library". Rename and merge: toasts above. |
| 204 | Delete, bulk delete | Toasts above. |
| 400 | Upload row | "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." In a dialog: "Something in the form isn't valid. Check the highlighted fields." |
| 401 | any | Global: /login?redirect=/media&reason=expired. No local copy. |
| 403 | any, unverified | Global: /register/pending. |
| 404 | rename, delete, merge, panel | The info toast above; the panel or dialog closes. |
| 409 | merge | Dialog alert above. |
| 413 | upload row | "scan.tif is 14.2 MB" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| 415 | upload row | "notes.pdf isn't an image" / same description. |
| 429 | upload | Rate-limited alert above, counting down Retry-After. |
| 500 / network | list, upload row, rename, delete, merge | The copy for each place above. |
| Offline | whole page | Banner above. |
## Keyboard and accessibility
Grid items are buttons, one tab stop each. Enter opens the detail panel, Space toggles selection, arrow keys move focus across the grid, Escape closes the panel or clears the selection. Focus returns to the item when the panel closes, and moves to the next item after a delete. Selection changes are announced ("north-wall.jpg selected, 1 selected"), as are upload completion and the merge result. Search, filter, sort and view live in the query string.
## States
| State | Trigger and behaviour |
|---|---|
| Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. |
| Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. |
| List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. |
| Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. |
| Empty library, first run | Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image". |
| No results | Search or filter matched nothing. "Clear filters" resets the search and goes back to All. |
| Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid. |
| Selection and bulk bar | Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it. |
| Bulk delete: confirm | Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count. |
| Bulk delete: in progress | Requests in flight. The dialog can't be dismissed. |
| Bulk delete: partly failed | Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed. |
| Bulk delete: done | 204 for all. Focus moves to the next item. |
| Detail panel: in use | Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close. |
| Detail panel: unused | Used in nothing. The section reads "Not used in any article" and says the image is safe to delete. |
| Detail panel: duplicate | The image has identical copies. An info alert links to the duplicates view. |
| Copy URL | Copy URL. Clipboard write succeeded. Success toasts last 5 s. |
| Rename: dialog | Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same. |
| Rename: saving | PATCH in flight. Field and buttons disabled, dialog can't be dismissed. |
| Rename: validation | Empty or only symbols, on submit or blur. Nothing is sent. |
| Rename: failed | 500 or network failure. The error shows inside the dialog. |
| Rename: done | 200: the panel title and list update in place. |
| Delete: unused image | Delete in the panel. Plain confirm. Says what happens and that it can't be undone. |
| Delete: image in use | Stronger confirm. Lists the affected articles and states that they will show a broken image. |
| Delete: in progress | DELETE in flight. The dialog can't be dismissed. |
| Delete: failed | 500 or network failure. The error shows inside the dialog, as in the article delete. |
| Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item. |
| Image already gone (404) | Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list. |
| Upload: drag over the page | Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page. |
| Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. |
| Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. |
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
| Duplicates: purge all | Owner or admin, groups exist. "Purge duplicates" opens the purge dialog (preview, confirm, progress, results), the same one as Settings "Purge now". "Merge into this one" is unchanged. |
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
| Merge: failed | 500 or network failure. Nothing is switched or deleted. |
| Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded. |
| Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. |
| List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. |
| Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. |
## App sync (batch A)
- Detail panel, "Used elsewhere" below the articles and entries lists: rows "Author avatar" (/authors) and "Site default social image" (/settings/sites). States: avatar only, site only, both, none (section absent). The "Used in N" badge includes these, N = articles + entries + elsewhere. "Not used in any article" appears only when N is 0; otherwise the line reads "No article shows it."
- Selected card (grid, mobile list): outline badges ("Used in N", "Draft") keep opaque bg-default over the sand tint, text colour unchanged. --ui-text-toned on --ui-bg: 9.7:1 light (#444 on #fff), 11.7:1 dark (#cfccc7 on #111).
- Media library board (variants): adds tabs (All, Unused, Duplicates), Select all, grid/list toggle, Duplicate badges, the variants filter row and the Details | Variants tab row. Variant URL: /static/uploadedImages/photo.png?v=thumb.
- Not yet done: mobile picker sheet height and tile count, desktop frame of the mobile-only state, stray miniatures.
+15 -61
View File
@@ -1,71 +1,25 @@
# New article
- **Route:** `modal from anywhere · /article/new`
- **API:** POST /v1/article/
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "New article" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `modal from anywhere · /article/new`. API: POST /v1/article/.
Recommendation: a modal. It needs two fields, and keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.
Recommendation: a modal. Title is the only field. The body is not asked for here; it is written in the editor, which opens straight after. Keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.
## States
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "New article". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default
## Copy per state (generated from the board source, pass 2)
**Trigger:** "New article" from Home, Articles or the empty state.
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: placeholder "e.g. Choosing tiles for a small bathroom"; focused; counter "0 / 200"; help "3 to 200 characters."
- field `First paragraph`: placeholder "Start writing. You'll continue in the editor."; textarea; help "You can format it and add images in the editor."
- cancel "Cancel"
- confirm "Create draft"
### New article (group "New article", 5 states)
### Validation
Route: `modal from anywhere · /article/new`. API: POST /v1/article/.
**Trigger:** On submit or blur. Nothing is sent.
Recommendation: a modal. Title is the only field. The body is not asked for here; it is written in the editor, which opens straight after. Keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Ti"; counter "2 / 200" in error; **error "Use at least 3 characters."**
- field `First paragraph`: textarea; **error "Add a first paragraph. It can be a single sentence."**
- cancel "Cancel"
- confirm "Create draft"
### Submitting
**Trigger:** Request in flight. Fields are disabled and the dialog can't be dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- cancel "Cancel"
- confirm "Creating…", loading, not dismissible
### Title rejected (400)
**Trigger:** A server 400 that got past the browser checks. The raw error isn't shown.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"; **error "Check this title and try again."**
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- Alert: "Something in the form isn't valid. Check the highlighted fields."
- cancel "Cancel"
- confirm "Create draft"
### Error
**Trigger:** 500 or network failure.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- Alert: "Couldn't create the article. Your text is still here. Try again in a moment."
- cancel "Cancel"
- confirm "Create draft"
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | "New article" from Home, Articles or the empty state. | |
| 2 | Validation | On submit or blur. Nothing is sent. | "Use at least 3 characters." |
| 3 | Submitting | Request in flight. Fields are disabled and the dialog can't be dismissed. | "Creating…" / "Choosing tiles for a small bathroom" |
| 4 | Title rejected (400) | A server 400 that got past the browser checks. The raw error isn't shown. | "Something in the form isn't valid. Check the highlighted fields." / "Choosing tiles for a small bathroom" / "Check this title and try again." |
| 5 | Error | 500 or network failure. | "Couldn't create the article. Your text is still here. Try again in a moment." / "Choosing tiles for a small bathroom" |
Loaded 100 of 1727 files, more files were not shown because too many files have changed in this diff. Show more