Compare 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
882 changed files with 69235 additions and 2939 deletions

No files matched your search

+4
View File
@@ -29,3 +29,7 @@ CLAUDE.md
AGENTS.md AGENTS.md
codex-workflow.md codex-workflow.md
CODING_STANDARDS.md CODING_STANDARDS.md
# Generated LLM docs
internal/infrastructure/llmdocs/generated/*
!internal/infrastructure/llmdocs/generated/.gitkeep
+5
View File
@@ -4,6 +4,10 @@ COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY web/ ./ COPY web/ ./
RUN bun run build-only 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.3-alpine AS server FROM golang:1.27.3-alpine AS server
ARG VERSION=dev ARG VERSION=dev
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist 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 RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
FROM alpine:3.22 FROM alpine:3.22
+18 -2
View File
@@ -3,6 +3,7 @@ version: '3'
vars: vars:
BINARY: renewcms BINARY: renewcms
DIST_DIR: internal/infrastructure/assets/dist DIST_DIR: internal/infrastructure/assets/dist
LLMS_DIR: internal/infrastructure/llmdocs/generated
VERSION: VERSION:
sh: git describe --tags --always --dirty sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}} LDFLAGS: -s -w -X main.version={{.VERSION}}
@@ -32,6 +33,7 @@ tasks:
dev:api: dev:api:
desc: Run the API with hot reload on :8080 (SPA not embedded) desc: Run the API with hot reload on :8080 (SPA not embedded)
deps: [build:llms]
cmds: cmds:
- air - air
@@ -231,22 +233,36 @@ tasks:
desc: Type-check and build the SPA into {{.DIST_DIR}} desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web dir: web
deps: [web:install] 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'] generates: ['../{{.DIST_DIR}}/index.html']
cmds: cmds:
- bun run build - 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: build:
desc: Production binary for this machine → bin/{{.BINARY}} desc: Production binary for this machine → bin/{{.BINARY}}
deps: [build:web] deps: [build:web, build:llms]
cmds: cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server - 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: release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY) desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds: cmds:
- task: release:check - task: release:check
- task: build:web - task: build:web
- task: build:llms
- for: { var: RELEASE_TARGETS } - for: { var: RELEASE_TARGETS }
task: build:target task: build:target
vars: vars:
+56 -10
View File
@@ -12,6 +12,7 @@ import (
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role"
domainSession "RenewCMS/internal/domain/session" domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site" domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction" domainTransaction "RenewCMS/internal/domain/transaction"
@@ -62,6 +63,9 @@ type UseCases struct {
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase
CheckPermissionUseCase *useCases.CheckPermissionUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase UpdateSiteUseCase *useCases.UpdateSiteUseCase
@@ -72,6 +76,12 @@ type UseCases struct {
GetContentTypeUseCase *useCases.GetContentTypeUseCase GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListRolesUseCase *useCases.ListRolesUseCase
GetRoleUseCase *useCases.GetRoleUseCase
CreateRoleUseCase *useCases.CreateRoleUseCase
UpdateRoleUseCase *useCases.UpdateRoleUseCase
DeleteRoleUseCase *useCases.DeleteRoleUseCase
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
@@ -88,11 +98,20 @@ type UseCases struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
ListUsersUseCase *useCases.ListUsersUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
RemoveUserUseCase *useCases.RemoveUserUseCase
ExportUserDataUseCase *useCases.ExportUserDataUseCase
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
@@ -161,6 +180,8 @@ type Repositories struct {
EntryRepo domainEntry.Repository EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
UserEraser domainUser.Eraser
RoleRepo domainRole.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository VariantRepo domainImage.VariantRepository
@@ -194,6 +215,8 @@ const (
updateHelperFileName = "update-helper.json" updateHelperFileName = "update-helper.json"
updatesDirName = "updates" updatesDirName = "updates"
backupsDirName = "backups" backupsDirName = "backups"
maxConcurrentDataExports = 2
) )
func imageStorageConfig(settings config.Settings) storage.Config { func imageStorageConfig(settings config.Settings) storage.Config {
@@ -305,6 +328,8 @@ func getRepositories(connection *database.Connection, settings config.Settings)
EntryRepo: persistence.NewEntryRepository(db), EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
UserEraser: persistence.NewAccountEraser(db),
RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db), ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db), Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db), VariantRepo: persistence.NewVariantRepository(db),
@@ -335,9 +360,12 @@ func getRepositories(connection *database.Connection, settings config.Settings)
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases { func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
mailDispatcher := mailer.NewAsyncDispatcher()
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
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()) 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, repos.SiteRepo, repos.AuthorRepo) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
@@ -351,7 +379,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))), Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
@@ -366,16 +394,25 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder), UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder), DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo), 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), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder), SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder), DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now), CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo), ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo), GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now), UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo), 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(), ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(), GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo), LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
@@ -385,20 +422,29 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now), UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo), DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), 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), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, 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), 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), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), 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), 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), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
@@ -406,7 +452,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
MergeMediaUseCase: mergeMedia, MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), GetMediaUsageUseCase: mediaUsage,
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
@@ -419,7 +465,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), 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), 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, repos.SiteRepo, repos.AuthorRepo), 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), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
+12 -6
View File
@@ -1,11 +1,12 @@
package activity package activity
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
case errors.Is(err, audit.ErrInvalidFilter): case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed): case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden) access.WriteMissingPermission(w, role.KeyActivityView)
case errors.Is(err, user.ErrUserNotFound): case access.WriteIfMissingPermission(w, err):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default: default:
http.Error(w, "Internal server error", http.StatusInternalServerError) 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 := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size 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 { if err != nil {
writeActivityError(w, err) writeActivityError(w, err)
return return
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeActivityError(w, err) writeActivityError(w, err)
return return
+7 -2
View File
@@ -1,6 +1,7 @@
package apikey package apikey
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return return
} }
entries, err := h.ListUseCase.ListAPIKeys(userId) entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil { if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return 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 { switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound): case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound): case errors.Is(err, user.ErrUserNotFound):
+38 -53
View File
@@ -1,8 +1,8 @@
package article package article
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
@@ -31,7 +31,6 @@ type Handler struct {
ListUseCase *useCases.ListArticlesUseCase ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
type PostArticle struct { type PostArticle struct {
@@ -39,14 +38,13 @@ type PostArticle struct {
Body string `json:"body"` 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{ return &Handler{
CreateUseCase: create, CreateUseCase: create,
GetUseCase: get, GetUseCase: get,
ListUseCase: list, ListUseCase: list,
DeleteUseCase: delete, DeleteUseCase: delete,
UpdateUseCase: update, UpdateUseCase: update,
GetUserUseCase: getUser,
} }
} }
@@ -57,6 +55,7 @@ type baseArticleResponse struct {
BodyFormat domain.BodyFormat `json:"body_format"` BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"` SiteID uint32 `json:"site_id"`
CreatedBy *uint32 `json:"created_by,omitempty"`
Authors []author.Byline `json:"authors"` Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -86,7 +85,15 @@ type listItemResponse struct {
SEOSuggestions int `json:"seo_suggestions"` SEOSuggestions int `json:"seo_suggestions"`
} }
func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) { 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) body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON { if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body) encoded, err := json.Marshal(article.Body)
@@ -106,14 +113,15 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArtic
BodyFormat: format, BodyFormat: format,
IsOnline: article.IsOnline, IsOnline: article.IsOnline,
SiteID: article.SiteID, SiteID: article.SiteID,
CreatedBy: creatorFor(article, viewer),
Authors: authors, Authors: authors,
CreatedAt: article.CreatedAt, CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
}, nil }, nil
} }
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) { func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
base, err := toBaseResponse(article, format) base, err := toBaseResponse(article, format, viewer)
if err != nil { if err != nil {
return articleResponse{}, err return articleResponse{}, err
} }
@@ -138,16 +146,16 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
}, nil }, nil
} }
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat) (listItemResponse, error) { func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) {
base, err := toBaseResponse(article, format) base, err := toBaseResponse(article, format, viewer)
if err != nil { if err != nil {
return listItemResponse{}, err return listItemResponse{}, err
} }
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil 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) ([]byte, error) { func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
response, err := toArticleResponse(article, format) response, err := toArticleResponse(article, format, viewer)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -180,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
var article domain.Article article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
if err != nil { if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
@@ -195,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := encodeArticle(article, format) articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -223,12 +226,11 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
creatorId, _ := claims.UserId(r)
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{ createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format, Format: format,
CreatedBy: creatorId, Caller: access.CallerOf(r),
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) { if errors.Is(err, domain.ErrInvalidTitle) {
@@ -241,7 +243,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format) writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
} }
func writeArticleErrorResponse(w http.ResponseWriter, err error) { func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -251,6 +253,9 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
} }
func writeKnownArticleError(w http.ResponseWriter, err error) bool { func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if access.WriteIfMissingPermission(w, err) {
return true
}
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true return true
@@ -303,8 +308,8 @@ func writeValidationError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) { func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format) articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -330,13 +335,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return 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 { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
} }
type optionalImageID struct { type optionalImageID struct {
@@ -457,7 +462,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{ updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Body: request.Body, Body: request.Body,
SiteID: request.SiteID, SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs, AuthorIDs: request.AuthorIDs,
@@ -480,7 +485,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
} }
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -491,29 +496,6 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false) h.setIsOnline(w, r, false)
} }
func (h *Handler) callerIsVerified(r *http.Request) bool {
return isVerifiedCaller(r, h.GetUserUseCase)
}
func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
}
currentUser, err := getUser.GetUser(userId)
if err != nil {
return false
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != currentUser.TokenVersion {
return false
}
return currentUser.IsVerified
}
const ( const (
defaultListPage = 1 defaultListPage = 1
defaultListSize = 25 defaultListSize = 25
@@ -597,7 +579,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{ page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format, Format: format,
Verified: h.callerIsVerified(r), Caller: access.CallerOf(r),
Page: query.Page, Page: query.Page,
Size: query.Size, Size: query.Size,
Q: query.Q, Q: query.Q,
@@ -613,7 +595,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
responses := make([]listItemResponse, 0, len(page.Items)) responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items { for _, article := range page.Items {
response, err := toListItemResponse(article, page.SEO[article.ID], format) response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -639,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return 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) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return return
} }
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError) http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return return
} }
+5 -5
View File
@@ -1,6 +1,7 @@
package article package article
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -14,11 +15,10 @@ import (
type SEOHandler struct { type SEOHandler struct {
ResolveUseCase *useCases.ResolveArticleSEOUseCase ResolveUseCase *useCases.ResolveArticleSEOUseCase
CheckUseCase *useCases.CheckArticleSEOUseCase CheckUseCase *useCases.CheckArticleSEOUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler { func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler {
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser} return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check}
} }
type CheckArticleSEOBody struct { type CheckArticleSEOBody struct {
@@ -68,7 +68,7 @@ func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
return return
} }
resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase)) resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r))
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
@@ -93,7 +93,7 @@ func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
return return
} }
checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{ checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{
Body: request.Body, Body: request.Body,
Slug: request.Slug, Slug: request.Slug,
Excerpt: request.Excerpt, Excerpt: request.Excerpt,
+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)
}
}
+7 -11
View File
@@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
} }
func removeJwtCookie(w http.ResponseWriter, isProduction bool) { func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
Name: authtoken.CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
} }
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error { func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
@@ -139,7 +130,12 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
} }
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *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.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body) _, _ = w.Write(body)
} }
+35 -2
View File
@@ -3,8 +3,10 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
"RenewCMS/api/pkg/publicuser" "RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
@@ -15,12 +17,14 @@ import (
type MeHandler struct { type MeHandler struct {
GetUseCase *useCases.GetUserUseCase GetUseCase *useCases.GetUserUseCase
GetAuthorUseCase *useCases.GetMyAuthorUseCase GetAuthorUseCase *useCases.GetMyAuthorUseCase
GetAccessUseCase *useCases.GetMyAccessUseCase
} }
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase) *MeHandler { func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler {
return &MeHandler{ return &MeHandler{
GetUseCase: getUser, GetUseCase: getUser,
GetAuthorUseCase: getAuthor, GetAuthorUseCase: getAuthor,
GetAccessUseCase: getAccess,
} }
} }
@@ -29,9 +33,25 @@ type meAuthor struct {
ArticleCount int `json:"article_count"` 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 { type meResponse struct {
publicuser.PublicUser publicuser.PublicUser
PendingEmail *string `json:"pending_email"`
Author *meAuthor `json:"author"` 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 { func toMeAuthor(found *author.Author) *meAuthor {
@@ -41,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor {
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount} 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) { func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r) userId, ok := claims.UserId(r)
if !ok { if !ok {
@@ -64,7 +91,13 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked)}) 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 { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+7 -19
View File
@@ -1,6 +1,7 @@
package author package author
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -33,11 +34,10 @@ type Handler struct {
CreateMyUseCase *useCases.CreateMyAuthorUseCase CreateMyUseCase *useCases.CreateMyAuthorUseCase
UpdateUseCase *useCases.UpdateAuthorUseCase UpdateUseCase *useCases.UpdateAuthorUseCase
DeleteUseCase *useCases.DeleteAuthorUseCase DeleteUseCase *useCases.DeleteAuthorUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler { 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, GetUserUseCase: getUser} return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
} }
type socialLinkRequest struct { type socialLinkRequest struct {
@@ -180,6 +180,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
func writeAuthorError(w http.ResponseWriter, err error) { func writeAuthorError(w http.ResponseWriter, err error) {
switch { switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAuthorNotFound): case errors.Is(err, domain.ErrAuthorNotFound):
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSlugTaken): case errors.Is(err, domain.ErrSlugTaken):
@@ -226,18 +227,6 @@ func writeValidationError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
func (h *Handler) currentUser(r *http.Request) (user.User, bool) {
userId, ok := claims.UserId(r)
if !ok {
return user.User{}, false
}
current, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
return user.User{}, false
}
return current, true
}
type listQuery struct { type listQuery struct {
Page int `validate:"gte=1"` Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"` Size int `validate:"oneof=5 25 50 100"`
@@ -277,8 +266,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return return
} }
current, _ := h.currentUser(r) page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
@@ -351,12 +339,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
current, ok := h.currentUser(r) callerID, ok := claims.UserId(r)
if !ok { if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
} }
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch()) updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch())
if err != nil { if err != nil {
writeAuthorError(w, err) writeAuthorError(w, err)
return return
@@ -92,6 +92,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) {
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict) http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries): case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict) 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): case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict) http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName): case errors.Is(err, domain.ErrInvalidName):
+53 -22
View File
@@ -1,8 +1,8 @@
package entry package entry
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/response" "RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
@@ -43,6 +43,49 @@ type entryResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"` 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 { type mediaBody struct {
@@ -81,6 +124,7 @@ func toResponse(typed useCases.TypedEntry) entryResponse {
CreatedAt: typed.CreatedAt, CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt, UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media), Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
} }
} }
@@ -134,6 +178,9 @@ func parseEntryId(r *http.Request) (uint32, error) {
} }
func writeEntryError(w http.ResponseWriter, err error) { func writeEntryError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var fieldError domain.FieldError var fieldError domain.FieldError
switch { switch {
case errors.Is(err, contenttype.ErrContentTypeNotFound): case errors.Is(err, contenttype.ErrContentTypeNotFound):
@@ -167,21 +214,13 @@ func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
return body, true return body, true
} }
func currentUserId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
}
return userId, ok
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query()) query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil { if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return return
} }
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort}) 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 { if err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
@@ -199,7 +238,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return return
} }
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id) found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id, access.CallerOf(r))
if err != nil { if err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
@@ -208,15 +247,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userId, ok := currentUserId(w, r)
if !ok {
return
}
body, ok := readBody(w, r) body, ok := readBody(w, r)
if !ok { if !ok {
return return
} }
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, UserID: userId}) created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, Caller: access.CallerOf(r)})
if err != nil { if err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
@@ -230,15 +265,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return return
} }
userId, ok := currentUserId(w, r)
if !ok {
return
}
body, ok := readBody(w, r) body, ok := readBody(w, r)
if !ok { if !ok {
return return
} }
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, UserID: userId}) updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, Caller: access.CallerOf(r)})
if err != nil { if err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
@@ -252,7 +283,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return return
} }
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id); err != nil { if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id, access.CallerOf(r)); err != nil {
writeEntryError(w, err) writeEntryError(w, err)
return return
} }
+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))
}
@@ -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})
}
+7 -30
View File
@@ -1,11 +1,12 @@
package update package update
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update" domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -21,29 +22,23 @@ type Handler struct {
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
type restartRequest struct { type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"` 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{ return &Handler{
GetUpdateStatusUseCase: getStatus, GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate, CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload, StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart, RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
} }
} }
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { 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 return *request.Kind, true
} }
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) { func canApply(r *http.Request) bool {
userId, ok := claims.UserId(r) return access.Grants(r).Holds(role.KeySystemApplyUpdates)
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 writeDownloadError(w http.ResponseWriter, err error) { 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 package user
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -25,6 +27,7 @@ type InviteHandler struct {
type CreateInviteRequest struct { type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"` Email string `json:"email" validate:"required,inviteemail"`
RoleID uint32 `json:"role_id"`
} }
type inviteResponse struct { type inviteResponse struct {
@@ -32,6 +35,7 @@ type inviteResponse struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Status string `json:"status"` Status string `json:"status"`
Role payload.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
@@ -40,6 +44,7 @@ type inviteListItem struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Role *payload.RoleRef `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -50,7 +55,8 @@ type userListItem struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` Role *payload.RoleRef `json:"role"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` 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{ created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Caller: access.CallerOf(r),
Email: request.Email, Email: request.Email,
InvitedBy: userId, InvitedBy: userId,
RoleID: request.RoleID,
}) })
if err != nil { if err != nil {
writeCreateInviteError(w, err) writeCreateInviteError(w, err)
@@ -94,12 +102,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusCreated, created) 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{ body, err := json.Marshal(inviteResponse{
ID: entry.ID, ID: entry.ID,
Kind: useCases.EntryKindInvite, Kind: useCases.EntryKindInvite,
Email: entry.Email, Email: entry.Email,
Status: string(entry.StatusAt(time.Now())), Status: string(entry.StatusAt(time.Now())),
Role: payload.RoleRefOf(entry.Role),
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt, ExpiresAt: entry.ExpiresAt,
}) })
@@ -144,7 +153,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
@@ -153,6 +162,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
} }
func writeManageInviteError(w http.ResponseWriter, err error) { func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrInviteNotFound): case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound) 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) { func writeCreateInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrEmailHasAccount): case errors.Is(err, invite.ErrEmailHasAccount):
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict) http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite): case errors.Is(err, invite.ErrPendingInvite):
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict) http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent): case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError) 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) { func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites() entries, err := 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)
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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)
} }
+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
}
+3 -1
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -10,6 +11,7 @@ import (
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler { func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
origin := audit.Origin{ origin := audit.Origin{
Actor: auditActor(getUser, r), Actor: auditActor(getUser, r),
Source: auditSource(r), Source: auditSource(r),
@@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if !ok { if !ok {
return audit.AnonymousActor("") return audit.AnonymousActor("")
} }
current, err := getUser.GetUser(userId) current, err := access.LoadUser(getUser, r, userId)
if err != nil { if err != nil {
return audit.AnonymousActor("") return audit.AnonymousActor("")
} }
+4 -37
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
return user.User{}, false return user.User{}, false
} }
currentUser, err := getUser.GetUser(userId) currentUser, err := access.LoadUser(getUser, r, userId)
if err != nil { if err != nil {
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) 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 { func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r) currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok { if !ok {
return return
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r) currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok { if !ok {
return 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)
})
}
}
+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)))
})
}
}
+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
}
+39 -2
View File
@@ -19,12 +19,14 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed." const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account." const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address." 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 InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found" const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted." const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work." const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired." const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another." const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer." const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid." const MediaQueryErrorMessage = "The media query is invalid."
@@ -39,8 +41,9 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json." const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found" const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future." 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 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 PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running." const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365." const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
@@ -69,6 +72,7 @@ const InvalidLocaleErrorMessage = "invalid_locale"
const SiteImageNotFoundErrorMessage = "image_not_found" const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default" const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign" const InvalidReassignErrorMessage = "invalid_reassign"
const ReassignTargetNotFoundErrorMessage = "target_role_not_found"
const InvalidSocialTypeErrorMessage = "invalid_social_type" const InvalidSocialTypeErrorMessage = "invalid_social_type"
const AuthorNotFoundErrorMessage = "author_not_found" const AuthorNotFoundErrorMessage = "author_not_found"
@@ -92,6 +96,7 @@ const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found" const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken" const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries" const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key" const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon" const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found" const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
@@ -99,3 +104,35 @@ const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible" const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found" const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry" 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"
+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: "/",
}
}
+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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
} }
func New(u user.User) PublicUser { func New(u user.User) PublicUser {
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: u.Role,
} }
} }
+224 -83
View File
@@ -11,7 +11,11 @@ import (
"RenewCMS/api/handlers/entry" "RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
"RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site" "RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
@@ -19,7 +23,9 @@ import (
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken" "RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/llmdocs"
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
@@ -46,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
const ( const (
apiKeyMaxRequests = 120 apiKeyMaxRequests = 120
apiKeyWindow = time.Minute 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 { func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
@@ -55,16 +69,37 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware 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 { func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) 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, container.GetUserUseCase) seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Get("/{id}/seo", seoHandler.Resolve) r.Get("/{id}/seo", seoHandler.Resolve)
@@ -73,12 +108,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle) r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle) r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle) r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/seo/check", seoHandler.Check) r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check)
}) })
return r return r
} }
@@ -90,8 +125,9 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase) meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) 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) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
@@ -122,6 +158,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
}) })
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.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.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
@@ -132,13 +184,18 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
return r 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() r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase) userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase) lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) 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.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
@@ -146,16 +203,60 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe) r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers) r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup) r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) { 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.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite) r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite) r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) 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 return r
} }
@@ -170,6 +271,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsView))
r.Get("/", settingsHandler.GetSettings) r.Get("/", settingsHandler.GetSettings)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -180,7 +282,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemEditRetention))
r.Patch("/", settingsHandler.UpdateSettings) r.Patch("/", settingsHandler.UpdateSettings)
}) })
return r return r
@@ -193,6 +295,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase) sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -210,19 +314,19 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.Post("/", siteHandler.Create) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.Patch("/{id}", siteHandler.Update) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.Patch("/{id}/default", siteHandler.SetDefault) r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.Delete("/{id}", siteHandler.Delete) r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
}) })
return r return r
} }
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler { func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase) roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -230,6 +334,40 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(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.Get("/", authorHandler.List)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -241,12 +379,9 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine) r.Post("/me", authorHandler.CreateMine)
r.Patch("/{id}", authorHandler.Update) r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update)
r.Group(func(r chi.Router) { r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete)
r.Post("/", authorHandler.Create)
r.Delete("/{id}", authorHandler.Delete)
})
}) })
return r return r
} }
@@ -265,19 +400,16 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", contentTypeHandler.List) r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List)
r.Get("/{key}", contentTypeHandler.Get) r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get)
r.Group(func(r chi.Router) { r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update)
r.Post("/", contentTypeHandler.Create) r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete)
r.Put("/{key}", contentTypeHandler.Update) r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List)
r.Delete("/{key}", contentTypeHandler.Delete) r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create)
}) r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get)
r.Get("/{key}/entry", entryHandler.List) r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update)
r.Post("/{key}/entry", entryHandler.Create) r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete)
r.Get("/{key}/entry/{id}", entryHandler.Get)
r.Put("/{key}/entry/{id}", entryHandler.Update)
r.Delete("/{key}/entry/{id}", entryHandler.Delete)
return r return r
} }
@@ -293,7 +425,7 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.Use(container.require(role.KeySchemaView))
r.Get("/", builtinTypeHandler.List) r.Get("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get) r.Get("/{key}", builtinTypeHandler.Get)
return r return r
@@ -311,12 +443,9 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List) r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
r.Group(func(r chi.Router) { r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
return r return r
} }
@@ -330,7 +459,7 @@ const (
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler { func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() 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(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
@@ -338,19 +467,19 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(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.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check) r.Post("/update/check", updateHandler.Check)
}) })
r.Group(func(r chi.Router) { 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.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download) r.Post("/update/download", updateHandler.Download)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemApplyUpdates))
r.Post("/update/restart", updateHandler.Restart) r.Post("/update/restart", updateHandler.Restart)
}) })
return r return r
@@ -367,9 +496,23 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create) r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List) r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke) 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 return r
} }
@@ -385,28 +528,24 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) { r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-settings", mediaHandler.GetPurgeSettings) r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings) r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey) r.Use(middleware.RejectAPIKey)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
r.Get("/{id}/variants", mediaHandler.ListVariants) r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants) r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant) r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
}) })
r.Get("/", mediaHandler.List) r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload) r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge) r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename) r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete) r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}", mediaHandler.Get) r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
r.Get("/{id}/usage", mediaHandler.Usage) r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
return r return r
} }
@@ -421,15 +560,14 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
r.Get("/", variantHandler.List) r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
r.Post("/", variantHandler.Create) r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
r.Get("/cache", variantHandler.CacheStats) r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
r.Post("/cache/purge", variantHandler.PurgeCache) r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
r.Put("/{name}", variantHandler.Update) r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
r.Patch("/{name}", variantHandler.SetEnabled) r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
r.Delete("/{name}", variantHandler.Delete) r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
r.Post("/{name}/purge", variantHandler.Purge)
return r return r
} }
@@ -445,9 +583,10 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) 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("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(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("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies)) r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies)) r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
@@ -455,6 +594,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies)) r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies)) r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(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("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
@@ -493,6 +633,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL)) apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
apiRouter.Use(chimiddleware.GetHead) apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth) apiRouter.Get("/healthz", GetHealth)
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
apiRouter.Mount("/v1", backend) apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase)) apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend) apiRouter.Mount("/", frontend)
+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)
}
}
+264 -14
View File
@@ -11,8 +11,8 @@ for example `https://cms.example/v1/article/`.
## Authentication ## Authentication
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` - 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 only; the list and the resolved SEO and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
object also answer drafts to a verified session or API key. (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: - 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 `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. creator, with that user's role, and shows in full once.
@@ -33,7 +33,7 @@ for example `https://cms.example/v1/article/`.
| --- | --- | | --- | --- |
| 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} | | 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) | | 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/, 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 (with the linked `author`) | | 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 | | Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap | | Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ | | Authors | GET /v1/author/ |
@@ -45,12 +45,12 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
| Route | Who | Purpose | | 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/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. | | `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`, Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` `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 (`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 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`). are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
@@ -59,7 +59,7 @@ are UTC. The list answers `{ items, total, page, size }`, newest first (`created
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`, 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_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 `{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup 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. removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins. Whichever limit is reached first wins.
@@ -74,7 +74,7 @@ never changes the response or the change itself.
| --- | --- | | --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `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` | | `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`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `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` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.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` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
@@ -138,8 +138,8 @@ dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale` 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 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 is `null` or the id of an image. Read access needs a verified session or an API key; every write is
session only and owner only (403 "Only the owner can do this.", or "This action needs a real 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. session." with a key. The role is checked before the body.
| Route | Purpose | | Route | Purpose |
| --- | --- | | --- | --- |
@@ -173,7 +173,7 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
| Route | Who | Purpose | | Route | Who | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads drafts | 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`. | | `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. | | `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/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`. |
@@ -255,8 +255,8 @@ article is allowed and never blocks publishing.
| `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. | | `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. | | `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 "Only owners and admins can do this." for `POST /` and The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author `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. 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`, 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 409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
@@ -284,8 +284,7 @@ repoint the avatars of the merged copies and report it as `authors_updated`.
## Image variants ## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API 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; any other role gets 403 "Only owners and admins can do 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).
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose | | Route | Purpose |
| --- | --- | | --- | --- |
@@ -354,10 +353,261 @@ disabled variant on `POST`), 413 "The source image is too large to generate this
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for 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". 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 ## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The 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. 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 ## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL. - 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. - Browsers can call the API only from origins in the server's CORS setting.
+5 -2
View File
@@ -13,7 +13,7 @@ Every route that returns an article accepts the optional `format` query paramete
- `PATCH /v1/article/{id}/publish` - `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish` - `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 ## Pagination, search and counts
@@ -72,6 +72,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"body_format": "markdown", "body_format": "markdown",
"is_online": true, "is_online": true,
"site_id": 1, "site_id": 1,
"created_by": 1,
"authors": [], "authors": [],
"slug": "backyard-renovation-update", "slug": "backyard-renovation-update",
"excerpt": "", "excerpt": "",
@@ -90,6 +91,8 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
} }
``` ```
`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. 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 ## SEO fields
@@ -121,7 +124,7 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr
## Resolved SEO object ## 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 and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys: `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`). `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`).
+13 -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. | | 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`. | | 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`. | | 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`. | | 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. | | 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. | | 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 | | 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/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/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. | | `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. |
@@ -111,6 +112,16 @@ The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepos
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. 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 ## Frontend ↔ backend
``` ```
+22 -6
View File
@@ -3,7 +3,7 @@
Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0. Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
## Boards ## 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`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`. - `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 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). - `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. - Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
@@ -21,7 +21,7 @@ Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: f
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit. - Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
## Pass 4: custom content types ## Pass 4: custom content types
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 97 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 3, 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. 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. - 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. - 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). - Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
@@ -48,7 +48,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| B9 Media and picker | Badge added; Media pagination drawn (9 states); filter, sort and size not folded in | | 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 | | B10 "proposed" labels | Only article pagination and JSON export |
## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 409) ## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 413)
| Group | States | | Group | States |
|---|---| |---|---|
| Boot | 1 | | Boot | 1 |
@@ -57,7 +57,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| Articles: pagination | 11 | | Articles: pagination | 11 |
| New article | 5 | | New article | 5 |
| Editor | 41 | | Editor | 41 |
| Media | 52 | | Media | 56 |
| Media picker | 13 | | Media picker | 13 |
| Integration | 8 | | Integration | 8 |
| Activity | 9 | | Activity | 9 |
@@ -83,7 +83,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| Cache panel | 10 | | Cache panel | 10 |
| Editor picker | 5 | | Editor picker | 5 |
Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 409. 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 471 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. 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.
## Fixes applied (pass 2) ## 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. 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.
@@ -107,7 +107,7 @@ Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 40
## Defaults chosen ## Defaults chosen
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet. - 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%). - 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: API shape (array body, total in `X-Total-Count`) is a design assumption to confirm with the specifier. - 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 Remove action in the link popover has no confirmation; undo restores the link.
- The link button is disabled when an image is selected. - The link button is disabled when an image is selected.
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS. - The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
@@ -138,3 +138,19 @@ Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 40
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix. - 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). - 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. - 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`.
+29
View File
@@ -91,3 +91,32 @@ Ordered by implementation dependency: tokens and shell first, then shared compon
| 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 | | 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 | | 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 | | 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.
+38 -1
View File
@@ -27,7 +27,7 @@ One component for every code or copyable value. Props: `tabs` (`{ k, label, code
## Editor: link ## 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. - **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." - **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). - **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. - **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. - **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.
@@ -131,3 +131,40 @@ Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field
| 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 | | 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 | | 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 | | 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. 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. 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`.
+29 -1
View File
@@ -22,7 +22,7 @@
| /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 | 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/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} | | /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
| /settings/permissions | Permissions grid (owner or admin). Later | none | not built | | /settings/permissions | Retired. 301 to /settings/roles (see Roles and permissions below) | none | not built |
## Pagination in the URL (Articles, Media) ## 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. `/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.
@@ -53,3 +53,31 @@ No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?
## Custom content types in the URL ## 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. `/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 |
+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.
+30
View File
@@ -93,3 +93,33 @@ Route: `/activity`. API: GET /v1/activity (server scoped to the user).
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | | | 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
| 2 | Empty | The user has not done anything yet. | | | 2 | Empty | The user has not done anything yet. | |
| 3 | Details | Same slide-over, own event. | | | 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 |
|---|---|---|
| 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 |
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.
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".
### 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) |
Event names match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
+97 -4
View File
@@ -12,10 +12,10 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- 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." - 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", a CodeSnippet with one tab ("Base URL") and the 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}`. - 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." 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". - 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 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." - 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."" - 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.`). - 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." - 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."
@@ -23,7 +23,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- 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). - 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 in one CodeSnippet with 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. - 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. - Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## States ## States
@@ -34,7 +34,7 @@ Desktop 1440×900, mobile 390×844, light and dark.
Plain text, one sentence. Used in the table and in the per-endpoint error captions. 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." 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". - 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." - 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." Note under the 403 row: "Site SEO defaults are owner only. 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. - 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) - 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`. - 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error" - 500: "Internal server error"
@@ -114,3 +114,96 @@ New group "Search and sharing" after Articles, in the sidebar index and the mobi
- 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/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). - 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. 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.
+7 -1
View File
@@ -10,7 +10,7 @@ Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media
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. 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.
### Media (group "Media", 52 states) ### Media (group "Media", 56 states)
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. 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.
@@ -116,3 +116,9 @@ A secondary "Purge duplicates" button (icon merge) in the toolbar next to the so
## Media field (pass 5) ## 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. Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.8. Board groups: 11 Media library usage.
## 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.
+4
View File
@@ -64,3 +64,7 @@ The "Author profile" card sits between Account and Password in every Profile fra
| 3 | Created | 201: the card shows the author. The author is named from the display name and linked to the user. | Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author. | | 3 | Created | 201: the card shows the author. The author is named from the display name and linked to the user. | Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author. |
| 4 | Created, slug adjusted | The slug was taken, so the server added a number. No error is shown. | Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author. | | 4 | Created, slug adjusted | The slug was taken, so the server added a number. No error is shown. | Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author. |
| 5 | Edit own author | Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen. | Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes | | 5 | Edit own author | Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen. | Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes |
| 6 | No author profile, no display name | The user has no linked author and has not set a display name. | Uses your username, marta. You can change it afterwards. |
| 7 | Edit own author: owner or admin | Edit author on the card, by an owner or admin. The form opens with the linked user and Unlink is allowed. | Edit Marta Alves · Linked user · marta@renew.example · Unlink · Save changes |
Order on the page: Display name, Account, Your role (role card), Author profile, Password.
+886
View File
@@ -0,0 +1,886 @@
# Roles and permissions
Routes: `/settings/roles`, `/settings/roles/new`, `/settings/roles/:id`. `/settings/permissions` is retired and redirects (301) to `/settings/roles`. Board: `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`). Every state is "Available now". Copy below is exact; `·` separates strings.
## 1 Roles list
Route: /settings/roles · API: GET /v1/role · Auth: Roles: view
Settings > Roles. Visible to anyone who can view roles. Owner carries a Locked badge. The row menu has Edit, Duplicate and Delete. Mobile: one card per role.
### Default (Available now)
Trigger: Owner opens /settings/roles.
Copy: Roles · Who can do what on this site. Each user has one role. · New role · Role · Description · Members · Type · Owner · Locked · Admin · Writer · Contributor · Content lead · Built-in · Custom
### Loading (Available now)
Trigger: First load or retry. Skeleton rows after 150 ms. New role disabled.
Copy: (skeleton) · New role (disabled)
### Only built-in roles (Available now)
Trigger: GET /v1/role returns the three built-ins and no custom role.
Copy: No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role
### Row menu: custom role (Available now)
Trigger: Open the row menu on Contributor.
Copy: Edit · Duplicate · Delete
### Row menu: built-in role (Available now)
Trigger: Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.
Copy: Edit · Duplicate · Delete (disabled) · Built-in roles can't be deleted. Duplicate it to make a custom role.
### No permission to edit (Available now)
Trigger: User has Roles: view but not Roles: manage. New role is disabled with a tooltip.
Copy: ReadOnlyBanner: You can see roles, but only people who manage roles can create or change them.
### Error (Available now)
Trigger: 500 or network failure.
Copy: Couldn't load roles · GET /v1/role failed. Check your connection and try again. · Try again
## 2 Role editor
Route: /settings/roles/new, /settings/roles/:id · API: POST /v1/role, GET/PUT /v1/role/{id} · Auth: Roles: manage (view for read-only)
Name and description, then Content permissions (the grid moved here from /settings/permissions) and General permissions. Desktop: the plain-language panel sits on the right. Mobile: the grid is one card per type, and a sticky bar holds "What this role can do" and Save. The old route /settings/permissions redirects here (301 to /settings/roles).
### New role (empty) (Available now)
Trigger: Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.
Copy: New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all
### Editing a custom role (Available now)
Trigger: Open Contributor. Two groups open. Case studies shows Custom with a Reset link.
Copy: Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all · Custom · Reset · 4 members · Custom
### Built-in role, as owner (Available now)
Trigger: The owner opens Writer. A warning shows how many members are affected.
Copy: This role has 5 members · Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default. · 2 unsaved changes
### Built-in role, as admin (read-only) (Available now)
Trigger: An admin opens Writer. All controls are disabled and the fields are locked.
Copy: Only the owner can edit built-in roles · You can read what Writer allows. To change it, duplicate it into a custom role. · Duplicate role · Built-in
### Owner role (locked) (Available now)
Trigger: Anyone with Roles: view opens Owner.
Copy: The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked
### General permissions (Available now)
Trigger: Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.
Copy: Activity · See activity · Read the log of who did what. · Export activity · Download the log as a CSV file. · Users and invites · View users · Invite users · Change a user's role · Remove users · Settings · View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site · Owner only · 2 of 2 enabled
### Owner-only items (Available now)
Trigger: Roles and System groups, as an admin. Owner-only items are disabled with a lock and an Owner only tag. They stay off even for Admin.
Copy: Roles · View roles · See roles and what they allow. · Manage roles · Create, edit, duplicate and delete roles. · Edit roles with owner-only power · Change a role that carries owner-only abilities. · System · Apply software updates · Install a new version of RenewCMS. · Edit activity retention · How long the activity log is kept. · Transfer ownership · Make another member the owner. · Owner only
### Unsaved changes (Available now)
Trigger: Three edits. Changed cells carry a square marker and the preview tags the changed line.
Copy: 3 unsaved changes · Discard · Save changes · Changed
### Saving (Available now)
Trigger: PUT in flight. Fields dim; Save shows the loader.
Copy: Saving… · Discard (disabled)
### Saved (Available now)
Trigger: 200. The bar closes and a toast confirms.
Copy: Role saved · Contributor is updated for 4 members.
### Name required (Available now)
Trigger: Save with an empty name.
Copy: Give the role a name. · 1 problem to fix
### Name already taken (Available now)
Trigger: 409 role_name_taken on POST or PUT.
Copy: A role named "Writer" already exists. Choose a different name. · 1 problem to fix
### Escalation blocked (Available now)
Trigger: The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.
Copy: You can only grant what your role has. Your role can publish only entries you created, so All isn't available. · All (locked)
### Conflict (Available now)
Trigger: 409 role_conflict on PUT (version mismatch).
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
### Permission lowered while open (403) (Available now)
Trigger: 403 missing_permission on PUT or on refresh. The page becomes read-only.
Copy: Your access changed · An owner changed your role while this page was open. You can still see roles, but you can't change them. Your edits weren't saved. · Reload page
## 3 Plain-language preview
Route: Panel inside the role editor; bottom sheet on mobile · API: None. Computed in the browser from the form · Auth: Roles: view
Title "What this role can do". Two parts: Content (one sentence per group of types that share the same permissions) and Everything else (one sentence per group). A last line lists areas with no access. Lines that change while editing carry a Changed tag. Sentences are built from fixed templates, never from keys.
### Live panel (Available now)
Trigger: Editing Contributor. Events: Update changed from Own to All. The first line is tagged.
Copy: What this role can do · Updates as you edit. · Content · All content types · Can read any entry, create entries and edit only their own. Can't delete and publish. · Everything else · Schema · Can see content types and fields. · Media · Can browse the media library and upload files. · Authors · Can see authors and edit authors, only their own. · No access to Activity, Users and invites, Settings, Roles. · Applies to 4 members, and to the API keys they created. · Changed
### Read-only summary (Available now)
Trigger: Opened from a built-in role in read-only mode. No "Updates as you edit".
Copy: What this role can do · A summary of what this role allows.
### Nothing granted (Available now)
Trigger: New role with every permission off.
Copy: Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.
### Mobile sheet (Available now)
Trigger: Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.
Copy: What this role can do · Updates as you edit. · Close
## 4 Delete role
Route: Dialog over /settings/roles · API: POST /v1/role/{id}/reassign, DELETE /v1/role/{id} · Auth: Roles: manage
A role with members is blocked until they move. Choosing a target role enables a single action that moves them and deletes the role. Roles with more power than the user are disabled in the picker with a reason.
### Blocked: has members (Available now)
Trigger: Delete Contributor, 4 members.
Copy: Delete Contributor? · 4 members have this role. Move them to another role first. Then Contributor can be deleted. · Move members to · Choose a role · They keep their API keys. Keys follow the new role. · Cancel · Move 4 members and delete role (disabled until a role is chosen)
### Role picker open (Available now)
Trigger: The picker shows each role's summary. Admin is disabled for an admin.
Copy: Writer · Reads everything and writes content. Can't delete or publish. · Content lead · Edits and publishes all content. · Admin · Your role can't assign a role with more power than yours.
### Confirm: no members (Available now)
Trigger: Delete Intern, 0 members.
Copy: Delete Intern? · No one has this role. This can't be undone. · Deleting a role doesn't affect content. · Cancel · Delete role
### Built-in role (Available now)
Trigger: Delete is disabled in the menu. If the dialog is reached by link it shows this state.
Copy: Delete Writer? · Built-in roles can't be deleted. · Writer is built in · Duplicate it to make a custom role you can change or delete. · Duplicate role · Close
## 5 Users: roles and ownership
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.
### List with roles (Available now)
Trigger: Owner opens /users.
Copy: Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending
### Row menu (Available now)
Trigger: Open the menu on Ines Duarte.
Copy: Change role · Send password reset · Remove user
### Owner row menu (Available now)
Trigger: Owner row: Change role and Remove are disabled; Transfer ownership appears.
Copy: Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can't be changed or removed. Transfer ownership first.
### Change role dialog (Available now)
Trigger: Each option shows the role's one-line summary. The current role is marked.
Copy: Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
### Change role: options blocked (Available now)
Trigger: An admin opens the dialog. Roles with more power are disabled with a reason.
Copy: Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can't grant that.
### Invite dialog (Available now)
Trigger: Role picker defaults to Writer and shows its summary.
Copy: Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite
### Transfer ownership: pick member (Available now)
Trigger: Owner menu > Transfer ownership. Only members can be chosen.
Copy: Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue
### Transfer ownership: confirm (Available now)
Trigger: Consequences listed. The action stays off until the email is retyped exactly.
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can't take ownership back unless they transfer it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
### Transfer ownership: done (Available now)
Trigger: The former owner is now Admin. The list shows the new owner.
Copy: Ownership transferred · Jonas Weber is now the owner. You are an Admin.
## 6 Role-aware shell
Route: Every page · API: GET /v1/auth/me returns role and permission keys · Auth: Any
Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.
### The rule (Available now)
Trigger: Reference card used across the app.
Copy: Hidden · The person can never use it. · Disabled, with a reason · The person sees the thing but can't act on it. · Read-only · The person may look but not change.
### Writer: Articles list (Available now)
Trigger: Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.
Copy: Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published
### Writer: row menu (Available now)
Trigger: Delete is disabled with a reason.
Copy: Edit · Duplicate · Delete (disabled) · Your role can't delete articles.
### Writer: entry editor (Available now)
Trigger: Publish is hidden. Status reads Draft with a hint.
Copy: Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.
### Own scope: entry list (Available now)
Trigger: Contributor can edit only entries they created. Others show a View only mark.
Copy: Events · You can create entries and edit your own. · Entries by others are view only · Your role lets you edit entries you created. · New entry · View only
### Own scope: row menu (Available now)
Trigger: On an entry by someone else, Edit and Delete are disabled.
Copy: Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.
### Admin: read-only settings (Available now)
Trigger: An admin without Edit general settings. Fields locked, Save off.
Copy: You can read these settings but not change them · Ask the owner to change them, or ask for a role that can edit settings. · Save changes (disabled)
### Admin: owner-only settings (Available now)
Trigger: Software and Retention show an Owner only tag. The action stays visible and disabled.
Copy: Owner only · Apply update (disabled) · Only the owner can apply updates. · Keep activity for · Admins can read the log. Only the owner can change how long it is kept.
## 7 No access: 403 copy
Route: Any page · API: 403 missing_permission, 403 role_locked · Auth: Any
A direct link to a page the person can't use shows the page-level state with a link to a page they can use. A blocked action shows a toast.
### Page: no access (Available now)
Trigger: Contributor opens /settings/roles by link.
Copy: You don't have access to this page · Your role, Contributor, doesn't include it. Ask an owner or admin if you need it. · Go to Articles · See what I can do
### Action: missing_permission (Available now)
Trigger: An action was refused with 403 missing_permission.
Copy: You don't have permission to do that · Your role doesn't include Delete entries. Ask an owner or admin.
### Action: role_locked (Available now)
Trigger: An attempt to edit or delete the Owner role.
Copy: This role is locked · The Owner role can't be changed or deleted. Transfer ownership from Users instead.
## 8 Activity: role events
Route: /activity · API: GET /v1/activity · Auth: Activity: own or all
New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.
### List with role events (Available now)
Trigger: Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted
### Detail: role diff (Available now)
Trigger: Open "Role Contributor edited".
Copy: Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.
### Failed role events (Available now)
Trigger: The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.
Copy: Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed
### Failed event: details (Available now)
Trigger: Open "Failed to edit the role Contributor". The reason comes from the API.
Copy: Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.
### Category filter (Available now)
Trigger: The Category select open. Roles is a new category; Users & invites is renamed Users and invites.
Copy: All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System
## 9 Profile: your role
Route: /profile · API: GET /v1/auth/me · Auth: Any
The person sees their own role and a read-only summary.
### Role card: custom role (Available now)
Trigger: Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.
Copy: Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.
### Role card: Owner (Available now)
Trigger: The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.
Copy: Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.
### See what I can do (Available now)
Trigger: Opens the summary. Dialog on desktop, sheet on mobile.
Copy: What you can do · A summary of your role, Contributor. Read-only. · Close
## 10 API keys: inherited role
Route: /settings/api-keys · API: GET /v1/api-key · Auth: Settings: API keys
Keys follow their creator's role live. The fact is stated in an info alert and each row shows the creator's role.
### Keys follow the role (Available now)
Trigger: Always shown on the API keys screen.
Copy: Keys follow their creator's role · A key can do what its creator's role can do, today. If the person's role changes, their keys change with it. · Follows role
## 11 Flow: create "Contributor" in under a minute
Route: /settings/roles, /settings/roles/new · API: POST /v1/role · Auth: Roles: manage
Six steps, about 40 seconds: 1 New role. 2 Name and description. 3 Choose the preset Write own. 4 Check the preview and the Schema and Media defaults. 5 Save. 6 Done. A new role starts with nothing granted; presets fill the grid in one tap.
### 1 Roles list: New role (Available now)
Trigger: Click New role.
Copy: New role
### 2 Name it (Available now)
Trigger: Type a name and a one-line description.
Copy: Name Contributor · Description Writes posts and edits only their own
### 3 Tap the preset: Write own (Available now)
Trigger: The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.
Copy: Write own · 7 unsaved changes
### 4 Check General permissions (Available now)
Trigger: Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.
Copy: What this role can do · Can read any entry, create entries and edit only their own. Can't delete and publish.
### 5 Save (Available now)
Trigger: Save changes. The role appears in the list and in the invite picker.
Copy: Save changes
### 6 Done (Available now)
Trigger: Toast, and the bar closes.
Copy: Role created · Contributor is ready. Assign it from Users.
## 12 Toasts: role changed and stale state
Route: Any page · API: PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership · Auth: As the action
Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".
### Role changed (Available now)
Trigger: 200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.
Copy: Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.
### Role edited elsewhere (409) (Available now)
Trigger: 409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.
Copy: Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload
### Role deleted elsewhere (Available now)
Trigger: Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.
Copy: That role no longer exists · It was deleted somewhere else, so we removed it from the list.
### Role not changed: member's role changed first (409) (Available now)
Trigger: Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.
Copy: Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload
### Ownership not transferred: target's role changed first (409) (Available now)
Trigger: Transfer ownership was confirmed after the target's role changed, and the dialog was closed in the meantime. Nothing was transferred.
Copy: Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload
### User not removed: member's role changed first (409) (Available now)
Trigger: Remove user was confirmed after the member's role changed. Nothing was removed.
Copy: User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload
## 13 Inline errors: editor and dialogs
Route: Role editor, Change role, Delete role, Invite · API: POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite · Auth: As the action
Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.
### Role editor: save failed (500) (Available now)
Trigger: 500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.
Copy: Couldn't save the role · Your changes are still here. Try again in a moment. · Try again
### Change role: failed (500) (Available now)
Trigger: 500 or network failure. The choice is kept.
Copy: Couldn't change the role · Nothing was changed. Try again in a moment.
### Delete role: target role deleted (Available now)
Trigger: 409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.
Copy: Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.
### Delete role: role gained members (Available now)
Trigger: 409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.
Copy: Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)
### Delete role: move failed (500) (Available now)
Trigger: 500 or network failure on reassign. The chosen role is kept.
Copy: Couldn't move the members · Nothing was changed. Try again in a moment.
### Invite: invalid email (Available now)
Trigger: 400 or client check. Shown under Email.
Copy: Enter a valid email address.
### Invite: address already has an account (409) (Available now)
Trigger: The address belongs to a user. Shown under Email. Same text as the API.
Copy: This address already has an account.
### Invite: role above yours (403) (Available now)
Trigger: An admin picked a role with more power than their own. Shown under Role.
Copy: Your role can't assign a role with more power than yours.
### Invite: send failed (500) (Available now)
Trigger: 500 or network failure. Email and role are kept.
Copy: Couldn't send the invite · Try again in a moment.
## 14 Read = Own: what the person sees
Route: /articles, /content/:key, /settings/roles/:id · API: GET /v1/article/, GET /v1/content-type/{key}/entry · Auth: As the role
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people's rows are still listed with a View only mark.
### Role editor: Read set to Own (Available now)
Trigger: Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.
Copy: 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. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.
### Articles list: Read = Own (Available now)
Trigger: Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.
Copy: Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article
### Articles list: no drafts of their own (Available now)
Trigger: The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.
Copy: You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0
### Entry list: Read = Own (Available now)
Trigger: Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.
Copy: Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry
### Entry list: no entries of their own (Available now)
Trigger: The person has created none. Other people's entries exist but are not hinted at.
Copy: 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. · New entry
## 15 Role 409 conflicts: stale save, change and transfer
Route: /settings/roles/:id, /users · API: PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership · Auth: As the action
Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.
### Stale save: banner (Available now)
Trigger: 409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
### Stale save: review differences (Available now)
Trigger: Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.
Copy: How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits
### Stale save: edits kept after reload (Available now)
Trigger: After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.
Copy: Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes
### Change role: member's role changed first (409) (Available now)
Trigger: Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.
Copy: Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role
### Transfer ownership: target's role changed first (409) (Available now)
Trigger: Transfer confirmed after the target's role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.
Copy: Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)
## Tables
The board lists, and this file repeats, the tables below.
### Permission catalogue (names shown to people)
| Group | Items (human name) | Scope |
|---|---|---|
| Content, per type | Read entries · Create entries · Update entries · Delete entries · Publish entries | None / Own / All, except Create (on or off) |
| Schema | View content types · Create and edit content types · Delete content types | On or off |
| Media | View the media library · Upload files · Delete files | Delete: None / Own / All |
| Authors | View authors · Create authors · Edit authors · Delete authors | Edit: None / Own / All |
| Activity | See activity · Export activity | See: None / Own / All |
| Users and invites | View users · Invite users · Change a user's role · Remove users | On or off |
| Settings | View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site (owner only) | API keys: None / Own / All |
| Roles | View roles · Manage roles · Edit roles with owner-only power (owner only) | On or off |
| System (owner only) | Apply software updates · Edit activity retention · Transfer ownership | Locked off for everyone but the owner |
### Built-in roles
| Role | What it has | Editable by |
|---|---|---|
| Owner | Everything. One per site. Ownership can be transferred; the former owner becomes Admin. | No one (locked) |
| Admin | Everything except the owner-only list. Can see all settings, read-only where they can't edit. | Owner |
| Writer | Reads schema and all content; creates and edits all content. No delete, no publish; no schema, settings, sites, users or integrations. Authors: view, edit own (assumed). | Owner (duplicate for variants) |
### No-escalation feedback
| Rule | Where it shows | Copy |
|---|---|---|
| Grant only what you hold | Disabled segment or switch with a lock; popover on focus or hover | You can only grant what your role has. Your role can publish only entries you created, so All isn't available. |
| No role above yours | Disabled option in the role picker (Change role, Delete reassign, Invite) | Your role can't assign a role with more power than yours. |
| No editing your own role | Editor opens read-only with a banner | You can't change the permissions of your own role. Ask another owner or admin. |
| No delete while it has members | Delete dialog, blocked state | 4 members have this role. Move them to another role first. Then Contributor can be deleted. |
| Owner-only list | Disabled item with an Owner only tag and a lock | Only the owner can apply updates. |
### Hidden or disabled
| Case | Treatment | Example |
|---|---|---|
| Whole area the role never has | Hidden (no sidebar item, no page card). Direct link shows the 403 page. | Schema for a Writer |
| Action on something visible, blocked by role | Disabled with a reason in a tooltip or row text | Delete on an article for a Writer |
| Action that only exists above their level | Hidden | Publish for a Writer; status shows Draft and "Ask an editor to publish." |
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
| Item the role may not read (Read = Own) | Not listed and not counted. No View only mark, no hint that it exists. | Another person's draft article, or an entry created by someone else |
### HTTP status to copy (additions)
| Code | Status | Title | Body |
|---|---|---|---|
| missing_permission | 403 | You don't have permission to do that | Your role doesn't include {permission name}. Ask an owner or admin. |
| role_locked | 403 | This role is locked | The Owner role can't be changed or deleted. Transfer ownership from Users instead. |
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| role_conflict (change role) | 409 | Role not changed | Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed. |
| role_conflict (remove, transfer) | 409 | User not removed / Ownership not transferred | {user}'s role changed in the meantime, so nothing was removed (transferred). Check it and try again. |
| role_not_found | 404 | That role no longer exists | It was deleted somewhere else, so we removed it from the list. |
| escalation | 403 | You can't grant that | You can only grant what your role has. |
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
### Components (new) and Nuxt UI v4 mapping
| 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: 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. |
### 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). The same 409 covers a change of role or a removal when the member's role changed first, and an ownership transfer when the target's role changed first. |
| 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. The Failed outcome is a field on the event, not a separate name. |
### Existing 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 | New "Roles" item. "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. |
### Activity events: sentences, EN and FR
| Event name | EN | FR |
|---|---|---|
| role.create | Role Contributor created | Rôle Contributor créé |
| role.edit | Role Contributor edited | Rôle Contributor modifié |
| role.delete | Role Intern deleted | Rôle Intern supprimé |
| role.reassign | Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer) | 3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer) |
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
### Activity events: failed sentences, EN and FR
| Event name (outcome failed) | EN | FR |
|---|---|---|
| Template | Failed to {verb} {object} | Échec de {action} {objet} |
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
| user.invite | Failed to invite sofia@renewbuild.example | Échec de l'invitation de sofia@renewbuild.example |
| user.remove | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
| Reason: role_conflict | Jonas Weber saved changes to Contributor first, so this edit was refused. | Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée. |
| Reason: role_locked | The Owner role can't be changed or deleted. | Le rôle Owner ne peut être ni modifié ni supprimé. |
| Reason: missing_permission | The role of this person doesn't include that permission. | Le rôle de cette personne n'inclut pas cette autorisation. |
### Activity filter: category names, EN and FR
| Value | EN | FR |
|---|---|---|
| (none) | All categories | Toutes les catégories |
| articles | Articles | Articles |
| media | Media | Médias |
| users | Users and invites (was "Users & invites") | Utilisateurs et invitations |
| roles (new, to confirm) | Roles | Rôles |
| api_keys | API keys | Clés API |
| auth | Authentication | Authentification |
| system | System | Système |
### Final copy: toasts and errors, EN and FR
| State | EN | FR |
|---|---|---|
| Toast: role changed | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. | Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle. |
| Toast: role edited elsewhere (409) | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload | Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. | Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste. |
| Toast: member's role changed before a change of role (409) | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload | Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
| Toast: target's role changed before a transfer (409) | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload | Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger |
| Toast: member's role changed before a removal (409) | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload | Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger |
| Editor: name required | Give the role a name. | Donnez un nom au rôle. |
| Editor: name taken (409) | A role named "Writer" already exists. Choose a different name. | Un rôle nommé « Writer » existe déjà. Choisissez un autre nom. |
| Editor: save failed (500) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again | Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer |
| Editor: stale save banner (409) | This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences | Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences |
| Editor: review differences | How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits | Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications |
| Editor: edits kept after reload | Role reloaded · Your 2 changes are still here. Review them, then save. | Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez. |
| Editor: Read = Own note | 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. | Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle. |
| Change role: member's role changed (409) | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. | Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez. |
| Change role: failed (500) | Couldn't change the role · Nothing was changed. Try again in a moment. | Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant. |
| Change role: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
| Delete role: target deleted | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. | Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres. |
| Delete role: role gained members (409) | Role still has members · Intern now has 1 member. Move them to another role first. | Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle. |
| Delete role: move failed (500) | Couldn't move the members · Nothing was changed. Try again in a moment. | Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant. |
| Invite: invalid email | Enter a valid email address. | Saisissez une adresse e-mail valide. |
| Invite: account exists (409) | This address already has an account. | Cette adresse a déjà un compte. |
| Invite: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
| Invite: send failed (500) | Couldn't send the invite · Try again in a moment. | Impossible d'envoyer l'invitation · Réessayez dans un instant. |
| Transfer: target's role changed (409) | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member | Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre |
### Read = Own: what each surface returns
| Surface | Read = All | Read = Own |
|---|---|---|
| Articles list | Every article, including drafts by others | Online articles plus the person's own drafts |
| Status counts (All, Online, Draft) | Counted over every article | Counted over the visible articles only: Draft counts own drafts, Online counts every online article |
| Custom type entry list | Every entry | Only entries the person created. The count line and pager use that number. |
| API list calls and X-Total-Count | Every item | Only the visible items. The total does not reveal hidden ones. |
| Direct link to a hidden item (proposed) | Opens | The existing 404 page: Entry not found. No hint that the item exists. |
| Empty state | Existing empty states | You have no drafts · You haven't created an event yet. New article or New entry stays available when Create is on. |
### 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 | Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted. | Hide other people's entries for Update and Delete too. |
| 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. |
State count: 90.
## Read = Own: singular noun
The singular noun in Read = Own copy ("entries you created", "event") is derived from the type name. Irregular names ("People", "Series") need a per-type singular field. Drawn as a Later item.
## 2b Role editor: app sync
Presets: Read only sets Read: All and everything else None for every type. Write own sets Read: All, Create: On, Update: Own, Delete and Publish: None. Full content sets Read, Update, Delete and Publish: All and Create: On. A preset overwrites every type row, including custom rows, and every changed cell counts as an unsaved change.
"N unsaved changes" counts cells in the content grid plus general toggles that differ from the loaded role. Name and description count as one each. Setting a cell back to its loaded value removes it from the count.
The footer bar is pinned to the bottom. The content grid scrolls sideways inside its card below 720 px. There is no leave guard on the delete dialog: it holds no unsaved edits.
| State | Trigger | Copy |
|---|---|---|
| No Schema: view | The editor's role lacks schema.view, so custom types can't be listed. | Custom content types aren't listed · Your role doesn't include Schema: view, so only Articles is shown. The permissions of custom types stay as they are. |
| Reset to default: confirm | Built-in role, owner only. | Reset Writer to default? · Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can't be undone. · Cancel · Reset to default (proposal) |
| Reset to default: done | | Role reset · Writer is back to its defaults. Save to apply it to 5 members. (proposal) |
| Review differences: name and description | Name and description appear as rows above the permission rows. | Name · Contributor · Event writer · Description · Writes posts · Writes events |
| Duplicate | /settings/roles/new?from=<id>. Name prefilled "<role> copy"; nothing saved until Save changes. | Copied from Contributor · The permissions are the same. Give the copy a name that fits, then save. (proposal) |
| role_not_found page | /settings/roles/:id for a deleted role. | That role no longer exists · It was deleted somewhere else, so we removed it from the list. · Back to Roles |
| invalid_role_name (400) | Server refuses a blank name. | Give the role a name. |
| Generic 400 | Any other 400 on save. | Couldn't save the role · Something in the form isn't valid. Check the highlighted fields. |
| Role created | 201 on create. | Role created · Contributor is ready. Assign it from Users. |
| Built-in role delete | Reached by link. | Delete Writer? · Built-in roles can't be deleted. |
| Change role: choice is now current | After the re-read the chosen role equals the member's current role. | Change role is disabled. |
### Preview sentence templates (beyond the three examples)
| Setting | Sentence |
|---|---|
| Read: None | No access to entries. |
| Read: Own | Can read only entries they created. |
| Read: All | Can read any entry. |
| Create: On | Can create entries. |
| Update: Own / All | Can edit only their own / any entry. |
| Delete: Own / All | Can delete only their own / any entry. |
| Publish: Own / All | Can publish only their own / any entry. |
| Mixed rows | Listed per type, after "All content types". |
Sentences join with commas and "and"; types that differ from the wildcard get their own line.
+9 -5
View File
@@ -4,7 +4,8 @@ All states on this page are **Later**: relations, permissions and schema export
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Relations", "Permissions", "Export and import". 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. Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Relations", "Permissions", "Export and import". 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.
### 7 Relation field and picker (group "7 Relations", 3 states) ### 7 Relation field and picker (group "7 Relations", 5 states)
The picker exists in the app, so this group is no longer under "Later" in the build order; the route and the permission row stay as written.
Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form). Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form).
@@ -13,12 +14,13 @@ All later. The model reads as two sentences, with a live preview of both sides.
| # | State | Trigger | Copy | Scope | | # | State | Trigger | Copy | Scope |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | Relation in the builder | A seventh type, Relation, in the type gallery. | Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events" | Later | | 1 | Relation in the builder | A seventh type, Relation, in the type gallery. | Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events" | Later |
| 2 | Relation in the entry form | Search and pick; chips for many. | Venue · Speakers · Search to add · An event can have many speakers. | Later | | 2 | Relation in the entry form | Search and pick; chips for many. The picker loads 25 results per page (sample: 25 of 62 venues for "rue"); the list scrolls and ends with a footer. | Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more | Later |
| 3 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later | | 3 | Relation picker: loading more | "Load more" pressed. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, the footer count stays. Focus stays on the button, then moves to the first new row. | Showing 25 of 62 · Loading… · (skeleton rows) | Later |
| 4 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
### 8 Permissions grid (group "8 Permissions", 2 states) ### 8 Permissions grid (group "8 Permissions", 2 states)
Route: `/settings/permissions`. API: not built. Auth: Owner or admin. Route: `/settings/permissions`, retired: the grid now lives in the role editor (`/settings/roles/:id`), see `roles.md`. Kept below as the original draft.
All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes. All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.
@@ -29,7 +31,7 @@ All later. Rows are types, columns are actions, one role at a time. Built-in row
### 9 Schema export and import (group "9 Export and import", 3 states) ### 9 Schema export and import (group "9 Export and import", 3 states)
Route: `/schema (dialogs)`. API: not built. Auth: Owner or admin. Route: `/schema (dialogs)`. API: not built. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm. All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.
@@ -38,3 +40,5 @@ All later. Dev-to-production flow: export a file, import it elsewhere, preview,
| 1 | Export | Export on /schema. | Export schema · Download the fields of your content types as a file. Entries aren't included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file | Later | | 1 | Export | Export on /schema. | Export schema · Download the fields of your content types as a file. Entries aren't included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file | Later |
| 2 | Import preview | A file was chosen. | Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn't fit, nothing is imported. · Cancel · Import schema | Later | | 2 | Import preview | A file was chosen. | Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn't fit, nothing is imported. · Cancel · Import schema | Later |
| 3 | Import refused | The server answers schema_incompatible. | Nothing was imported · Some entries on this site don't fit the imported fields. RenewCMS can't tell which entries. The flagged change can cause this. · Try again | Later | | 3 | Import refused | The server answers schema_incompatible. | Nothing was imported · Some entries on this site don't fit the imported fields. RenewCMS can't tell which entries. The flagged change can cause this. · Try again | Later |
| 4 | Relation picker: load more failed | Load more failed. Loaded rows stay, an inline error replaces the count, the button reads Try again. Focus stays on the button, then moves to the first new row. | Couldn't load more results. The 25 shown are still here. · Try again | Available now |
+13 -4
View File
@@ -1,6 +1,6 @@
# Schema # Schema
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: owner or admin. Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
Custom content types: an owner or admin defines a type (for example Events) made of 1 to 50 ordered fields; any verified user then writes entries. Everything about a type happens on one screen. Primary words: content type, field, choice list, entry. "Key" is kept for the API identifier. The key of a type is chosen at creation and never changes; the key and type of a saved field never change. Built-in types (Articles, Authors, Sites, Media, Users) are listed with a lock and a "Built-in" badge and open a read-only view. Non-owner, non-admin users see no Schema entry; a direct link shows a 403 page. Custom content types: an owner or admin defines a type (for example Events) made of 1 to 50 ordered fields; any verified user then writes entries. Everything about a type happens on one screen. Primary words: content type, field, choice list, entry. "Key" is kept for the API identifier. The key of a type is chosen at creation and never changes; the key and type of a saved field never change. Built-in types (Articles, Authors, Sites, Media, Users) are listed with a lock and a "Built-in" badge and open a read-only view. Non-owner, non-admin users see no Schema entry; a direct link shows a 403 page.
@@ -8,7 +8,7 @@ Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Schema list", "Sche
### 1 Schema: list of types (group "1 Schema list", 6 states) ### 1 Schema: list of types (group "1 Schema list", 6 states)
Route: `/schema, /schema/:key (built-in)`. API: GET /v1/content-type. Auth: Owner or admin. Route: `/schema, /schema/:key (built-in)`. API: GET /v1/content-type. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative. Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative.
@@ -23,7 +23,7 @@ Sidebar entry "Schema" for owners and admins only. Built-in types are listed wit
### 2 Schema builder (group "2 Schema builder", 16 states) ### 2 Schema builder (group "2 Schema builder", 16 states)
Route: `/schema/new, /schema/:key`. API: POST /v1/content-type, GET/PUT /v1/content-type/{key}. Auth: Owner or admin. Route: `/schema/new, /schema/:key`. API: POST /v1/content-type, GET/PUT /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both. One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.
@@ -48,7 +48,7 @@ One screen, three regions: type name and key on top, ordered field list on the l
### 3 Schema change review and delete type (group "3 Review and delete", 7 states) ### 3 Schema change review and delete type (group "3 Review and delete", 7 states)
Route: `/schema/:key (dialogs)`. API: PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}. Auth: Owner or admin. Route: `/schema/:key (dialogs)`. API: PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}. Auth: permissions schema.view (open and read), schema.edit (create and change), schema.delete (delete a type).
The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later. The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later.
@@ -79,3 +79,12 @@ One frame per type: inspector options, the entry form control (default and open
## Media field (pass 5) ## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.1 to 3.4. Board groups: 1 Schema list, 2 Schema builder, 3 Review, 4 Field types. Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.1 to 3.4. Board groups: 1 Schema list, 2 Schema builder, 3 Review, 4 Field types.
## Permissions (app sync)
Three keys gate Schema: schema.view (list and read types, and the Schema sidebar entry), schema.edit (create and change types and fields), schema.delete (delete a content type). The built-in Owner and Admin roles hold all three. The app still words the 403 as "Only owners and admins can…"; the design keeps that copy until the app changes. A role with schema.view only sees the builder read-only: Save, Add field, Move and Remove are hidden.
## Mobile field sheet
The field sheet carries Move up, Move down and Remove (44 px, equal widths) above Done. Move up is disabled on the first field, Move down on the last.
## Saving
Save always opens the review first, with or without entries. During Apply the dialog reads Applying… and the entries info bar is hidden.
+8 -2
View File
@@ -2,7 +2,7 @@
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending). Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created. One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Users". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md. Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Users". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
@@ -29,7 +29,7 @@ Every state below is drawn in light and dark (theme switch on the board), at 144
Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending). Route: `/users`. API: GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending).
One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created. One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role badge, invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.
| # | State | Trigger | Copy | | # | State | Trigger | Copy |
|---|---|---|---| |---|---|---|---|
@@ -50,3 +50,9 @@ One shared workspace: every user sees the same articles and this same list. Colu
| 15 | Renew invite: done | 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. Clock-sensitive: shows a relative time or a countdown. | "Today" / "Invite renewed" / "p.huber@gmail.com has 3 days to accept the new link." | | 15 | Renew invite: done | 200: the row becomes Pending with today's date, moves to the top, and the old link stops working. Clock-sensitive: shows a relative time or a countdown. | "Today" / "Invite renewed" / "p.huber@gmail.com has 3 days to accept the new link." |
| 16 | Renew invite: failed | 500 or network failure. The row stays Expired and the button can be pressed again. | "Couldn't renew the invite" / "Try again in a moment." | | 16 | Renew invite: failed | 500 or network failure. The row stays Expired and the button can be pressed again. | "Couldn't renew the invite" / "Try again in a moment." |
| 17 | Invite sent | 201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms. | "lea.martin@renew.example" / "Today" / "lea.martin@renew.example has 3 days to accept." | | 17 | Invite sent | 201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms. | "lea.martin@renew.example" / "Today" / "lea.martin@renew.example has 3 days to accept." |
## Spec notes (app sync)
- The Users table has no "Assigned by" column. Columns: email, display name, role, invite status, created.
- A blocked role in the RolePicker shows the generic RolePicker reason, not a per-role sentence. The current role carries a "Current" mark; Owner is disabled with its reason.
- Own row: Change role is disabled with "You can't change your own role". Remove user is disabled with "You can't remove yourself. Ask another owner or admin."
- Remove user: dialog "Remove {0}?" / "They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay." / Remove user (pending: "Removing…"). Success toast "User removed" / "{0} no longer has access." 500: "Couldn't remove the user" / "Nothing was changed. Try again in a moment." 409 target_role_not_found: toast "User not removed" / "{0}'s role changed in the meantime, so nothing was removed. Check it and try again." / Reload. {0} is the display name, falling back to the username.
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="./account-copy.js"></script>
</helmet>
<div style="display:contents;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
<sc-if value="{{ pEmail }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;gap:6px;min-width:0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ c.emailLabel }}</span><sc-if value="{{ showChange }}"><span data-act="ac-change" role="button" style="min-height:{{ linkH }};display:flex;align-items:center;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px">{{ c.changeEmail }}</span></sc-if></div>
<div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ old }}</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div>
</div>
</sc-if>
<sc-if value="{{ pPanel }}" hint-placeholder-val="{{ false }}">
<sc-if value="{{ form }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:{{ panelPad }};background:var(--ui-bg-muted);border:1px solid var(--ui-border)">
<sc-if value="{{ form.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ form.notice }}</span></div></sc-if>
<sc-for list="{{ form.fields }}" as="f" hint-placeholder-count="2">
<div style="display:flex;flex-direction:column;gap:6px">
<label style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</label>
<div style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }};overflow:hidden;white-space:nowrap"><span style="overflow:hidden;text-overflow:ellipsis;letter-spacing:{{ f.ls }}">{{ f.shown }}</span><sc-if value="{{ f.caret }}"><span style="flex-shrink:0;display:inline-block;width:1px;height:18px;background:var(--ui-text-highlighted);margin-left:1px"></span></sc-if></div>
<sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if>
</div>
</sc-for>
<span style="color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ c.formHelp }}</span>
<div style="display:flex;flex-direction:{{ actDir }};justify-content:flex-end;gap:8px">
<span data-act="ac-form-cancel" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ form.cancelFg }};font-weight:500">{{ c.cancel }}</span>
<span data-act="ac-form-send" style="min-height:{{ btnH }};padding:6px 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ form.op }}"><sc-if value="{{ form.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ form.sendLabel }}</span>
</div>
</div>
</sc-if>
<sc-if value="{{ pend }}">
<div role="status" style="display:flex;flex-direction:column;gap:12px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/mail.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:600">{{ c.pendTitle }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty;overflow-wrap:anywhere">{{ pend.text }}</span></div></div>
<div style="display:flex;flex-direction:{{ colDir }};gap:8px">
<span data-act="ac-resend" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ pend.op }}"><sc-if value="{{ pend.sending }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ pend.resendLabel }}</span>
<span data-act="ac-cancel-change" style="min-height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ c.cancelChange }}</span>
</div>
<sc-if value="{{ pend.note }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px;line-height:18px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ pend.note }}</span></sc-if>
</div>
</sc-if>
</sc-if>
<sc-if value="{{ pCards }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;flex-direction:column;gap:16px;flex-shrink:0">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;flex-shrink:0;display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">{{ c.dataTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dataDesc }}</span></div>
<div style="display:grid;grid-template-columns:{{ itemCols }};gap:12px 16px">
<sc-for list="{{ items }}" as="it" hint-placeholder-count="4">
<div style="display:flex;gap:10px;min-width:0"><span style="display:flex;padding-top:2px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ it.title }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ it.text }}</span></div></div>
</sc-for>
</div>
<sc-if value="{{ data.notice }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ data.notice.bg }};color:{{ data.notice.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.notice.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ data.notice.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ data.notice.text }}</span></div></div></sc-if>
<div style="display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};gap:12px">
<span data-act="ac-export" style="min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ data.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ data.icon }}.svg') center / contain no-repeat"></span>{{ data.label }}</span>
<span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ data.hint }}</span>
</div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-error);display:flex;flex-direction:column;flex-shrink:0">
<div style="padding:16px;display:flex;flex-direction:{{ colDir }};align-items:{{ rowAlign }};justify-content:space-between;gap:14px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px;min-width:0"><span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span>{{ c.dzTitle }}</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ c.dzDesc }}</span></div>
<span data-act="ac-delete" style="flex-shrink:0;min-height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;text-align:center;background:var(--ui-error);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ c.dzBtn }}</span>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
componentDidMount() {
if (!window.ACCOUNT_COPY) this.t = setInterval(() => { if (window.ACCOUNT_COPY) { clearInterval(this.t); this.forceUpdate() } }, 100)
}
componentWillUnmount() { clearInterval(this.t) }
renderVals() {
const p = this.props, m = !!p.mobile, part = p.part || 'cards', ac = p.ac || {}
const C = p.c || (window.ACCOUNT_COPY && window.ACCOUNT_COPY.en) || {}
const old = ac.old || 'marta@renew.example', nw = ac.new || 'marta.alves@atelier-renew.example'
const fmt = s => (s || '').split('{new}').join(nw).split('{old}').join(old)
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
const fld = (label, val, ph, o = {}) => ({
label, shown: val || ph || '', caret: !!o.focus && !o.dis,
fg: o.dis ? 'var(--ui-text-dimmed)' : val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)',
bd: o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
sh: o.err ? 'inset 0 0 0 1px var(--ui-error)' : o.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none',
bg: o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', err: o.err || '', ls: o.mask && val ? '0.12em' : 'normal'
})
const F = ac.form
const form = F ? {
fields: [
fld(C.newEmail, F.email, C.newEmailPh, { err: F.errEmail, focus: F.focus === 'email', dis: F.sending }),
fld(C.password, F.pw ? '•'.repeat(F.pw.length) : '', '', { err: F.errPw, focus: F.focus === 'pw', dis: F.sending, mask: true })
],
notice: F.notice || '', sending: !!F.sending, sendLabel: F.sending ? C.sending : C.send,
op: F.disabled ? '0.4' : '1', cancelFg: F.sending ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)'
} : null
const P = ac.pending
const pend = P ? {
text: fmt(C.pendText), sending: P.resend === 'sending',
resendLabel: P.resend === 'sending' ? C.sending : P.resend === 'rate' ? C.resendRate : C.resend,
op: P.resend === 'rate' ? '0.4' : '1',
note: P.resend === 'rate' ? C.rate : P.note || ''
} : null
const D = ac.data || {}, async_ = D.mode === 'async', ds = D.state || 'idle'
let data = { label: async_ ? C.request : C.download, icon: async_ ? 'mail' : 'download', op: '1', hint: async_ ? fmt(C.asyncHint) : C.dlHint, notice: null }
if (!async_ && ds === 'preparing') data = { ...data, label: C.preparing, icon: 'loader-circle', hint: '' }
if (!async_ && ds === 'rate') data = { ...data, label: C.exRateBtn, op: '0.4', icon: 'download', hint: '', notice: { ...tone('info'), icon: 'timer', title: C.exRateT, text: C.exRateB } }
if (ds === 'error') data = { ...data, notice: { ...tone('error'), icon: 'circle-x', title: C.exErrT, text: C.exErrB } }
if (async_ && ds === 'queued') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('success'), icon: 'mail-check', title: C.queuedT, text: fmt(C.queuedB) } }
if (async_ && ds === 'inprogress') data = { ...data, label: C.queuedBtn, op: '0.4', icon: 'mail-check', hint: '', notice: { ...tone('info'), icon: 'info', title: C.inprogT, text: fmt(C.inprogB) } }
return {
c: C, old, form, pend, data,
items: (C.dataItems || []).map(x => ({ icon: x[0], title: x[1], text: x[2] })),
pEmail: part === 'email', pPanel: part === 'panel', pCards: part === 'cards',
showChange: !F && !P,
inH: m ? '44px' : '40px', inF: m ? '16px' : '14px', btnH: m ? '44px' : '36px', linkH: m ? '44px' : '20px',
panelPad: '16px', actDir: m ? 'column-reverse' : 'row', colDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'center',
itemCols: m ? 'minmax(0,1fr)' : 'repeat(2,minmax(0,1fr))', delAlign: m ? 'stretch' : 'flex-start'
}
}
}
</script>
</body>
</html>
+6 -5
View File
@@ -16,11 +16,11 @@
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if> <sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
<sc-if value="{{ showList }}"> <sc-if value="{{ showList }}">
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"> <sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div> <div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Follows role</span><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}"> <sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if> <sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:50%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span> <sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span> <sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span><span style="display:inline-flex;align-items:center;height:20px;padding:0 6px;border:1px solid var(--ui-border-accented);color:{{ r.fg2 }};font:400 12px 'IBM Plex Mono',monospace">{{ r.role }}</span></span><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if> <span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
</div></sc-for></div></sc-if> </div></sc-for></div></sc-if>
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0"> <sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
@@ -28,7 +28,7 @@
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if> <sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div> <sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span> <span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span> <span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Follows role {{ r.role }} · Last used {{ r.used }} · Expires {{ r.exp }}</span>
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if> <sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
</div></sc-for></div></sc-if> </div></sc-for></div></sc-if>
</sc-if> </sc-if>
@@ -69,6 +69,7 @@ class Component extends DCLogic {
let all = owner let all = owner
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')] ? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')] : [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
all = all.map(r => ({ ...r, role: r.by === (owner ? 'Marta Alves' : 'Jonas') ? (owner ? 'owner' : 'admin') : '—' }))
const st = k.state || 'list' const st = k.state || 'list'
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
const cp = { reveal: true } const cp = { reveal: true }
@@ -87,7 +88,7 @@ class Component extends DCLogic {
mob, desk: !mob, owner, rows, pad: mob ? '16px 16px 84px' : '24px', pinned: mob && !m, h1: '20px/28px', mob, desk: !mob, owner, rows, pad: mob ? '16px 16px 84px' : '24px', pinned: mob && !m, h1: '20px/28px',
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.', sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading', err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px', cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 90px 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 90px 100px 100px 100px 80px',
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px', toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
modal: !!m, m: m || {}, closeSz: mob ? '44px' : '28px', btnH: mob ? '44px' : '32px', mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '0' : '140px 16px 16px', mW: mob ? '100%' : '460px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row' modal: !!m, m: m || {}, closeSz: mob ? '44px' : '28px', btnH: mob ? '44px' : '32px', mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '0' : '140px 16px 16px', mW: mob ? '100%' : '460px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
} }
+18 -12
View File
@@ -12,7 +12,7 @@
<div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px"> <div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px">
<div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div> <div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div>
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"> <sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if> <div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if>
</sc-for> </sc-for>
<sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if> <sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if>
<div style="flex:1"></div> <div style="flex:1"></div>
@@ -103,7 +103,7 @@
<sc-if value="{{ a.t }}"> <sc-if value="{{ a.t }}">
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span> <span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if> <sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span> <span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span>
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span> <span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span> <span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if> <sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
@@ -125,7 +125,7 @@
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6"> <sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}"> <div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if> <sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit<Line truncated <sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center Line truncated
</div> </div>
</sc-for> </sc-for>
</div> </div>
@@ -263,7 +263,7 @@
</sc-if> </sc-if>
<sc-if value="{{ isProfile }}"> <sc-if value="{{ isProfile }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;overflow:hidden"> <div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;max-width:720px;flex-shrink:0;margin-top:{{ pfShift }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div> <div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div>
<sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if> <sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if>
<sc-if value="{{ pfReady }}"> <sc-if value="{{ pfReady }}">
@@ -281,11 +281,12 @@
</div> </div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"> <div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div> <div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Email</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta@renew.example</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div> <div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><dc-import name="AccountCards" part="email" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></div>
<sc-if value="{{ pfPanel }}"><dc-import name="AccountCards" part="panel" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import></sc-if>
</div> </div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div> <div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div>
<sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if> <sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if>
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">Uses your display name, Marta Alves. You can change it afterwards.</span></div></sc-if> <sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">{{ pAu.uses }}</span></div></sc-if>
<sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div> <sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div>
<sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if> <sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if>
<div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div> <div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div>
@@ -294,6 +295,7 @@
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div> <div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
<sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if> <sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if>
</div> </div>
<dc-import name="AccountCards" part="cards" ac="{{ pfAc }}" c="{{ pfC }}" mobile="{{ mob }}" hint-size="100%,60px" style="display:block;height:auto;min-height:0;flex-shrink:0"></dc-import>
</sc-if> </sc-if>
</div> </div>
</sc-if> </sc-if>
@@ -316,9 +318,12 @@
<div style="padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.modal.title }}</span><sc-if value="{{ s.modal.desc }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ s.modal.desc }}</span></sc-if></div><sc-if value="{{ s.modal.close }}"><span data-act="modal-close" style="width:{{ closeSz }};height:{{ closeSz }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></div> <div style="padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.modal.title }}</span><sc-if value="{{ s.modal.desc }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ s.modal.desc }}</span></sc-if></div><sc-if value="{{ s.modal.close }}"><span data-act="modal-close" style="width:{{ closeSz }};height:{{ closeSz }};display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></div>
<div style="padding:20px 24px 0;display:flex;flex-direction:column;gap:16px"> <div style="padding:20px 24px 0;display:flex;flex-direction:column;gap:16px">
<sc-if value="{{ s.modal.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ s.modal.notice }}</span></div></sc-if> <sc-if value="{{ s.modal.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ s.modal.notice }}</span></div></sc-if>
<sc-if value="{{ s.modal.list }}"><div style="display:flex;flex-direction:column;gap:6px"><sc-for list="{{ mList }}" as="li" hint-placeholder-count="3"><div style="display:flex;gap:8px;align-items:flex-start;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">{{ li.t }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ mWarn }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ mWarn.bg }};color:{{ mWarn.fg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mWarn.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">{{ mWarn.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ mWarn.text }}</span></div></div></sc-if>
<sc-for list="{{ mFields }}" as="f" hint-placeholder-count="0"> <sc-for list="{{ mFields }}" as="f" hint-placeholder-count="0">
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</span><sc-if value="{{ f.count }}"><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ f.countFg }}">{{ f.count }}</span></sc-if></div><div style="min-height:{{ f.h }};padding:{{ f.p }};display:flex;align-items:{{ f.ai }};border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }}">{{ f.shown }}</div><sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if><sc-if value="{{ f.help }}"><span style="color:var(--ui-text-muted);font-size:12px">{{ f.help }}</span></sc-if></div> <div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ f.label }}</span><sc-if value="{{ f.count }}"><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ f.countFg }}">{{ f.count }}</span></sc-if></div><div style="min-height:{{ f.h }};padding:{{ f.p }};display:flex;align-items:{{ f.ai }};border:1px solid {{ f.bd }};box-shadow:{{ f.sh }};background:{{ f.bg }};color:{{ f.fg }};font-size:{{ inF }}">{{ f.shown }}</div><sc-if value="{{ f.err }}"><span style="display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ f.err }}</span></sc-if><sc-if value="{{ f.help }}"><span style="color:var(--ui-text-muted);font-size:12px">{{ f.help }}</span></sc-if></div>
</sc-for> </sc-for>
<sc-if value="{{ s.modal.link }}"><span data-act="modal-link" role="link" style="align-self:flex-start;min-height:{{ closeSz }};display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/download.svg') center / contain no-repeat"></span>{{ s.modal.link }}</span></sc-if>
<sc-if value="{{ s.modal.toggle }}"><div style="display:flex;align-items:center;gap:12px;min-height:{{ inH }}"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ s.modal.toggle.j }};background:{{ s.modal.toggle.bg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span><span style="color:var(--ui-text-highlighted);font-weight:500">Open in new tab</span></div></sc-if> <sc-if value="{{ s.modal.toggle }}"><div style="display:flex;align-items:center;gap:12px;min-height:{{ inH }}"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ s.modal.toggle.j }};background:{{ s.modal.toggle.bg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span><span style="color:var(--ui-text-highlighted);font-weight:500">Open in new tab</span></div></sc-if>
<sc-if value="{{ s.modal.removeLink }}"><div style="display:flex;align-items:center;gap:8px;min-height:{{ inH }};color:var(--ui-error);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</div></sc-if> <sc-if value="{{ s.modal.removeLink }}"><div style="display:flex;align-items:center;gap:8px;min-height:{{ inH }};color:var(--ui-error);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</div></sc-if>
<sc-if value="{{ s.modal.upload }}"> <sc-if value="{{ s.modal.upload }}">
@@ -362,16 +367,17 @@
</div> </div>
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px"> <div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if> <sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
<sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if> <sc-if value="{{ s.modal.confirm }}"><span data-act="modal-confirm" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ mConfirmBg }};color:var(--ui-bg);font-weight:500;opacity:{{ mConfirmOp }};text-align:center"><sc-if value="{{ s.modal.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ s.modal.confirm }}</span></sc-if>
</div> </div>
</div> </div>
</div> </div>
</sc-if> </sc-if>
{{ purgeDlg }} {{ purgeDlg }}
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkiLine truncated <sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for><sc-if value="{{ n.hasAccess }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Access</span></sc-if><sc-for list="{{ n.subs3 }}" as="x3" hint-placeholder-count="1"><div data-act="nav-{{ x3.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x3.bg }};color:{{ x3.fg }};font-weight:{{ x3.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x3.ic }}.svg') center / contain no-repeat"></span>{{ x3.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span stLine truncated
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if> <sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if> <sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
<sc-if value="{{ integSearch }}"><dc-import name="IntegrationSearch" s="{{ s.search }}" mobile="{{ mob }}" hint-size="100%,100%"></dc-import></sc-if>
{{ slotOverlays }} {{ slotOverlays }}
<sc-if value="{{ hasToasts }}"> <sc-if value="{{ hasToasts }}">
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}"> <div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
@@ -390,7 +396,7 @@ class Component extends DCLogic {
const k = s.kind || 'home' const k = s.kind || 'home'
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']] const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1) const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } }) const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], hasAccess: s.role !== 'user', subs3: s.role === 'user' ? [] : [(() => { const o = cr0[1] === 'Roles'; return { t: 'Roles', ic: 'shield', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } })()], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } }) const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 })) const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {} const L = s.list || {}
@@ -538,7 +544,7 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation' const edTitle = e.title || 'How we price a bathroom renovation'
return { return {
lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.needsN || 3, edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1 } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 130px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn, lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.naState === 'pending' ? '…' : L.naState === 'fail' ? '!' : L.naState === 'absent' ? '' : String(L.needsN ?? 3), edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1, uses: q.user ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, Marta Alves. You can change it afterwards.' } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 150px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)', langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)', chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading, isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
@@ -546,7 +552,7 @@ class Component extends DCLogic {
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot, contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row', fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu', mLead: s.mLead || 'menu',
navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users', navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error, usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))', pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px', upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
@@ -579,7 +585,7 @@ class Component extends DCLogic {
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.', saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)', copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
errPad: m ? '40px 20px' : '64px 48px', errPad: m ? '40px 20px' : '64px 48px',
closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row', closeSz: m ? '44px' : '28px', modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '0' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row', mConfirmOp: md.disabled ? '0.4' : '1', mList: (md.list || []).map(t => ({ t })), mWarn: md.warn ? { ...md.warn, bg: 'var(--rc-' + md.warn.tone + '-soft)', fg: 'var(--ui-' + md.warn.tone + ')' } : null, pfShift: pf.shift ? '-' + (m ? pf.shift[1] : pf.shift[0]) + 'px' : '0px', pfAc: pf.ac || {}, pfC: s.copy || null, pfPanel: !!(pf.ac && (pf.ac.form || pf.ac.pending)),
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)', mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px' hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
} }
+11 -7
View File
@@ -162,7 +162,7 @@ class Component extends DCLogic {
Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }), Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }),
F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }), F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }),
Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'), Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'),
F('Site name', f.sname || 'e.g. Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name.' }), F('Site name', f.sname || f.name || 'Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name. Empty uses the site name, then "Renew".' }),
D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))), D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))),
F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }), F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }),
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')), D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
@@ -313,6 +313,7 @@ class Component extends DCLogic {
body.push(D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);' + (m ? '' : ''), D('padding:4px', ...inner), st === 'error' || mt.noCreate ? null : create)) body.push(D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);' + (m ? '' : ''), D('padding:4px', ...inner), st === 'error' || mt.noCreate ? null : create))
} }
if (mt.grab != null) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', au[mt.grab].name + ' grabbed. Position ' + (mt.grab + 1) + ' of ' + n + '. Use the up and down arrow keys to move, Space to drop, Escape to cancel.')) if (mt.grab != null) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', au[mt.grab].name + ' grabbed. Position ' + (mt.grab + 1) + ' of ' + n + '. Use the up and down arrow keys to move, Space to drop, Escape to cancel.'))
else if (mt.live) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', mt.live))
else if (n > 1 && mt.open !== 'authors') body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Drag to reorder. The first author is the primary byline.')) else if (n > 1 && mt.open !== 'authors') body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Drag to reorder. The first author is the primary byline.'))
out.push(D('display:flex;flex-direction:column;gap:8px', ...body)) out.push(D('display:flex;flex-direction:column;gap:8px', ...body))
return D('display:flex;flex-direction:column;gap:16px', ...out) return D('display:flex;flex-direction:column;gap:16px', ...out)
@@ -344,13 +345,14 @@ class Component extends DCLogic {
const Ct = (n, lo, hi) => S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (n && (n < lo || n > hi) ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), n + ' / ' + hi) const Ct = (n, lo, hi) => S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (n && (n < lo || n > hi) ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), n + ' / ' + hi)
const FW = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, !o.req && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.ct), F(null, val, o), o.fb && Chip(o.fb)) const FW = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, !o.req && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.ct), F(null, val, o), o.fb && Chip(o.fb))
const Dis = (t, o = {}) => D('display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 24) + 'px;color:var(--ui-text-highlighted)', I(o.open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', t), o.tag && Note(o.tag), S('flex:1;text-align:right;font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.sum)), ...(o.open ? o.kids : [])) const Dis = (t, o = {}) => D('display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 24) + 'px;color:var(--ui-text-highlighted)', I(o.open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', t), o.tag && Note(o.tag), S('flex:1;text-align:right;font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.sum)), ...(o.open ? o.kids : []))
const SugRow = (c, o = {}) => D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14))) const SugRow = (c, o = {}, i = 0) => D('display:flex;flex-direction:column;gap:6px', D('display:flex;align-items:flex-start;gap:8px;opacity:' + (o.busy === i ? 0.6 : 1), S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I(o.busy === i ? 'loader-circle' : 'x', 14))), o.fail === i && D('margin-left:22px;display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--ui-bg);border:1px solid var(--ui-error)', S('font-weight:600;color:var(--ui-error)', 'Couldn\'t dismiss the suggestion'), S('color:var(--ui-text-muted)', 'Try again in a moment.'), Link('Try again', 'rotate-cw')))
const Sugs = (codes, st, o = {}) => { const Sugs = (codes, st, o = {}) => {
const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c) const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c)
if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)'))) if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)')))
if (st === 'error') return box(Alert('error', 'circle-x', 'Couldn\'t check this article.', 'Your article is unchanged. Search and sharing still saves.', 'Try again'))
if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed'))) if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed')))
const shown = o.compact ? codes.slice(0, 3) : codes const shown = o.compact ? codes.slice(0, 3) : codes
return box(...shown.map(c => SugRow(c, o)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' )) return box(...shown.map((c, i) => SugRow(c, o, i)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' ))
} }
const SEOSIDE = (k) => { const SEOSIDE = (k) => {
const codes = k.codes || [], st = k.state || 'ok' const codes = k.codes || [], st = k.state || 'ok'
@@ -388,8 +390,8 @@ class Component extends DCLogic {
Dis('Social sharing', { open: p.social, tag: 'Optional', sum: p.social ? '' : (soc.t || soc.d || soc.img) ? 'Customised' : 'Uses the meta values', kids: [FW('Social title', soc.t || p.mt || ATT, { val: !!soc.t, ct: Ct([...(soc.t || '')].length, 0, 90), dis, fb: soc.t ? null : 'Using meta title' }), FW('Social description', soc.d || p.md || p.ex || AD1, { val: !!soc.d, area: true, dis, fb: soc.d ? null : 'Using meta description' }), Pick(soc.img || null, 'Social image', soc.img ? null : 'Using the cover image.')] }), Dis('Social sharing', { open: p.social, tag: 'Optional', sum: p.social ? '' : (soc.t || soc.d || soc.img) ? 'Customised' : 'Uses the meta values', kids: [FW('Social title', soc.t || p.mt || ATT, { val: !!soc.t, ct: Ct([...(soc.t || '')].length, 0, 90), dis, fb: soc.t ? null : 'Using meta title' }), FW('Social description', soc.d || p.md || p.ex || AD1, { val: !!soc.d, area: true, dis, fb: soc.d ? null : 'Using meta description' }), Pick(soc.img || null, 'Social image', soc.img ? null : 'Using the cover image.')] }),
Dis('Advanced', { open: p.adv, sum: p.adv ? '' : p.canon ? 'Canonical URL set' : 'Canonical URL: automatic', kids: [FW('Canonical URL', p.canon || site.url + '/' + sl, { val: !!p.canon, mono: true, dis, err: er.canon, focus: p.focus === 'canon', fb: p.canon ? null : 'Using site address and slug', help: er.canon ? null : 'Only change this if the same content lives at another address.' })] }), Dis('Advanced', { open: p.adv, sum: p.adv ? '' : p.canon ? 'Canonical URL set' : 'Canonical URL: automatic', kids: [FW('Canonical URL', p.canon || site.url + '/' + sl, { val: !!p.canon, mono: true, dis, err: er.canon, focus: p.focus === 'canon', fb: p.canon ? null : 'Using site address and slug', help: er.canon ? null : 'Only change this if the same content lives at another address.' })] }),
Sw(!!p.noindex, 'Hide from search engines', 'Sets noindex in the API. It doesn\'t change who can read the article.', dis)) Sw(!!p.noindex, 'Hide from search engines', 'Sets noindex in the API. It doesn\'t change who can read the article.', dis))
const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 }))) const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 })))
const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right) const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed, busy: p.busy, fail: p.fail }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right)
return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body) return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body)
} }
const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } } const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } }
@@ -397,12 +399,13 @@ class Component extends DCLogic {
const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok' const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok'
const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' })) const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' }))
const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk))) const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk)))
const cf = x.copyFail && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-warning-soft, var(--ui-bg-elevated));color:var(--ui-warning)', I('triangle-alert', 14, 'margin-top:3px'), S('color:var(--ui-text-toned);text-wrap:pretty', 'The browser blocked copying. The JSON is selected below: press Ctrl+C or Cmd+C to copy it.'))
let body let body
if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)'))) if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)')))
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again') else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again')
else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:var(--ui-bg-elevated);white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2)) else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:' + (x.copyFail ? 'var(--ui-info-soft, var(--rc-info-soft))' : 'var(--ui-bg-elevated)') + ';' + (x.copyFail ? 'outline:2px solid var(--ui-info);' : '') + 'white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2))
else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage')) else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage'))
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body)) return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', cf, x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body))
} }
// ---------- compose // ---------- compose
const v = s.v const v = s.v
@@ -424,6 +427,7 @@ class Component extends DCLogic {
if (s.seo && s.seo.panel) ov.push(seoPanel()) if (s.seo && s.seo.panel) ov.push(seoPanel())
if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 })) if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 }))
if (s.seo && s.seo.api) ov.push(apiDialog()) if (s.seo && s.seo.api) ov.push(apiDialog())
if (s.modal && m) { ov.push(Panel({ title: 'Details', z: 55, ro: true, confirm: '' }, metaUI())); edMeta = null }
if (s.modal) ov.push(newAuthorModal()) if (s.modal) ov.push(newAuthorModal())
if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann))) if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann)))
} else if (v === 'profile') { } else if (v === 'profile') {
+1 -1
View File
@@ -104,7 +104,7 @@ class Component extends DCLogic {
const dims = cur[1].split('×') const dims = cur[1].split('×')
const ratio = dims.length === 2 && !missing ? dims[0] + ' / ' + dims[1] : '4 / 3' const ratio = dims.length === 2 && !missing ? dims[0] + ' / ' + dims[1] : '4 / 3'
const rows = list.map((v, i) => ({ n: v[0], dim: v[1], fmt: v[2], url: '/media/' + img + '?v=' + v[0], bg: sel === v[0] ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)' })) const rows = list.map((v, i) => ({ n: v[0], dim: v[1], fmt: v[2], url: '/media/' + img + '?v=' + v[0], bg: sel === v[0] ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)' }))
const resp = [['/media/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' })) const resp = [['/static/uploadedImages/photo.png?v=thumb', '200', 'The variant image. Generated on first request.'], ['/media/photo.png?v=unknown', '400', 'Unknown variant', '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'], ['/media/photo.png?width=400', '400', 'Unsupported parameter', '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'], ['/media/missing.png?v=thumb', '404', 'Not found', '']].map((r, i) => ({ req: r[0], st: r[1], res: r[2], body: r[3], bt: i ? '1px solid var(--ui-border)' : '0' }))
const hasVariants = list.length > 0 const hasVariants = list.length > 0
return { return {
gap: m ? '20px' : '24px', btn: m ? '44px' : '32px', inH: m ? '44px' : '32px', pad: m ? '16px' : '20px', bCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1fr)', gap: m ? '20px' : '24px', btn: m ? '44px' : '32px', inH: m ? '44px' : '32px', pad: m ? '16px' : '20px', bCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1fr)',
+85 -30
View File
@@ -9,8 +9,9 @@
<x-dc> <x-dc>
<div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px"> <div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
<sc-if value="{{ mob }}"> <sc-if value="{{ mob }}">
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px"> <div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;align-items:center;gap:8px">
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button> <button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;flex:1;min-width:0;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
<button aria-label="Search the API reference" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;flex-shrink:0;width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span></button>
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"> <sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for> <sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
</div></sc-if> </div></sc-if>
@@ -19,12 +20,15 @@
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start"> <div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}"> <div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
<div style="display:flex;flex-direction:column;gap:6px"> <div style="display:flex;flex-direction:column;gap:6px">
<span style="color:var(--ui-text-muted)">Integration</span> <span style="color:var(--ui-text-muted)">Integration</span>
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span> <span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span> <span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
</div> </div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">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.</p> <sc-if value="{{ desk }}"><button aria-label="Search the API reference" aria-keyshortcuts="/ Control+K Meta+K" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;width:320px;flex-shrink:0;height:36px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);outline:{{ trigRing }};outline-offset:2px" style-hover="border-color:var(--ui-text-muted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:14px">Search the API reference…</span><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">/</span></button></sc-if>
</div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">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, and manage content types and entries, without the back-office.</p>
<div style="display:flex;flex-direction:column;gap:6px"> <div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span> <span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
@@ -41,14 +45,14 @@
</div> </div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0"> <div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div> <div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. 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.</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. 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.</span>
<dc-import name="CodeSnippet" tabs="{{ authSnips }}" hint-size="100%,160px"></dc-import> <dc-import name="CodeSnippet" tabs="{{ authSnips }}" hint-size="100%,160px"></dc-import>
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span> <span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a> <a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
</div> </div>
</div> </div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"> <div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. 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.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. 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. Owner and admin routes answer 403 to a key whose creator is neither.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
</div> </div>
</section> </section>
@@ -57,10 +61,15 @@
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px"> <section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div> <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
<sc-if value="{{ g.hasNote }}"><div style="display:flex;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty"><sc-for list="{{ g.noteParts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-if>
<sc-if value="{{ g.hasFt }}"><div data-anchor="{{ g.ftA }}" style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Field types</span><div style="border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Type</span><span>Value in an entry</span><sc-if value="{{ desk }}"><span>Rules</span></sc-if></div>
<sc-for list="{{ g.ftRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.t }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.v }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><span style="grid-column:{{ g.ftRule }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.r }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-for></div></div></sc-if>
<sc-if value="{{ g.hasSnip }}"><dc-import name="CodeSnippet" tabs="{{ g.snip }}" hint-size="100%,160px"></dc-import></sc-if>
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)"> <div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6"> <sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)"> <div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"> <button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }};outline:{{ ep.ring }};outline-offset:-2px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span> <span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span> <span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span> <span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
@@ -73,9 +82,9 @@
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div> <div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if> <sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div> <sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" hint-size="100%,200px"></dc-import> <dc-import name="CodeSnippet" tabs="{{ ep.snips }}" active="{{ ep.tabSet }}" hint-size="100%,200px"></dc-import>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span> <div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code></span></sc-if></div></sc-for></div> <sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code><sc-if value="{{ e.hasTail }}"><span style="margin-left:6px">{{ e.tail }}</span></sc-if></span></sc-if></div></sc-for></div>
</div> </div>
</sc-if> </sc-if>
</div> </div>
@@ -85,7 +94,7 @@
</sc-for> </sc-for>
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px"> <section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div> <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">{{ sessCount }}</span></div>
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)"> <div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div> <div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3"> <sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
@@ -104,7 +113,7 @@
<span style="color:var(--ui-text-toned);text-wrap:pretty">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.</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">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.</span>
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden"> <div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div> <div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span><sc-if value="{{ r.n }}"><span style="grid-column:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for> <sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.parts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><sc-if value="{{ r.n }}"><span style="grid-column:1 / -1;padding-top:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty"><sc-for list="{{ r.nparts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></sc-if></div></sc-for>
</div> </div>
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import> <dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
</section> </section>
@@ -130,11 +139,16 @@
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script> <script type="text/x-dc" data-dc-script>
class Component extends DCLogic { class Component extends DCLogic {
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })() state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: this.grpOf(i.scroll || 'auth'), authTab: i.authTab || 'curl', flash: i.flash || null } })()
rootRef = React.createRef() rootRef = React.createRef()
BASE = 'https://cms.renew.example' BASE = 'https://cms.renew.example'
KEY = 'rcms_Kf3x9QwL0aBc…' KEY = 'rcms_Kf3x9QwL0aBc…'
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']] IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['ctypes', 'Content types'], ['entries', 'Entries'], ['builtin', 'Built-in types'], ['media', 'Media'], ['sites', 'Sites and authors'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
grpOf(s) {
if (s.indexOf('ep-') !== 0 && s !== 'ct-fields') return s
const id = s.replace('ep-', ''), M = [['ct-', 'ctypes'], ['en-', 'entries'], ['bt-', 'builtin'], ['med-', 'media'], ['site-', 'sites'], ['author-', 'sites'], ['art-', 'articles'], ['seo-', 'seo'], ['sitemap', 'seo'], ['usr-', 'users'], ['auth-', 'users'], ['set-', 'settings']]
const m = M.find(x => id.indexOf(x[0]) === 0); return m ? m[1] : 'auth'
}
componentDidMount() { componentDidMount() {
const i = (this.props.s && this.props.s.int) || {} const i = (this.props.s && this.props.s.int) || {}
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) } const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
@@ -150,8 +164,8 @@ class Component extends DCLogic {
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) } yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
onScroll = () => { onScroll = () => {
const r = this.rootRef.current; if (!r) return const r = this.rootRef.current; if (!r) return
let cur = this.IDX[0][0] let cur = this.IDX[0][0], best = -1
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k } for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (!el) continue; const y = this.yOf(el, r); if (y <= r.scrollTop + 120 && y > best) { best = y; cur = k } }
if (cur !== this.state.active) this.setState({ active: cur }) if (cur !== this.state.active) this.setState({ active: cur })
} }
flash(k) { flash(k) {
@@ -177,9 +191,19 @@ class Component extends DCLogic {
const i404 = [404, 'Invite not found'] const i404 = [404, 'Invite not found']
const mm = 'The request cannot be processed due to a mismatch in the format of the body.' const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
const j = o => JSON.stringify(o, null, 2) const j = o => JSON.stringify(o, null, 2)
const eAd = [403, 'missing_permission', true], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
const Pp = s => s.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t)
const F = (key, label, type, o = {}) => ({ key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, relation: null, ...o })
const evF = [F('title', 'Title', 'text', { required: true, min: 3, max: 120 }), F('starts_on', 'Starts on', 'date', { required: true }), F('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }), F('online', 'Online', 'boolean'), F('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }), F('cover', 'Cover', 'media')]
const evT = { id: 2, key: 'events', name: 'Events', icon: 'calendar', fields: evF, created_at: '2026-09-02T08:30:00Z', updated_at: '2026-10-20T14:05:00Z' }
const evD = { title: 'Open day', starts_on: '2026-11-14', capacity: 120, online: false, category: 'Open day', cover: 4 }
const ent = { id: 31, content_type: 'events', data: evD, created_by: 1, updated_by: 1, created_at: '2026-10-20T14:05:00Z', updated_at: '2026-10-22T09:41:00Z', media: { cover: { id: 4, filename: 'north-wall.jpg', width: 1600, height: 1067, size: 3900000, thumbnail_url: '/static/uploadedImages/3f9c-thumb.jpg' } }, relations: {} }
const FT = [['text', 'string', 'min/max = length in characters. Can be unique.'], ['richtext', 'string (Markdown)', 'min/max = length. Cannot be unique.'], ['number', 'number', 'min/max = value. Can be unique.'], ['boolean', 'true/false', 'No min/max. Cannot be unique.'], ['date', 'string "YYYY-MM-DD"', 'min/max = earliest/latest date. Can be unique.'], ['select', 'one string from `options` (1 to 50 choices)', 'Can be unique.'], ['media', 'the numeric id of an uploaded image (see Media)', '\u2014'], ['relation', 'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many', '\u2014']]
const btA = [F('title', 'Title', 'text', { required: true, min: 3, max: 200 }), F('slug', 'Slug', 'text', { required: true, unique: true }), F('body', 'Body', 'richtext')], btU = [F('name', 'Name', 'text', { required: true, min: 1, max: 100 }), F('slug', 'Slug', 'text', { required: true, unique: true })]
const nameRule = 'Up to 100 characters.', fieldsRule = '1 to 50 fields, in order. See Field types.'
const G = { const G = {
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [ articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }), A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The body is an object: {items, total, page, size, counts}. counts holds the number of articles per tab and for suggestions.', 'Public', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns empty items.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j({ items: [{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }], total: 12, page: 1, size: 25, counts: { all: 12, online: 8, draft: 4, suggestions: 3 } }) }),
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }), A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }), A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'No', 'Any length.'), S('slug', 'string', 'No', 'Lowercase letters, numbers and hyphens. Unique on the article\'s site. 409 slug_taken if used.'), S('excerpt', 'string', 'No', 'Up to 300 characters. Also the article teaser.'), S('seo_title', 'string', 'No', 'Up to 120 characters. Falls back to the article title.'), S('seo_description', 'string', 'No', 'Up to 320 characters. Falls back to the excerpt, then the first sentence of the body.'), S('cover_image_id', 'integer', 'No', 'A media item. Falls back to the site social image.'), S('cover_image_alt', 'string', 'No', 'Up to 200 characters.'), S('social_title', 'string', 'No', 'Open Graph and Twitter title. Falls back to the resolved title.'), S('social_description', 'string', 'No', 'Falls back to the resolved description.'), S('social_image_id', 'integer', 'No', 'Falls back to the cover image.'), S('canonical_url', 'string', 'No', 'An absolute https URL. Default is the site base URL plus the slug.'), S('noindex', 'boolean', 'No', 'Sets robots to noindex in the SEO object. It doesn\'t change who can read the article.'), S('seo_dismissed', 'string[]', 'No', 'Suggestion codes the editor dismissed. Used by the back-office only.')], { body: { slug: 'bathroom-costs', seo_title: 'What a bathroom renovation costs' }, r: j({ id: 12, status: 'draft', slug: 'bathroom-costs' }) }), A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'No', 'Any length.'), S('slug', 'string', 'No', 'Lowercase letters, numbers and hyphens. Unique on the article\'s site. 409 slug_taken if used.'), S('excerpt', 'string', 'No', 'Up to 300 characters. Also the article teaser.'), S('seo_title', 'string', 'No', 'Up to 120 characters. Falls back to the article title.'), S('seo_description', 'string', 'No', 'Up to 320 characters. Falls back to the excerpt, then the first sentence of the body.'), S('cover_image_id', 'integer', 'No', 'A media item. Falls back to the site social image.'), S('cover_image_alt', 'string', 'No', 'Up to 200 characters.'), S('social_title', 'string', 'No', 'Open Graph and Twitter title. Falls back to the resolved title.'), S('social_description', 'string', 'No', 'Falls back to the resolved description.'), S('social_image_id', 'integer', 'No', 'Falls back to the cover image.'), S('canonical_url', 'string', 'No', 'An absolute https URL. Default is the site base URL plus the slug.'), S('noindex', 'boolean', 'No', 'Sets robots to noindex in the SEO object. It doesn\'t change who can read the article.'), S('seo_dismissed', 'string[]', 'No', 'Suggestion codes the editor dismissed. Used by the back-office only.')], { body: { slug: 'bathroom-costs', seo_title: 'What a bathroom renovation costs' }, r: j({ id: 12, status: 'draft', slug: 'bathroom-costs' }) }),
@@ -191,13 +215,39 @@ class Component extends DCLogic {
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }), A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) }) A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
] }, ] },
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
A('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', 1, [e401], [], { r: j([{ ...evT, fields: evF.slice(0, 3) }]) }),
A('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', 1, [e401, ctnf], [], { r: j(evT) }),
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".', 'Needs permission', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
] },
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. Writing entries needs the entry permissions for that type.', note: 'An entry\'s `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).', snip: [{ k: 'entry', label: 'Entry', code: j(ent) }], eps: [
A('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', 1, [[400, 'Invalid page, size or sort.'], e401, ctnf], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.'), S('sort', 'string', 'No', 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.')], { qp: 1, qs: '?page=1&size=25&sort=updated_at:desc', r: j({ items: [ent], total: 1, page: 1, size: 25 }) }),
A('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover'], [[400, mm], e401, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: evD }, r: j(ent) }),
A('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', 1, [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], e401, enf, ctnf], [], { r: j(ent) }),
A('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', 1, [[400, mm], e401, enf, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: { ...evD, capacity: 150 } }, r: j({ ...ent, data: { ...evD, capacity: 150 } }) }),
A('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', 1, [e401, enf, ctnf], [], { r: '204 No Content' })
] },
builtin: { title: 'Built-in types', desc: 'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.', note: 'Built-in types are read-only: they cannot be changed through the API.', eps: [
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
] },
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [ media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }), A('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }), A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }), A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }), A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }), A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }) A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images. Needs the permission "Delete files".', 'Needs permission', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
] },
sites: { title: 'Sites and authors', desc: 'Read the sites and authors your content belongs to. Creating and changing them needs a session.', eps: [
A('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', 1, [e401], [], { r: j([{ id: 1, key: 'renew', name: 'Renew', default: true }]) }),
A('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', 1, [[400, mm], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j({ items: [{ id: 3, name: 'In\u00e8s Duarte', slug: 'ines-duarte' }], total: 1, page: 1, size: 25 }) })
] }, ] },
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [ users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }), A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
@@ -205,41 +255,42 @@ class Component extends DCLogic {
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }), A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }), A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }), A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
A('usr-del', 'DELETE', '/v1/user/{id}?expected_role_id=N', 'Remove a user. expected_role_id is the role the user had when you loaded them; if it changed since, nothing is removed.', 'Needs permission', 1, [e401, eAd, [409, 'target_role_not_found']], [S('expected_role_id', 'integer', 'Yes', 'Query parameter. The id of the role you saw.')], { qp: 1, r: '204 No Content' }),
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) }) A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
] }, ] },
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [ settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) }) A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
] } ] }
} }
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] } const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const mob = !!this.props.mobile const mob = !!this.props.mobile
return Object.keys(G).map(key => { return Object.keys(G).map(key => {
const g = G[key] const g = G[key]
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => { return { key, title: g.title, desc: g.desc, hasNote: !!g.note, noteParts: g.note ? Pp(g.note) : [], hasFt: !!g.ft, ftA: g.ft ? 'ct-fields' : '', ftCols: mob ? '64px minmax(0,1fr)' : '88px minmax(0,1.3fr) minmax(0,1fr)', ftRule: mob ? '2' : 'auto', ftRows: g.ft ? FT.map(r => ({ t: r[0], v: Pp(r[1]), r: Pp(r[2]) })) : [], hasSnip: !!g.snip, snip: g.snip || [], count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12') const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{key}', e.p.indexOf('/v1/content-type') === 0 ? 'events' : e.p.indexOf('/v1/builtin-type') === 0 ? 'author' : 'renew').replace('{id}', '12') + (e.qs || '')
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY] const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"' let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
hdr.forEach(h => curl += ' \\\n -H "' + h + '"') hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\'' if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + (e.pretty ? JSON.stringify(e.body, null, 2) : JSON.stringify(e.body)) + '\''
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"') if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'" let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
const opts = [] const opts = []
if (e.m !== 'GET') opts.push("method: '" + e.m + "'") if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }') if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')') if (e.body) opts.push('body: JSON.stringify(' + (e.pretty ? JSON.stringify(e.body, null, 2).replace(/\n/g, '\n ') : JSON.stringify(e.body)) + ')')
if (e.form) opts.push('body: form') if (e.form) opts.push('body: form')
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')' fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON' const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
const cp = this.state.copied === e.id const cp = this.state.copied === e.id
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o }) const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })] const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Needs permission' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips, return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })), toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields, bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }), tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }], snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }],
errs: e.errs.map(([c, cap, code]) => ({ c, cap, code: !!code, plain: !code })) } errs: e.errs.map(([c, cap, code, tail]) => ({ c, cap, code: !!code, plain: !code, hasTail: !!tail, tail })), tabSet: this.state.tab[e.id] }
}) } }) }
}) })
} }
@@ -255,8 +306,11 @@ class Component extends DCLogic {
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } }) const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) }) const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
const D = (a) => 'rowDisplay' const D = (a) => 'rowDisplay'
const sessGroups = [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']]), sg('Sites and authors writes', [['POST', '/v1/site/', 'Create a site'], ['PATCH', '/v1/site/{id}', 'Update a site'], ['PATCH', '/v1/site/{id}/default', 'Set the default site'], ['DELETE', '/v1/site/{id}', 'Delete a site (owner)'], ['POST', '/v1/author/me', 'Create my author profile'], ['POST', '/v1/author/', 'Create an author'], ['PATCH', '/v1/author/{id}', 'Update an author'], ['DELETE', '/v1/author/{id}', 'Delete an author']]), sg('Image variants', [['GET', '/v1/image-variants/', 'List variants'], ['POST', '/v1/image-variants/', 'Create a variant'], ['PUT', '/v1/image-variants/{name}', 'Replace a variant'], ['PATCH', '/v1/image-variants/{name}', 'Update a variant'], ['DELETE', '/v1/image-variants/{name}', 'Delete a variant'], ['POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'], ['GET', '/v1/image-variants/cache', 'Image cache status'], ['POST', '/v1/image-variants/cache/purge', 'Purge the image cache'], ['GET', '/v1/media/{id}/variants', 'List the variants of an image'], ['POST', '/v1/media/{id}/variants', 'Add variants to an image'], ['POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'], ['DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image']]), sg('Activity', [['GET', '/v1/activity/', 'List activity'], ['GET', '/v1/activity/summary', 'Activity summary'], ['GET', '/v1/activity/export', 'Export activity']])]
const sessN = sessGroups.reduce((n, g) => n + g.rows.length, 0)
return { return {
rootRef: this.rootRef, onScroll: this.onScroll, imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })), sessGroups, sessCount: sessN + ' routes, not available to API keys',
rootRef: this.rootRef, onScroll: this.onScroll, trigRing: ((this.props.s && this.props.s.int) || {}).trig === 'focus' ? '2px solid var(--ui-primary)' : 'none', imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index, activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px', pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)', authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
@@ -265,17 +319,18 @@ class Component extends DCLogic {
groups: this.eps(), groups: this.eps(),
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap', rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto', fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center', sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3', errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
errRows: [ errRows: [
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' }, { c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' }, { c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
{ c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' }, { c: 403, m: 'Forbidden', n: '`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.', w: '"This action needs a real session." (a session-only route called with a key), `missing_permission` (the role of the user, or of the key\'s creator, lacks the permission the route needs) or "You must verify your account to access this resource."' },
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' }, { c: 404, m: 'Not found', n: 'Text in code style is the literal body of the response, not a sentence.', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Content types and entries answer `content_type_not_found` or `entry_not_found`. An unknown site key on the sitemap answers `site_not_found`. Drafts answer 404 to anonymous callers.' },
{ c: 409, m: 'Conflict', w: 'Content types: `content_type_key_taken`, `content_type_has_entries`, `content_type_referenced` or `schema_incompatible`. Entries: \`invalid_entry: <reason>\`, only when a unique field clashes. Media and users: "Some of the images to merge changed. Refresh and try again." or "This address already has an account." Articles: `slug_taken`. Removing a user whose role changed first: `target_role_not_found`.' },
{ c: 422, m: 'Unprocessable', w: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.' },
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' }, { c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
{ c: 500, m: 'Server error', w: '"Internal server error"' } { c: 500, m: 'Server error', w: '"Internal server error"' }
], ].map(r => ({ ...r, parts: r.w.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t), nparts: r.n ? r.n.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t) : [] })),
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.' errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
} }
} }
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;justify-content:center;align-items:flex-start;padding:{{ shellPad }};font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;color:var(--ui-text)">
<div role="dialog" aria-modal="true" aria-label="Search the API reference" style="width:{{ w }};height:{{ h }};max-height:100%;background:var(--ui-bg);border:{{ bd }};box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;min-height:0">
<div style="display:flex;align-items:center;gap:10px;height:{{ inH }};padding:0 {{ inPx }};border-bottom:1px solid var(--ui-border);box-shadow:inset 0 0 0 2px var(--ui-primary);flex-shrink:0">
<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span>
<input role="combobox" aria-expanded="true" aria-controls="rc-sr-list" aria-autocomplete="list" aria-activedescendant="{{ activeId }}" aria-label="Search the API reference" placeholder="Search the API reference…" value="{{ q }}" onChange="{{ onInput }}" onKeyDown="{{ onKey }}" autocomplete="off" spellcheck="false" style="all:unset;box-sizing:border-box;flex:1;min-width:0;height:100%;font:400 {{ inFs }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)"/>
<sc-if value="{{ desk }}"><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span></sc-if>
<sc-if value="{{ mob }}"><button aria-label="Close search" onClick="{{ onClose }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 8px;margin-right:-8px;display:inline-flex;align-items:center;font-weight:500;font-size:14px;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">Cancel</button></sc-if>
</div>
<span role="status" aria-live="polite" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">{{ live }}</span>
<div id="rc-sr-list" role="listbox" aria-label="Search results" style="flex:1;min-height:0;max-height:{{ listMax }};overflow-y:auto;padding:4px 0;scrollbar-width:thin">
<sc-if value="{{ none }}"><div style="padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search-x.svg') center / contain no-repeat"></span><span style="max-width:360px;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ noneText }}</span></div></sc-if>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="2">
<div role="group" aria-labelledby="{{ g.hid }}">
<div id="{{ g.hid }}" style="padding:8px {{ rowPx }} 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ g.title }}</div>
<sc-for list="{{ g.items }}" as="it" hint-placeholder-count="4">
<div id="{{ it.id }}" role="option" aria-selected="{{ it.sel }}" aria-label="{{ it.label }}" onClick="{{ it.go }}" style="cursor:pointer;display:grid;grid-template-columns:{{ cols }};align-items:start;gap:{{ colGap }};padding:{{ rowPad }};min-height:{{ rowH }};background:{{ it.bg }};box-shadow:{{ it.bar }}" style-hover="background:var(--ui-bg-elevated)">
<sc-if value="{{ it.isEp }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:{{ it.mBg }};color:{{ it.mFg }};border:1px {{ it.mBs }} {{ it.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ it.m }}</span></sc-if>
<sc-if value="{{ it.isSec }}"><span style="width:56px;height:22px;display:inline-flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ it.icon }}.svg') center / contain no-repeat"></span></span></sc-if>
<div style="min-width:0;display:flex;flex-direction:column;gap:2px">
<sc-if value="{{ it.isEp }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:{{ pathShrink }};min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:{{ it.pFw }} 13px 'IBM Plex Mono',monospace;color:{{ it.pFg }}"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></sc-if></div></sc-if>
<sc-if value="{{ it.isSec }}"><div style="display:flex;align-items:center;gap:12px;min-height:22px;min-width:0"><span style="flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)"><sc-for list="{{ it.pSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span><sc-if value="{{ desk }}"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-muted)">{{ it.desc }}</span></sc-if></div></sc-if>
<sc-if value="{{ mob }}"><div style="display:flex;align-items:center;gap:8px;min-width:0"><sc-if value="{{ it.hasChip }}"><span style="flex-shrink:0;height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ it.dFg }}"><sc-if value="{{ it.isEp }}"><sc-for list="{{ it.dSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></sc-if><sc-if value="{{ it.isSec }}">{{ it.desc }}</sc-if></span></div></sc-if>
<sc-if value="{{ it.via }}"><div style="display:flex;align-items:baseline;gap:8px;min-width:0;font-size:12px;line-height:18px;color:var(--ui-text-muted)"><span style="flex-shrink:0">{{ it.viaLabel }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><sc-for list="{{ it.viaSeg }}" as="s" hint-placeholder-count="1"><span style="background:{{ s.bg }};color:{{ s.fg }}">{{ s.t }}</span></sc-for></span></div></sc-if>
</div>
<sc-if value="{{ desk }}"><sc-if value="{{ it.hasChip }}"><span style="height:20px;margin-top:1px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ it.cBg }};color:{{ it.cFg }};border:1px {{ it.cBs }} {{ it.cBd }};font-size:12px;font-weight:{{ it.cFw }}"><sc-if value="{{ it.sess }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></sc-if>{{ it.chip }}</span></sc-if></sc-if>
</div>
</sc-for>
</div>
</sc-for>
<sc-if value="{{ more }}"><div style="padding:8px {{ rowPx }};color:var(--ui-text-muted)">{{ moreText }}</div></sc-if>
</div>
<div style="flex-shrink:0;display:flex;align-items:center;gap:16px;height:{{ ftH }};padding:0 {{ inPx }};border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-muted)">
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↑</span><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↓</span>Move</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 4px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">↵</span>Open</span>
<span aria-hidden="true" style="display:inline-flex;align-items:center;gap:6px"><span style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">Esc</span>Close</span></sc-if>
<span style="flex:1"></span><span aria-hidden="true">{{ count }}</span>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900}}">
class Component extends DCLogic {
state = { q: (this.props.s && this.props.s.q) || '', sel: (this.props.s && this.props.s.sel) || 0 }
L = 'You must be logged in to access this resource.'
V = 'You must verify your account to access this resource.'
MM = 'The request cannot be processed due to a mismatch in the format of the body.'
data() {
const L = this.L, V = this.V, MM = this.MM, SS = 'This action needs a real session.'
const E = (id, m, p, d, acc, f, e, x = {}) => ({ id, m, p, d, acc, f, e: acc === 'Verified user' ? e.flatMap(r => r[0] === 401 ? [r, [403, V]] : [r]) : e, ...x })
const S = (id, m, p, d) => ({ id, m, p, d, acc: 'Session only', f: [], e: [[403, SS]], sess: 1 })
const a404 = [404, 'Article not found'], i404 = [404, 'Image not found'], eAd = [403, 'Your role doesn\'t include this action. Ask an owner or admin.'], ctnf = [404, 'content_type_not_found'], enf = [404, 'entry_not_found']
const uq = [409, 'invalid_entry: title must be unique'], ie = [422, 'invalid_entry: capacity must be at most 5000'], evf = ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover']
const eps = [
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]),
E('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', ['title', 'body'], [[400, MM], [401, L]]),
E('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', ['body', 'slug', 'excerpt', 'seo_title', 'seo_description', 'cover_image_id', 'cover_image_alt', 'social_title', 'social_description', 'social_image_id', 'canonical_url', 'noindex', 'seo_dismissed'], [[400, MM], [401, L], a404, [409, 'slug_taken']]),
E('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', [], [[401, L], a404]),
E('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', [], [[401, L], a404]),
E('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', [], [[401, L], a404]),
E('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article.', 'Public', [], [[401, L], a404]),
E('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site.', 'Public', ['page', 'size'], [[400, MM], [401, L], [404, 'site_not_found']], { qp: 1 }),
E('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', [], [[401, L]]),
E('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', [], [[401, L], ctnf]),
E('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'schema.edit', ['key', 'name', 'icon', 'fields'], [[400, MM], [401, L], eAd, [409, 'content_type_key_taken']]),
E('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'schema.edit', ['name', 'icon', 'fields'], [[400, MM], [401, L], eAd, ctnf, [409, 'schema_incompatible Some existing entries don\'t fit the new fields. Nothing was changed.']]),
E('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'schema.delete', [], [[401, L], eAd, ctnf, [409, 'content_type_has_entries'], [409, 'content_type_referenced']]),
E('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', ['page', 'size', 'sort'], [[400, 'Invalid page, size or sort.'], [401, L], ctnf], { qp: 1 }),
E('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', evf, [[400, MM], [401, L], ctnf, uq, ie]),
E('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', [], [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], [401, L], enf, ctnf]),
E('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', evf, [[400, MM], [401, L], enf, ctnf, uq, ie]),
E('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', [], [[401, L], enf, ctnf]),
E('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd]),
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'schema.view', [], [[401, L], eAd, [404, 'builtin_type_not_found']]),
E('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]),
E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]),
E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]),
E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]),
E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]),
E('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Needs permission', [], [[401, L], eAd]),
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Needs permission', ['auto_purge_enabled', 'auto_purge_interval_days'], [[400, MM], [401, L], eAd]),
E('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Needs permission', [], [[401, L], eAd]),
E('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Needs permission', ['fingerprint'], [[401, L], eAd]),
E('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', [], [[401, L]]),
E('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', ['page', 'size'], [[400, MM], [401, L]], { qp: 1 }),
E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]),
E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]),
E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]),
E('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', [], [[401, L], [404, 'Invite not found']]),
E('auth-me', 'GET', '/v1/auth/me', 'Get the current user.', 'Verified user', [], [[401, L]]),
E('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', [], [[401, L]]),
S('s-login', 'POST', '/v1/auth/login', 'Log in'), S('s-logout', 'POST', '/v1/auth/logout', 'Log out'), S('s-reg', 'POST', '/v1/auth/register', 'Register'), S('s-verify', 'POST', '/v1/auth/verify', 'Verify email'), S('s-reset', 'POST', '/v1/auth/password-reset', 'Reset password'),
S('s-keys', 'GET', '/v1/api-key', 'List keys'), S('s-kcreate', 'POST', '/v1/api-key', 'Create a key'), S('s-krev', 'DELETE', '/v1/api-key/{id}', 'Revoke a key'),
S('s-site-c', 'POST', '/v1/site/', 'Create a site'), S('s-site-u', 'PATCH', '/v1/site/{id}', 'Update a site'), S('s-site-d', 'PATCH', '/v1/site/{id}/default', 'Set the default site'), S('s-site-x', 'DELETE', '/v1/site/{id}', 'Delete a site (owner)'),
S('s-au-me', 'POST', '/v1/author/me', 'Create my author profile'), S('s-au-c', 'POST', '/v1/author/', 'Create an author'), S('s-au-u', 'PATCH', '/v1/author/{id}', 'Update an author'), S('s-au-x', 'DELETE', '/v1/author/{id}', 'Delete an author'),
S('s-iv-l', 'GET', '/v1/image-variants/', 'List variants'), S('s-iv-c', 'POST', '/v1/image-variants/', 'Create a variant'), S('s-iv-p', 'PUT', '/v1/image-variants/{name}', 'Replace a variant'), S('s-iv-pa', 'PATCH', '/v1/image-variants/{name}', 'Update a variant'), S('s-iv-d', 'DELETE', '/v1/image-variants/{name}', 'Delete a variant'), S('s-iv-pu', 'POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'), S('s-iv-ca', 'GET', '/v1/image-variants/cache', 'Image cache status'), S('s-iv-cp', 'POST', '/v1/image-variants/cache/purge', 'Purge the image cache'),
S('s-mv-l', 'GET', '/v1/media/{id}/variants', 'List the variants of an image'), S('s-mv-c', 'POST', '/v1/media/{id}/variants', 'Add variants to an image'), S('s-mv-g', 'POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'), S('s-mv-d', 'DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image'),
S('s-ac-l', 'GET', '/v1/activity/', 'List activity'), S('s-ac-s', 'GET', '/v1/activity/summary', 'Activity summary'), S('s-ac-e', 'GET', '/v1/activity/export', 'Export activity'),
S('s-upd', 'GET', '/v1/system/update', 'Update status'), S('s-chk', 'POST', '/v1/system/update/check', 'Check for update'), S('s-dl', 'POST', '/v1/system/update/download', 'Download update'), S('s-rst', 'POST', '/v1/system/update/restart', 'Restart')
]
const secs = [
['auth', 'Authentication', 'Public access and API keys', 'bearer api key rcms rate limit 429 base url public hash'],
['articles', 'Articles', '7 routes', ''], ['seo', 'Search and sharing', '2 routes', 'seo sitemap'], ['ctypes', 'Content types', '5 routes', 'custom fields field types schema'], ['entries', 'Entries', '5 routes', 'content type custom data'], ['builtin', 'Built-in types', '2 routes', 'builtin fields schema'], ['media', 'Media', '10 routes', 'purge'], ['sites', 'Sites and authors', '2 routes', 'site author'],
['images', 'Images and variants', 'The ?v= parameter', 'variant image width height format quality'],
['users', 'Users and invites', '6 routes', ''], ['settings', 'Settings', '1 route', ''],
['session', 'Session only', '35 routes, not available to API keys', 'login log in register password real session'],
['errors', 'Errors', 'Status codes and messages', '400 401 403 404 409 422 429 500 status error plain text'],
['notes', 'Notes', 'Images, CORS and ?format=', 'cors origins format markdown html json body_format']
]
return { eps, secs }
}
has(s, t) { return s.includes(t) || (t === 'entry' && s.includes('entries')) }
hl(t, toks) {
if (!toks.length) return [{ t, bg: 'transparent', fg: 'inherit' }]
const re = new RegExp('(' + toks.map(x => x === 'entry' ? 'entr(?:y|ies)' : x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig')
return t.split(re).filter(x => x !== '').map(x => { const on = toks.some(k => k === x.toLowerCase() || (k === 'entry' && x.toLowerCase() === 'entries')); return { t: x, bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'inherit' } })
}
compute() {
const { eps, secs } = this.data(), q = this.state.q.trim().toLowerCase(), toks = q ? q.split(/\s+/) : []
const byId = id => eps.find(e => e.id === id), bySec = k => secs.find(s => s[0] === k)
let ep = [], sc = [], title = 'Endpoints'
if (!toks.length) { ep = ['art-list', 'med-up', 'usr-me'].map(byId).map(e => ({ e })); sc = ['auth', 'errors'].map(bySec); title = null }
else {
eps.forEach(e => {
const hayP = (e.m + ' ' + e.p + ' ' + e.d + ' ' + e.acc).toLowerCase()
let via = null, ok = true
toks.forEach(t => {
if (this.has(hayP, t)) return
const f = e.f.find(x => this.has(x.toLowerCase(), t))
if (f) { via = via || { label: e.qp ? 'Query parameter' : 'Body field', text: f }; return }
const r = e.e.find(([c, m]) => this.has((c + ' ' + m).toLowerCase(), t))
if (r) { via = via || { label: 'Error', text: r[0] + ' ' + r[1] }; return }
ok = false
})
if (ok) ep.push({ e, via })
})
ep.sort((a, b) => (a.via ? 1 : 0) - (b.via ? 1 : 0))
sc = secs.filter(s => toks.every(t => this.has((s[1] + ' ' + s[2] + ' ' + s[3]).toLowerCase(), t)))
}
return { ep, sc, toks, title }
}
renderVals() {
const mob = !!this.props.mobile, st = this.state, { ep, sc, toks, title } = this.compute()
const CAP = 6, shown = ep.slice(0, CAP), total = ep.length + sc.length
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const sel = total ? Math.min(st.sel, shown.length + sc.length - 1) : 0
let idx = 0
const mkItem = (o) => o
const epItems = shown.map(({ e, via }) => { const s = !!e.sess, mc = MB[e.m]; return mkItem({
isEp: true, isSec: false, sess: s, m: e.m, mBg: s ? 'transparent' : mc[0], mFg: s ? 'var(--ui-text-muted)' : mc[1], mBd: s ? 'var(--ui-border-accented)' : mc[2], mBs: s ? 'dashed' : 'solid',
pSeg: this.hl(e.p, toks), pFw: s ? 400 : 500, pFg: s ? 'var(--ui-text-toned)' : 'var(--ui-text-highlighted)', dSeg: this.hl(e.d, toks), dFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)',
hasChip: true, chip: e.acc, cBg: 'transparent', cFg: s ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', cBd: 'var(--ui-border-accented)', cBs: s ? 'dashed' : 'solid', cFw: 400,
via: !!via, viaLabel: via ? via.label : '', viaSeg: via ? this.hl(via.text, toks) : [], label: e.m + ' ' + e.p + '. ' + e.d + ' ' + e.acc })
})
const secItems = sc.map(s => mkItem({ isEp: false, isSec: true, icon: s[0] === 'session' ? 'lock' : 'hash', pSeg: this.hl(s[1], toks), desc: s[2], hasChip: false, via: false, dSeg: [], dFg: 'var(--ui-text-muted)', label: 'Section ' + s[1] + '. ' + s[2] }))
const groups = []
if (epItems.length) groups.push({ hid: 'rc-sr-g0', title: title || 'Suggested', items: epItems })
if (secItems.length) groups.push({ hid: 'rc-sr-g1', title: title ? 'Sections' : 'Suggested', items: secItems })
if (!title) { const all = [...epItems, ...secItems]; const ord = [secItems[0], epItems[1], secItems[1], epItems[0], epItems[2]]; groups.length = 0; groups.push({ hid: 'rc-sr-g0', title: 'Suggested', items: ord }) }
const none = !!toks.length && total === 0
const more = ep.length > CAP
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const live = none ? 'No results' : !toks.length ? '5 suggestions' : plural(total, 'result') + (more ? ', showing ' + (shown.length + sc.length) : '')
const activeId = (!none && total) ? 'rc-sr-' + sel : undefined
const flat = groups.flatMap(g => g.items)
flat.forEach((it, i) => { const on = i === sel; Object.assign(it, { id: 'rc-sr-' + i, sel: on, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', bar: on ? 'inset 2px 0 0 var(--ui-primary)' : 'none', go: () => this.setState({ sel: i }) }) })
return {
groups, none, noneText: 'No match for "' + this.state.q.trim() + '". Try a path, a method or a status code like 404.', more, moreText: (ep.length - CAP) + ' more endpoint' + (ep.length - CAP === 1 ? '' : 's') + '. Keep typing to narrow the list.',
live, count: none ? '0 results' : !toks.length ? '5 suggestions' : plural(total, 'result'), activeId, q: st.q,
desk: !mob, mob, w: mob ? '100%' : '720px', h: mob ? '100%' : 'auto', bd: mob ? '0' : '1px solid var(--ui-border)', shellPad: mob ? '0' : '96px 24px 24px',
inH: mob ? '56px' : '52px', inPx: mob ? '16px' : '16px', inFs: mob ? '16px' : '15px', ftH: mob ? '40px' : '40px', listMax: mob ? 'none' : '440px', rowPx: mob ? '16px' : '16px',
cols: mob ? '56px minmax(0,1fr)' : '56px minmax(0,1fr) auto', colGap: '12px', rowPad: mob ? '10px 16px' : '8px 16px', rowH: mob ? '56px' : '40px', pathShrink: mob ? 1 : 0,
onInput: e => this.setState({ q: e.target.value, sel: 0 }),
onClose: () => this.props.onClose && this.props.onClose(),
onKey: e => {
const n = flat.length
if (e.key === 'ArrowDown') { e.preventDefault(); this.setState({ sel: n ? (sel + 1) % n : 0 }) }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState({ sel: n ? (sel - 1 + n) % n : 0 }) }
else if (e.key === 'Enter' && n) { e.preventDefault(); this.props.onOpen && this.props.onOpen(flat[sel].id) }
else if (e.key === 'Escape') { this.props.onClose && this.props.onClose() }
}
}
}
}
</script>
</body>
</html>
+10 -7
View File
@@ -29,7 +29,7 @@ class Component extends DCLogic {
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)'] const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
const mark = h('svg', { width: 28, height: 28, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' })) const mark = h('svg', { width: 28, height: 28, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const Th = (im, t = '', z = 24) => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', z, 'opacity:0.55')) const Th = (im, t = '', z = 24) => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', z, 'opacity:0.55'))
const BADGE = { unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], used: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], on: ['background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', 'globe'], draft: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'pencil-line'], vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'] } const BADGE = { unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], used: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)'], on: ['background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', 'globe'], draft: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)', 'pencil-line'], vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'] }
const varsBadge = im => im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars') const varsBadge = im => im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars')
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t) const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t)
const Cb = (on, z = 20, ind) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', (on || ind) && I(ind ? 'minus' : 'check', z - 6)) const Cb = (on, z = 20, ind) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on || ind ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', (on || ind) && I(ind ? 'minus' : 'check', z - 6))
@@ -54,16 +54,17 @@ class Component extends DCLogic {
const VC = { 1: 4, 2: 5, 3: 5, 4: 0, 5: 3, 6: 0, 7: 2, 8: 0, 9: 1, 10: 3, 11: 4, 12: 5 } const VC = { 1: 4, 2: 5, 3: 5, 4: 0, 5: 3, 6: 0, 7: 2, 8: 0, 9: 1, 10: 3, 11: 4, 12: 5 }
IM.forEach(x => { x.vc = VC[x.id] || 0 }) IM.forEach(x => { x.vc = VC[x.id] || 0 })
const ENT = { 10: 3, 11: 5 } const ENT = { 10: 3, 11: 5 }
IM.forEach(x => { x.ent = s.ent ? (ENT[x.id] || 0) : 0 }) IM.forEach(x => { x.ent = s.ent ? (ENT[x.id] || 0) : 0; x.ex = 0 })
const byId = id => IM.find(x => x.id === id) const byId = id => IM.find(x => x.id === id)
if (s.panel && s.panel.ext) byId(s.panel.id).ex = s.panel.ext === 'both' ? 2 : s.panel.ext === 'none' ? 0 : 1
const AR = [['How we price a bathroom renovation', 1], ['Winter checklist for timber windows', 0], ['Before and after: the Rue Verte kitchen', 1], ['Why we reuse original floorboards', 1], ['Choosing lime plaster for old stone walls', 0]] const AR = [['How we price a bathroom renovation', 1], ['Winter checklist for timber windows', 0], ['Before and after: the Rue Verte kitchen', 1], ['Why we reuse original floorboards', 1], ['Choosing lime plaster for old stone walls', 0]]
const usedList = n => AR.slice(0, Math.min(n, 5)) const usedList = n => AR.slice(0, Math.min(n, 5))
const ids = s.ids || IM.map(x => x.id) const ids = s.ids || IM.map(x => x.id)
const imgs = ids.map(byId) const imgs = ids.map(byId)
const sel = s.sel || [] const sel = s.sel || []
const pl = n => n + (n === 1 ? ' article' : ' articles') const pl = n => n + (n === 1 ? ' article' : ' articles')
const label = im => im.n + ', ' + im.sz + ', ' + (im.used + im.ent ? 'used in ' + [im.used && pl(im.used), im.ent && (im.ent + (im.ent === 1 ? ' entry' : ' entries'))].filter(Boolean).join(' and ') : 'unused') const label = im => im.n + ', ' + im.sz + ', ' + (im.used + im.ent + (im.ex || 0) ? 'used in ' + [im.used && pl(im.used), im.ent && (im.ent + (im.ent === 1 ? ' entry' : ' entries')), im.ex && (im.ex + ' other ' + (im.ex === 1 ? 'place' : 'places'))].filter(Boolean).join(' and ') : 'unused')
const usedBadge = im => { const n = im.used + im.ent, b = n ? Bd('Used in ' + n, 'used') : Bd('Unused', 'unused'); return s.tip === im.id ? S('position:relative;display:inline-flex', b, D('position:absolute;left:0;top:26px;z-index:10;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', im.used > 0 && D('', 'Used in articles: ' + im.used), D('', 'Used in entries: ' + im.ent))) : b } const usedBadge = im => { const n = im.used + im.ent + (im.ex || 0), b = n ? Bd('Used in ' + n, 'used') : Bd('Unused', 'unused'); return s.tip === im.id ? S('position:relative;display:inline-flex', b, D('position:absolute;left:0;top:26px;z-index:10;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', im.used > 0 && D('', 'Used in articles: ' + im.used), D('', 'Used in entries: ' + im.ent))) : b }
// ---------- Media page pieces // ---------- Media page pieces
const Card = im => { const Card = im => {
@@ -200,6 +201,7 @@ class Component extends DCLogic {
// ---------- detail panel // ---------- detail panel
const ENTL = [['Events', 'Lime plaster workshop'], ['Events', 'Heritage stone walk'], ['Events', 'Winter open day']] const ENTL = [['Events', 'Lime plaster workshop'], ['Events', 'Heritage stone walk'], ['Events', 'Winter open day']]
const EntBox = im => D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Used in entries'), D('border:1px solid var(--ui-border)', ...ENTL.map(([ty, tt], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('width:52px;flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', ty), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', tt), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))), im.ent > 3 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + (im.ent - 3) + ' more'))) const EntBox = im => D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Used in entries'), D('border:1px solid var(--ui-border)', ...ENTL.map(([ty, tt], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('width:52px;flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', ty), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', tt), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))), im.ent > 3 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + (im.ent - 3) + ' more')))
const ExtBox = ext => D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Used elsewhere'), D('border:1px solid var(--ui-border)', ...[['avatar', 'Author avatar', '/authors'], ['site', 'Site default social image', '/settings/sites']].filter(r => ext === 'both' || ext === r[0]).map(([k, t, href], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), S('font:400 12px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', href), I('arrow-up-right', 14, 'color:var(--ui-text-muted)')))))
const Panel = () => { const Panel = () => {
const im = byId(s.panel.id), list = usedList(im.used) const im = byId(s.panel.id), list = usedList(im.used)
const meta = [['Type', im.ty], ['Size', im.sz], ['Dimensions', im.dim], ['Uploaded', im.d], ['Uploaded by', im.by]] const meta = [['Type', im.ty], ['Size', im.sz], ['Dimensions', im.dim], ['Uploaded', im.d], ['Uploaded by', im.by]]
@@ -209,13 +211,14 @@ class Component extends DCLogic {
D('height:' + (m ? 56 : 56) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 4 : 12) + 'px;padding:0 ' + (m ? 6 : 20) + 'px;border-bottom:1px solid var(--ui-border)', m && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('arrow-left', 20)), S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), !m && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))), D('height:' + (m ? 56 : 56) + 'px;flex-shrink:0;display:flex;align-items:center;gap:' + (m ? 4 : 12) + 'px;padding:0 ' + (m ? 6 : 20) + 'px;border-bottom:1px solid var(--ui-border)', m && S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('arrow-left', 20)), S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), !m && S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px', D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
Th(im, 'aspect-ratio:' + (im.ent ? (m ? '16/3.6' : '16/6') : m ? '4/3' : '16/10') + ';border:1px solid var(--ui-border);flex-shrink:0', 40), Th(im, 'aspect-ratio:' + (im.ent ? (m ? '16/3.6' : '16/6') : m ? '4/3' : '16/10') + ';border:1px solid var(--ui-border);flex-shrink:0', 40),
D('display:flex', usedBadge(im)),
D('display:flex;gap:8px;' + (m ? 'flex-wrap:wrap' : ''), B('Copy URL', { icon: 'link', full: m }), B('Rename', { icon: 'pencil', full: m }), B('Delete', { k: 'del', icon: 'trash-2', full: m })), D('display:flex;gap:8px;' + (m ? 'flex-wrap:wrap' : ''), B('Copy URL', { icon: 'link', full: m }), B('Rename', { icon: 'pencil', full: m }), B('Delete', { k: 'del', icon: 'trash-2', full: m })),
im.dup && Alert('info', 'copy', 'Identical to 2 other images', 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', 'Review duplicates', { icon: 'arrow-right' }), im.dup && Alert('info', 'copy', 'Identical to 2 other images', 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', 'Review duplicates', { icon: 'arrow-right' }),
D('display:grid;grid-template-columns:110px minmax(0,1fr);gap:8px 12px;color:var(--ui-text-toned)', ...meta.flatMap(([k, v]) => [S('color:var(--ui-text-muted)', k), S('color:var(--ui-text-highlighted)', v)]), S('color:var(--ui-text-muted)', 'URL'), S('font:400 12px/20px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'cms.renew.example/media/' + im.n)), D('display:grid;grid-template-columns:110px minmax(0,1fr);gap:8px 12px;color:var(--ui-text-toned)', ...meta.flatMap(([k, v]) => [S('color:var(--ui-text-muted)', k), S('color:var(--ui-text-highlighted)', v)]), S('color:var(--ui-text-muted)', 'URL'), S('font:400 12px/20px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'cms.renew.example/media/' + im.n)),
D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', im.used ? 'Used in ' + pl(im.used) : 'Not used in any article'), D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', im.used ? 'Used in ' + pl(im.used) : (im.ent || im.ex) ? 'Articles' : 'Not used in any article'),
im.used ? D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on', 22) : Bd('Draft', 'draft', 22), I('arrow-up-right', 14, 'color:var(--ui-text-muted)')))) im.used ? D('border:1px solid var(--ui-border)', ...list.map(([t, on], i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)', t), on ? Bd('Online', 'on', 22) : Bd('Draft', 'draft', 22), I('arrow-up-right', 14, 'color:var(--ui-text-muted)'))))
: S('color:var(--ui-text-muted)', im.ent ? 'No article shows it.' : 'This image is safe to delete. No article shows it.'), : S('color:var(--ui-text-muted)', im.ent || im.ex ? 'No article shows it.' : 'This image is safe to delete. No article shows it.'),
im.used ? S('color:var(--ui-text-muted);font-size:12px', 'Counted from the text of your articles each time you open this page.') : null), im.ent > 0 && EntBox(im)))) im.used ? S('color:var(--ui-text-muted);font-size:12px', 'Counted from the text of your articles each time you open this page.') : null), im.ent > 0 && EntBox(im), im.ex > 0 && ExtBox(s.panel.ext))))
} }
// ---------- modal // ---------- modal
+7 -2
View File
@@ -38,9 +38,10 @@ class Component extends DCLogic {
fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v })) fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v }))
fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r })) fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r }))
fetch('emails/invitation.html').then(x => x.text()).then(i => this.setState({ i })) fetch('emails/invitation.html').then(x => x.text()).then(i => this.setState({ i }))
;['verify-new-email','email-change-notice','account-deleted','export-ready'].forEach(n => fetch('emails/' + n + '.html').then(x => x.text()).then(t => this.setState({ ['x_' + n]: t })))
} }
fill(t) { fill(t) {
return t.split('{{.InvitedBy}}').join('marta').split('{{.Email}} It').join('lea.martin@renew.example. It').split('sent to {{.Email}}').join('sent to lea.martin@renew.example').split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example') return t.split('{{.InvitedBy}}').join('marta').split('{{.Email}} It').join('lea.martin@renew.example. It').split('sent to {{.Email}}').join('sent to lea.martin@renew.example').split('{{.Username}}').join('marta').split('{{.NewEmail}}').join('marta.alves@atelier-renew.example').split('{{.DeletedAt}}').join('29 Oct 2026').split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example')
} }
frames(t) { frames(t) {
if (!t) return [] if (!t) return []
@@ -52,7 +53,11 @@ class Component extends DCLogic {
return { rows: [ return { rows: [
{ title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) }, { title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) },
{ title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) }, { title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) },
{ title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) } { title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) },
{ title: 'Verify new email', vars: '{{.Host}} · {{.Username}} · {{.VerificationCode}} → /account/email/confirm?c=', frames: this.frames(this.state['x_verify-new-email']) },
{ title: 'Email change notice (to the old address)', vars: '{{.Host}} · {{.Username}} · {{.NewEmail}}', frames: this.frames(this.state['x_email-change-notice']) },
{ title: 'Account deleted', vars: '{{.Host}} · {{.Username}} · {{.DeletedAt}}', frames: this.frames(this.state['x_account-deleted']) },
{ title: 'Export ready (alternative flow)', vars: '{{.Host}} · {{.Username}} · {{.VerificationCode}} → /account/export/', frames: this.frames(this.state['x_export-ready']) }
] } ] }
} }
} }
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<script src="./account-copy.js"></script>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: account (email, data, delete)</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Extends Profile with change email, export my data and delete account. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. </p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
</div>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
</div>
</sc-for>
</div>
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() {
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
return this.state.mode
}
apply() {
const el = this.rootRef.current
if (!el) return
const vars = this.resolved() === 'dark' ? this.dark : this.light
for (const k in vars) el.style.setProperty(k, vars[k])
document.body.style.background = vars['--rc-canvas']
}
componentDidMount() { this.apply() }
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() {
const C = (window.ACCOUNT_COPY || {}).en || {}
const nw = 'marta.alves@atelier-renew.example', old = 'marta@renew.example'
const fmt = t => (t || '').split('{new}').join(nw).split('{old}').join(old)
const toast = (tone, icon, t, b) => ({ tone, icon, title: C[t], text: fmt(C[b]) })
const S = (name, trigger, ac, extra = {}) => { const x = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', copy: C, name, trigger, ...extra, pf: { ac: { old, new: nw, ...ac }, shift: extra.shift || ES } }; return { ...x, m: { ...x } } }
const form = (o = {}) => ({ form: { email: nw, pw: 'limewash2026', focus: '', ...o } })
const pend = (o = {}) => ({ pending: { resend: 'idle', ...o } })
const D = (mode, state) => ({ data: { mode, state } })
const ES = [300, 330], DS = [780, 1100], ZS = [1000, 1500]
const del = (o = {}) => ({ title: C.delTitle, desc: C.delDesc, list: C.delList, warn: { tone: 'info', icon: 'info', title: C.delKeepT, text: C.delKeepB }, link: C.delExport, cancel: C.delKeep, confirm: C.delConfirm, tone: 'danger', disabled: true, close: true, ...o })
const pw = '••••••••••'
const dm = (fields, o = {}) => ({ modal: del({ fields, ...o }), shift: ZS })
const ff = (p, w, e = {}) => [{ label: C.delPw, value: p, ...(e.p || {}) }, { label: C.delWordLabel, value: w, ph: C.delWord, ...(e.w || {}) }]
const blk = (t, b, go) => ({ modal: { title: C[t], desc: C[b], cancel: C.blockBack, confirm: go ? C.blockGo : '' }, shift: ZS })
return this.finish([
{ label: 'Change email', title: 'Account: change email', route: '/profile', api: 'assumed: POST /v1/auth/email/change, resend, cancel', note: 'Extends the Account card on Profile. The email stays read-only until verified. The old email keeps working for login until the link is opened. A notice goes to the old address. A request for an address already in use looks like success, so nothing leaks.', states: [
S('Default', 'The Email field gains a Change email link.', {}),
S('Form open', 'Change email opens an inline form in the Account card.', form({ email: '', pw: '', focus: 'email' })),
S('Invalid format', 'On blur or submit. Nothing is sent.', form({ email: 'marta.alves@atelier', errEmail: C.errFormat })),
S('Same as current', 'Checked on submit, before any request.', form({ email: old, errEmail: C.errSame })),
S('Password missing', 'Submit with an empty password.', form({ pw: '', errPw: C.errPwEmpty, focus: 'pw' })),
S('Wrong password (403)', 'The password is cleared and gets focus.', form({ pw: '', errPw: C.errPwWrong, focus: 'pw' })),
S('Sending', 'Fields and button are disabled.', form({ sending: true })),
S('Pending', '200. The form closes; the old email stays active.', pend(), { toasts: [toast('success', 'circle-check', 'sentT', 'sentB')] }),
S('Pending: resending', 'Resend link in flight.', pend({ resend: 'sending' })),
S('Pending: resent', 'A new link replaces the old one.', pend(), { toasts: [toast('info', 'info', 'resentT', 'resentB')] }),
S('Pending: resend rate limited (429)', 'Resend is disabled with a countdown.', pend({ resend: 'rate' })),
S('Cancelled', 'Cancel change clears the pending state. No confirmation, it only discards a request.', {}, { toasts: [toast('info', 'info', 'cancelledT', 'cancelledB')] }),
S('Rate limited (429)', 'Submit is disabled.', form({ disabled: true, notice: C.rate })),
S('Server or network error', 'Values are kept.', form({ notice: C.server }))
] },
{ label: 'Your data', title: 'Account: export my data', route: '/profile', api: 'assumed: GET /v1/user/me/export (direct) or POST (async, emailed link)', note: 'Recommended: direct download, since one user\'s data in a small self-hosted CMS prepares in seconds. Alternative: a background export with an emailed link, for larger sites.', states: [
S('Direct (recommended): default', 'Your data card.', D('direct', 'idle'), { shift: DS }),
S('Direct: preparing', 'Button disabled, spinner.', D('direct', 'preparing'), { shift: DS }),
S('Direct: started', 'Browser download starts, then a toast.', D('direct', 'idle'), { shift: DS, toasts: [toast('success', 'circle-check', 'dlT', 'dlB')] }),
S('Direct: rate limited (429)', 'One export every 15 minutes.', D('direct', 'rate'), { shift: DS }),
S('Direct: error', '500 or network failure.', D('direct', 'error'), { shift: DS }),
S('Alternative, emailed link: default', 'Same card, request button.', D('async', 'idle'), { shift: DS }),
S('Alternative: requested', 'Export queued.', D('async', 'queued'), { shift: DS, toasts: [toast('success', 'circle-check', 'queuedToastT', 'queuedToastB')] }),
S('Alternative: already in progress', 'A second request while one is queued.', D('async', 'inprogress'), { shift: DS }),
S('Alternative: error', 'The request failed.', D('async', 'error'), { shift: DS })
] },
{ label: 'Delete account', title: 'Account: delete', route: '/profile', api: 'assumed: DELETE /v1/user/me {password}', note: 'Danger zone card with the existing destructive styling. Default for authored content: anonymize (credited to Deleted user). Open question. Confirm stays disabled until the password is filled and the word matches. Blocked states have no close button, no Escape and no backdrop dismissal.', states: [
S('Danger zone', 'Bottom of Profile.', {}, { shift: ZS }),
S('Confirm: empty', 'Confirm is disabled.', {}, dm(ff('', ''))),
S('Confirm: typing', 'Password entered, word partial. Still disabled.', {}, dm(ff(pw, 'DEL', { w: { focus: true } }))),
S('Confirm: ready', 'Both valid. Confirm is enabled.', {}, dm(ff(pw, 'DELETE'), { disabled: false })),
S('Wrong password (403)', 'The password field is cleared and marked.', {}, dm(ff('', 'DELETE', { p: { err: C.errPwWrong, focus: true } }), { disabled: false })),
S('Deleting', 'Fields and Cancel are disabled. Then sign out and redirect to log in.', {}, dm(ff(pw, 'DELETE'), { disabled: false, loading: true, confirm: C.deleting, close: false })),
S('Rate limited (429)', 'Confirm stays enabled.', {}, dm(ff(pw, 'DELETE'), { disabled: false, notice: C.rate })),
S('Server error', 'Nothing was deleted.', {}, dm(ff(pw, 'DELETE'), { disabled: false, notice: C.delServer })),
S('Blocked: last administrator', 'Not dismissable. Next step: Users.', {}, blk('blockT', 'blockB', true)),
S('Blocked: cannot be deleted', 'Generic server refusal. Not dismissable.', {}, blk('protT', 'protB', false))
] }
])
}
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
const on = this.state.mode === k
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
})
return { rootRef: this.rootRef, modes, groups: this.groups() }
}
}
</script>
</body>
</html>
@@ -156,6 +156,19 @@ class Component extends DCLogic {
{ name: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' }, { name: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' },
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } } { name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } }
] }, ] },
{ label: 'Verify new email', title: 'Verify new email', route: '/account/email/confirm?c=CODE', api: 'assumed: POST /v1/auth/email/confirm. Needs a session; without one, inline log in', note: 'Opened from the verification email sent to the new address. Submits on load. The unavailable state says the same thing whether the address is in use or rejected, so it never reveals whether an account exists.', states: [
{ name: 'Confirming', trigger: 'On load, with a code in the URL.', spinner: 'Confirming your new email…' },
{ name: 'Success', trigger: '200 → /profile after 800 ms, with a toast.', result: { tone: 'success', icon: 'mail-check', title: 'Your email address has changed', text: 'Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.', actions: [{ t: 'Go to profile' }] }, toast: toast('success', 'circle-check', 'Email address changed', 'Your account now uses marta.alves@atelier-renew.example.') },
{ name: 'Invalid or expired (403)', trigger: 'The code is wrong, used, cancelled or older than 2 hours.', result: { tone: 'error', icon: 'link-2-off', title: 'This link doesn\'t work', text: 'It may have expired (links last 2 hours), been cancelled or already been used.', note: 'Request a new link from your profile.', actions: [{ t: 'Go to profile' }] } },
{ name: 'Address unavailable (409)', trigger: 'The address is taken or rejected. One message for both.', result: { tone: 'error', icon: 'mail-x', title: 'We couldn\'t change your email address', text: 'This address can\'t be used for your account. Go back to your profile and try a different one.', actions: [{ t: 'Go to profile' }] } },
{ name: 'No session (401)', trigger: 'Opened in another browser. Log in inline, then the change is confirmed with the same code.', title: 'Log in to confirm your new email', desc: 'You opened this link somewhere you\'re not logged in. Log in here and we\'ll confirm the change right after.', fields: [U(), P()], submit: { t: 'Log in and confirm' } },
{ name: 'No session, retrying', trigger: 'Logged in inline. Now confirming.', spinner: 'Confirming your new email…' },
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } },
{ name: 'Server error', trigger: '500 or network failure. Nothing was changed.', result: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t confirm the change', text: 'Something went wrong on our side. Nothing was changed. Try again in a moment.', actions: [{ t: 'Try again' }] } }
] },
{ label: 'Account deleted', title: 'Log in: after account deletion', route: '/login?reason=account-deleted', api: 'none', note: 'After a successful deletion the user is signed out and sent here. The message shows once and is gone on reload.', states: [
{ ...login, name: 'Account deleted', trigger: 'Redirected from the delete dialog.', notice: { tone: 'success', icon: 'circle-check', title: 'Your account has been deleted', text: 'Your articles and media were kept without your name.' }, fields: loginF({}, {}) }
] },
{ label: 'Accept invite', title: 'Accept invite', route: '/invite/accept?c=CODE', api: 'GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending)', note: '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.', states: [ { label: 'Accept invite', title: 'Accept invite', route: '/invite/accept?c=CODE', api: 'GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending)', note: '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.', states: [
{ name: 'Checking the link', trigger: 'On load, while the invite is looked up.', spinner: 'Checking your invite…' }, { name: 'Checking the link', trigger: 'On load, while the invite is looked up.', spinner: 'Checking your invite…' },
{ ...inv, name: 'Default', trigger: 'The code is valid and unused.', fields: invF({}, {}) }, { ...inv, name: 'Default', trigger: 'The code is valid and unused.', fields: invF({}, {}) },
@@ -286,7 +286,7 @@ class Component extends DCLogic {
Mp({ empty: 'first', name: 'Empty library, first run', trigger: '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".' }), Mp({ empty: 'first', name: 'Empty library, first run', trigger: '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".' }),
Mp({ tab: 'unused', q: 'wall', empty: 'search', name: 'No results', trigger: 'Search or filter matched nothing. "Clear filters" resets the search and goes back to All.' }), Mp({ tab: 'unused', q: 'wall', empty: 'search', name: 'No results', trigger: 'Search or filter matched nothing. "Clear filters" resets the search and goes back to All.' }),
Mp({ hover: 2, focus: 3, ann: 'aria-label: kitchen-before.webp, 1.1 MB, used in 1 article', name: 'Hover, focus and keyboard', trigger: '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.' }), Mp({ hover: 2, focus: 3, ann: 'aria-label: kitchen-before.webp, 1.1 MB, used in 1 article', name: 'Hover, focus and keyboard', trigger: '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.' }),
Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: '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.' }), Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: 'Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. Outline badges ("Used in N", "Draft") keep an opaque --ui-bg fill over the sand tint with the text colour unchanged (--ui-text-toned on --ui-bg: 9.7:1 light, 11.7:1 dark; both above 4.5:1). 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.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk' }, name: 'Bulk delete: confirm', trigger: '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.' }), Mp({ sel: [1, 2, 8], modal: { k: 'bulk' }, name: 'Bulk delete: confirm', trigger: '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.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk', loading: true }, name: 'Bulk delete: in progress', trigger: 'Requests in flight. The dialog can\'t be dismissed.' }), Mp({ sel: [1, 2, 8], modal: { k: 'bulk', loading: true }, name: 'Bulk delete: in progress', trigger: 'Requests in flight. The dialog can\'t be dismissed.' }),
Mp({ ids: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], sel: [8], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }), Mp({ ids: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], sel: [8], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }),
@@ -294,6 +294,10 @@ class Component extends DCLogic {
Mp({ panel: { id: 1 }, name: 'Detail panel: in use', trigger: '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.' }), Mp({ panel: { id: 1 }, name: 'Detail panel: in use', trigger: '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.' }),
Mp({ panel: { id: 8 }, name: 'Detail panel: unused', trigger: 'Used in nothing. The section reads "Not used in any article" and says the image is safe to delete.' }), Mp({ panel: { id: 8 }, name: 'Detail panel: unused', trigger: 'Used in nothing. The section reads "Not used in any article" and says the image is safe to delete.' }),
Mp({ panel: { id: 12 }, name: 'Detail panel: duplicate', trigger: 'The image has identical copies. An info alert links to the duplicates view.' }), Mp({ panel: { id: 12 }, name: 'Detail panel: duplicate', trigger: 'The image has identical copies. An info alert links to the duplicates view.' }),
Mp({ panel: { id: 8, ext: 'avatar' }, name: 'Detail panel: used elsewhere, avatar only', trigger: 'window-frame.jpg has no article or entry, and is the avatar of one author. "Used elsewhere" lists "Author avatar" linking to /authors. The "Used in 1" badge counts it, so "Not used in any article" is replaced by "Articles" with "No article shows it."' }),
Mp({ panel: { id: 8, ext: 'site' }, name: 'Detail panel: used elsewhere, site image only', trigger: 'The image is the site default social image. The row "Site default social image" links to /settings/sites. Badge reads "Used in 1".' }),
Mp({ panel: { id: 8, ext: 'both' }, name: 'Detail panel: used elsewhere, both', trigger: 'Both rows, avatar first. Badge reads "Used in 2".' }),
Mp({ panel: { id: 8, ext: 'none' }, name: 'Detail panel: used elsewhere, none', trigger: 'No section. Badge reads "Unused" and "Not used in any article" with "This image is safe to delete." appears, never next to a badge of 1 or more.' }),
Mp({ panel: { id: 1 }, toasts: [TT('success', 'circle-check', 'URL copied', 'Paste it wherever you need the image address.')], name: 'Copy URL', trigger: 'Copy URL. Clipboard write succeeded. Success toasts last 5 s.' }), Mp({ panel: { id: 1 }, toasts: [TT('success', 'circle-check', 'URL copied', 'Paste it wherever you need the image address.')], name: 'Copy URL', trigger: 'Copy URL. Clipboard write succeeded. Success toasts last 5 s.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall' }, name: 'Rename: dialog', trigger: '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.' }), Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall' }, name: 'Rename: dialog', trigger: '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.' }),
Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall-01', loading: true }, name: 'Rename: saving', trigger: 'PATCH in flight. Field and buttons disabled, dialog can\'t be dismissed.' }), Mp({ panel: { id: 1 }, modal: { k: 'rename', val: 'north-wall-01', loading: true }, name: 'Rename: saving', trigger: 'PATCH in flight. Field and buttons disabled, dialog can\'t be dismissed.' }),
@@ -364,7 +368,27 @@ class Component extends DCLogic {
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "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. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "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. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "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.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }) withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "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.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }),
...((B, X) => [
X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }),
X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }),
X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"17 results, showing 7\".", search: { q: 'media', sel: 0 } }),
X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"22 results, showing 7\".", search: { q: '404', sel: 2 } }),
X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }),
X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }),
X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }),
X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } }),
X({ name: "9a. Content types: create, curl", trigger: "Content types group. The Field types table and the sample field object sit above the routes. POST /v1/content-type/ is open on the curl tab with its long body table (key, name, icon, fields). On mobile the view starts at the card.", int: { scroll: 'ct-fields', open: 'ct-create', tab: 'curl' } }, { int: { scroll: 'ep-ct-create', open: 'ct-create', tab: 'curl' } }),
X({ name: "9b. Entries: list, Response tab", trigger: "GET /v1/content-type/{key}/entry open on the Response tab. The response is an object with items, total, page and size. The Query parameters table (page, size, sort) is above the example.", int: { scroll: 'ep-en-list', open: 'en-list', tab: 'response' } }),
X({ name: "9c. Entries: replace, 422 and 409", trigger: "PUT /v1/content-type/{key}/entry/{id} open. Errors list the 409 as a sentence and the 422 as a code-style caption, \"invalid_entry: capacity must be at most 5000\".", int: { scroll: 'ep-en-update', open: 'en-update', tab: 'fetch' } }),
X({ name: "9d. Errors: 404, 409 and 422 rows", trigger: "The table now has real 409 and 422 rows and the two new 404 codes. Codes are the literal body, so they use code style. The 403 row keeps its muted note.", int: { scroll: 'errors' } }),
X({ name: "9e. Session only: 6 sub-groups", trigger: "35 routes in six sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants and Activity.", int: { scroll: 'session' } }),
X({ name: "9f. Search: \"entry\"", trigger: "Endpoints are capped at 6 and the rest are counted (\"N more endpoints\"). \"entries\" in a description or error also matches. Announced: \"8 results, showing 7\".", search: { q: 'entry', sel: 0 } }),
X({ name: "9g. Search: \"capacity\" (body field)", trigger: "Matches the capacity key in the data body of POST and PUT entry. The row adds a \"Body field\" line. Announced: \"2 results\".", search: { q: 'capacity', sel: 0 } }),
X({ name: "9h. Search: \"schema_incompatible\" (error)", trigger: "Matches the 409 of PUT /v1/content-type/{key}. The row adds an \"Error\" line. Announced: \"1 result\".", search: { q: 'schema_incompatible', sel: 0 } }),
X({ name: "9i. Search: \"content type\" (sections)", trigger: "Endpoints capped at 6, then a Sections group with Content types and Entries. Announced: \"12 results, showing 8\".", search: { q: 'content type', sel: 0 } }),
X({ name: "9j. Navigation: scroll-spy on Content types", trigger: "Desktop: the sticky index lists every group and marks Content types. Mobile: the Jump to list is open with all groups and a check on Content types.", int: { scroll: 'ctypes' } }, { int: { scroll: 'ctypes', jump: true } })
])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {}))
] }, ] },
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: '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.', states: [ { label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: '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.', states: [
Act('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.', { tip: 1 }), Act('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.', { tip: 1 }),
@@ -772,7 +796,7 @@ class Component extends DCLogic {
const T = (cols, tpl, rows, o = {}) => ({ cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) }) const T = (cols, tpl, rows, o = {}) => ({ cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) })
return [ return [
{ label: 'Request contract', title: '7. Public request contract', note: 'Shown as the "How requests are handled" card in section 1. Only variants that exist and are enabled are served. There is no arbitrary-size mode.', tables: [T(['Request', 'Status', 'Body or result'], '320px 80px minmax(0,1fr)', [ { label: 'Request contract', title: '7. Public request contract', note: 'Shown as the "How requests are handled" card in section 1. Only variants that exist and are enabled are served. There is no arbitrary-size mode.', tables: [T(['Request', 'Status', 'Body or result'], '320px 80px minmax(0,1fr)', [
['/media/photo.png?v=thumb', '200', 'The cached image. Generated on first request.'], ['/static/uploadedImages/photo.png?v=thumb', '200', 'The cached image. Generated on first request.'],
['/media/photo.png?v=unknown', '400', 'Unknown variant'], ['/media/photo.png?v=unknown', '400', 'Unknown variant'],
['/media/photo.png?v=hero (disabled)', '400', 'Unknown variant'], ['/media/photo.png?v=hero (disabled)', '400', 'Unknown variant'],
['/media/photo.png?width=400', '400', 'Unsupported parameter'], ['/media/photo.png?width=400', '400', 'Unsupported parameter'],
@@ -932,14 +956,23 @@ class Component extends DCLogic {
S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert' } }), S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert' } }),
S('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.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], hint: true }, modal: { name: 'Marta Alves', slug: 'marta-alves', me: true } }), S('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.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], hint: true }, modal: { name: 'Marta Alves', slug: 'marta-alves', me: true } }),
S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', err: true, slugErr: 'The slug "zoe-lambert" is already used by another author.', suggest: 'zoe-lambert-2' } }), S('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', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', err: true, slugErr: 'The slug "zoe-lambert" is already used by another author.', suggest: 'zoe-lambert-2' } }),
S('Create author: failed', '500 or network failure.', 'Couldn\'t create the author · The article is unchanged. Try again in a moment.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', notice: true } }) S('Create author: failed', '500 or network failure. On mobile the dialog sits above the Details sheet; the sheet stays behind it.', 'Couldn\'t create the author · The article is unchanged. Try again in a moment.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', notice: true } }),
S('Collapsed summary: authors', 'The summary under Details when the section is closed. Three authors show the first name and a count.', 'Inès Duarte +2', { v: 'editor', ed: { metaSummary: 'Inès Duarte +2' } }),
S('Collapsed summary: none', 'No authors.', 'No authors', { v: 'editor', ed: { metaSummary: 'No authors' } }),
S('Collapsed summary: site and author', 'Several sites: site name, then the first author.', 'Journal · Inès Duarte', { v: 'editor', ed: { metaSummary: 'Journal · Inès Duarte' } }),
S('Collapsed summary: site, no authors', 'Several sites and no author.', 'Journal · No authors', { v: 'editor', ed: { metaSummary: 'Journal · No authors' } }),
S('Reorder: dropped', 'Space on the grabbed row drops it. The live region (polite) announces the result.', 'Jonas Pereira dropped. Position 2 of 3.', { v: 'editor', metaOpen: true, meta: { ...{"authors":["Inès Duarte","Jonas Pereira","Camille Roche"]}, live: 'Jonas Pereira dropped. Position 2 of 3.' } }),
S('Reorder: cancelled', 'Escape returns the row to where it was.', 'Reorder cancelled. Jonas Pereira is back at position 3 of 3.', { v: 'editor', metaOpen: true, meta: { ...{"authors":["Inès Duarte","Jonas Pereira","Camille Roche"]}, live: 'Reorder cancelled. Jonas Pereira is back at position 3 of 3.' } })
]), ]),
G('Profile: author profile', 'Profile: author profile', '/profile', 'POST /v1/author/me · GET /v1/auth/me (author field)', 'The "Author profile" card sits between Account and Password in every Profile frame, defined once in AppFrame. The existing Profile frames show its default state ("Create my author profile"). All roles can create their own profile and edit their own linked author. Only the states specific to the card are drawn here.', [ G('Profile: author profile', 'Profile: author profile', '/profile', 'POST /v1/author/me · GET /v1/auth/me (author field)', 'The "Author profile" card sits between Account and Password in every Profile frame, defined once in AppFrame. The existing Profile frames show its default state ("Create my author profile"). All roles can create their own profile and edit their own linked author. Only the states specific to the card are drawn here.', [
S('Author profile created: toast', 'Hint-success toast after Create my author profile from the Details hint. The author is added to the article.', 'Author profile created · Marta Alves is now your byline.', { v: 'editor', metaOpen: true, meta: { authors: ['Marta Alves'] }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }),
S('No author profile', 'The user has no linked author.', 'Author profile · Your public byline on articles. It\'s separate from your account. · You don\'t have an author profile yet. Create one to be credited on your own articles. · Create my author profile · Uses your display name, Marta Alves. You can change it afterwards.', { v: 'profile', pa: { k: 'none' } }), S('No author profile', 'The user has no linked author.', 'Author profile · Your public byline on articles. It\'s separate from your account. · You don\'t have an author profile yet. Create one to be credited on your own articles. · Create my author profile · Uses your display name, Marta Alves. You can change it afterwards.', { v: 'profile', pa: { k: 'none' } }),
S('No author profile, no display name', 'The user has no linked author and no display name.', 'Uses your username, marta. You can change it afterwards.', { v: 'profile', pa: { k: 'none', user: true } }),
S('Error', '500 or network failure.', 'Couldn\'t create your author profile · Try again in a moment.', { v: 'profile', pa: { k: 'none', err: true } }), S('Error', '500 or network failure.', 'Couldn\'t create your author profile · Try again in a moment.', { v: 'profile', pa: { k: 'none', err: true } }),
S('Created', '201: the card shows the author. The author is named from the display name and linked to the user.', 'Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author.', { v: 'profile', pa: { k: 'has' }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }), S('Created', '201: the card shows the author. The author is named from the display name and linked to the user.', 'Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author.', { v: 'profile', pa: { k: 'has' }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }),
S('Created, slug adjusted', 'The slug was taken, so the server added a number. No error is shown.', 'Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author.', { v: 'profile', pa: { k: 'has', slug: 'marta-alves-2' } }), S('Created, slug adjusted', 'The slug was taken, so the server added a number. No error is shown.', 'Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author.', { v: 'profile', pa: { k: 'has', slug: 'marta-alves-2' } }),
S('Edit own author', 'Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen.', 'Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes', { v: 'profile', role: 'user', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: true, f: { name: 'Marta Alves', slug: 'marta-alves', slugMode: 'saved', bio: 'Project coordinator. Writes about permits and planning.', user: 'marta@renew.example' } } }) S('Edit own author', 'Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen.', 'Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes', { v: 'profile', role: 'user', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: true, f: { name: 'Marta Alves', slug: 'marta-alves', slugMode: 'saved', bio: 'Project coordinator. Writes about permits and planning.', user: 'marta@renew.example' } } }),
S('Edit own author: owner or admin', 'Edit author on the card, by an owner or admin. The form opens with the linked user and Unlink is allowed.', 'Edit Marta Alves · Linked user · marta@renew.example · Unlink · Save changes', { v: 'profile', role: 'owner', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: false, f: { name: 'Marta Alves', slug: 'marta-alves', slugMode: 'saved', bio: 'Project coordinator. Writes about permits and planning.', user: 'marta@renew.example' } } })
]), ]),
G('Articles: sites and authors', 'Articles: sites and authors', '/articles?site=journal', 'GET /v1/article/?site={key}&page=&size=&q=&status=', '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.', [ G('Articles: sites and authors', 'Articles: sites and authors', '/articles?site=journal', 'GET /v1/article/?site={key}&page=&size=&q=&status=', '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.', [
S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }), S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }),
@@ -949,7 +982,14 @@ class Component extends DCLogic {
G('Articles: search status', 'Articles: search status', '/articles?seo=suggestions', 'GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions)', '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.', [ G('Articles: search status', 'Articles: search status', '/articles?seo=suggestions', 'GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions)', '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.', [
S('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)', { v: 'app', list: {} }), S('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)', { v: 'app', list: {} }),
S('Needs attention on', 'The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included.', 'Needs attention · 3', { v: 'app', list: { needs: true } }), S('Needs attention on', 'The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included.', 'Needs attention · 3', { v: 'app', list: { needs: true } }),
S('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', { v: 'app', list: { needs: true, needsN: 0, counts: [0, 0, 0], empty: { icon: 'circle-check', title: 'Nothing needs attention', text: 'Every article has a clean check. Suggestions are optional, so this is just a bonus.', action: 'Show all articles' } } }) S('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', { v: 'app', list: { needs: true, needsN: 0, counts: [0, 0, 0], empty: { icon: 'circle-check', title: 'Nothing needs attention', text: 'Every article has a clean check. Suggestions are optional, so this is just a bonus.', action: 'Show all articles' } } }),
S('Count chip: 0', 'Zero suggestions: the toggle shows a 0 chip.', 'Needs attention · 0', { v: 'app', list: { needsN: 0 } }),
S('Count chip: absent', 'The count is not known yet, so the chip is not drawn.', 'Needs attention', { v: 'app', list: { naState: 'absent' } }),
S('Count chip: pending', 'The count request is in flight.', 'Needs attention · …', { v: 'app', list: { naState: 'pending' } }),
S('Count chip: failure', 'The count request failed. The toggle still works and the chip shows !.', 'Needs attention · !', { v: 'app', list: { naState: 'fail' } }),
S('Empty: suggestions + search', 'Precedence: search wins. The empty state names the search; Needs attention stays on.', 'No articles match "kitchen" · Try another search or clear it. · Clear search', { v: 'app', list: { needs: true, counts: [0, 0, 0], empty: { icon: 'search', title: 'No articles match "kitchen"', text: 'Try another search or clear it.', action: 'Clear search' } } }),
S('Empty: suggestions + tab', 'A tab other than All with Needs attention on and no match: the tab wins.', 'No drafts need attention · Switch to All to see the rest. · Show all articles', { v: 'app', list: { needs: true, counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No drafts need attention', text: 'Switch to All to see the rest.', action: 'Show all articles' } } }),
S('Empty: suggestions + site', 'A site filter with Needs attention on and no match: the site wins.', 'No articles on "Archive" need attention · Show all sites', { v: 'app', list: { multi: true, site: 'Archive', needs: true, counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive" need attention', text: 'Choose another site or clear the filter.', action: 'Show all sites' } } })
]), ]),
G('Editor: search and sharing', 'Editor: search and sharing', '/article/:id/edit', '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.', [ G('Editor: search and sharing', 'Editor: search and sharing', '/article/:id/edit', '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.', [
S('Collapsed: ready', 'The default. Nothing to suggest.', 'Search and sharing · Ready', { v: 'editor', ed: { seoSummary: 'Ready' } }), S('Collapsed: ready', 'The default. Nothing to suggest.', 'Search and sharing · Ready', { v: 'editor', ed: { seoSummary: 'Ready' } }),
@@ -975,12 +1015,20 @@ class Component extends DCLogic {
S('Panel: checking', 'Checks run on open and after each pause in typing.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { panel: { sugState: 'loading' } } }), S('Panel: checking', 'Checks run on open and after each pause in typing.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { panel: { sugState: 'loading' } } }),
S('Panel: saving', 'Fields are disabled while the request runs.', 'Saving…', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { state: 'saving', mt: 'What a bathroom renovation costs', codes: [] } } }), S('Panel: saving', 'Fields are disabled while the request runs.', 'Saving…', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { state: 'saving', mt: 'What a bathroom renovation costs', codes: [] } } }),
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { notice: 'Couldn\'t save search and sharing. Your changes are still here. Try again in a moment.', mt: 'What a bathroom renovation costs', codes: [] } } }), S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { notice: 'Couldn\'t save search and sharing. Your changes are still here. Try again in a moment.', mt: 'What a bathroom renovation costs', codes: [] } } }),
S('Check failed', 'The suggestion check returned an error. The rest of the section works.', 'Couldn\'t check this article. · Try again', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { state: 'error' } } }),
S('Panel: check failed', 'Same state inside the panel.', 'Couldn\'t check this article. · Try again', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { sugState: 'error' } } }),
S('Panel: dismiss busy', 'The × shows a spinner and the row dims while the request runs.', 'Dismiss (busy)', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { codes: ['title_long', 'cover_alt'], busy: 0 } } }),
S('Panel: dismiss failed', 'The row stays. An inline error with Try again appears under it.', 'Couldn\'t dismiss the suggestion · Try again in a moment.', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { codes: ['title_long', 'cover_alt'], fail: 0 } } }),
S('Panel: show dismissed', 'All suggestions dismissed. The line says how many; Show dismissed brings them back.', 'All good · 2 dismissed suggestions are hidden. · Show dismissed', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { codes: [], dismissed: 2 } } }),
S('Panel: excerpt over 300', 'Inline error under the excerpt; the counter turns red. Save changes is disabled while it shows, and also while nothing changed.', 'The excerpt is 342 characters. Keep it to 300 or fewer. · 342 / 300', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { ex: 'x'.repeat(342).replace(/x/g, 'a'), exErr: 'The excerpt is 342 characters. Keep it to 300 or fewer.', noChange: true, codes: [] } } }),
S('Panel: nothing changed', 'Save changes is disabled until a field differs from the saved value.', 'Save changes (disabled)', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { noChange: true, codes: [] } } }),
S('Saved', 'The panel closes. The summary updates.', 'Search and sharing saved · Your search and sharing settings are up to date.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } }, toasts: [['success', 'circle-check', 'Search and sharing saved', 'Your search and sharing settings are up to date.']] }) S('Saved', 'The panel closes. The summary updates.', 'Search and sharing saved · Your search and sharing settings are up to date.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } }, toasts: [['success', 'circle-check', 'Search and sharing saved', 'Your search and sharing settings are up to date.']] })
]), ]),
G('Editor: what the frontend gets', 'Editor: what the frontend gets', '/article/:id/edit', '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.', [ G('Editor: what the frontend gets', 'Editor: what the frontend gets', '/article/:id/edit', '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.', [
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: {} } }), S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: {} } }),
S('Raw JSON', 'The JSON as the API returns it.', 'Raw JSON', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw' } } }), S('Raw JSON', 'The JSON as the API returns it.', 'Raw JSON', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw' } } }),
S('Copied', 'The button reads Copied for 2 s and this is announced.', 'Copied', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copied: true } } }), S('Copied', 'The button reads Copied for 2 s and this is announced.', 'Copied', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copied: true } } }),
S('Copy refused', 'navigator.clipboard was refused. The Raw JSON view opens, the text is selected and a note explains how to copy by hand. The button keeps reading Copy JSON.', 'The browser blocked copying. The JSON is selected below: press Ctrl+C or Cmd+C to copy it.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copyFail: true } } }),
S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { draft: true } } }), S('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.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { draft: true } } }),
S('Loading', 'Copy is disabled.', '', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'loading' } } }), S('Loading', 'Copy is disabled.', '', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'loading' } } }),
S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'error' } } }) S('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', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'error' } } })
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Roles screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: roles and permissions</h1>
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Roles list, role editor with the content grid and plain-language preview, delete role, Users changes and ownership transfer, the role-aware shell, 403 copy, activity events, profile, API keys, and a six-step flow for creating a role. Every state at 1440 px (shown at 50%) and 390 px (shown at 75%); the switch shows dark. Specs in screens/roles.md. Same shell and tokens as <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="9">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">Auth: {{ g.auth }}</span></div>
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:48px 40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px;width:1037px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<span style="margin-left:10px;padding:1px 6px;border:{{ st.chipBd }};color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">{{ st.scopeLabel }}</span></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.isSchema }}"><dc-import name="RolesFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
<p style="margin:0;font-size:12px;line-height:18px;color:var(--ui-text-muted);text-wrap:pretty"><b style="color:var(--ui-text-toned)">Copy:</b> {{ st.copy }}</p>
</div>
</sc-for>
</div>
</section>
</sc-for>
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3">
<section style="display:flex;flex-direction:column;gap:10px;max-width:1240px">
<h2 style="margin:0;font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div>
<sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r.cells }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;overflow-wrap:anywhere">{{ c }}</span></sc-for></div></sc-for>
</div>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;light&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;tsType&quot;:&quot;string&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light', spec: { groups: [], tables: [] } }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-sel': '#e8dfd1', '--rc-on-sel': '#111111', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-sel': '#222222', '--rc-on-sel': '#fafafa', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() { if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return this.state.mode }
apply() { const el = this.rootRef.current; if (!el) return; const vars = this.resolved() === 'dark' ? this.dark : this.light; for (const k in vars) el.style.setProperty(k, vars[k]); document.body.style.background = vars['--rc-canvas'] }
componentDidMount() { this.apply(); import('./roles-spec.js').then(x => this.setState({ spec: { groups: x.groups, tables: x.tables } })).catch(() => {}) }
componentDidUpdate(p) { if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme }); this.apply() }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = this.state.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
const groups = this.state.spec.groups.map(g => ({ ...g, states: g.states.map(s => ({ ...s, isMedia: !!(s.s && s.s.frame === 'media'), isSchema: !(s.s && s.s.frame === 'media'), scopeLabel: s.scope === 'later' ? 'Later' : 'Available now', chipBd: s.scope === 'later' ? '1px dashed var(--ui-border-accented)' : '1px solid var(--ui-border-accented)' })) }))
const tables = this.state.spec.tables.map(t => ({ ...t, rows: t.rows.map(r => ({ cells: r })) }))
return { rootRef: this.rootRef, modes, groups, tables }
}
}
</script>
</body>
</html>
+385
View File
@@ -0,0 +1,385 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<sc-if value="{{ framed }}" hint-placeholder-val="{{ true }}"><dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" hint-size="100%,100%"></dc-import></sc-if>
<sc-if value="{{ bare }}"><div style="width:{{ bw }};height:{{ bh }};overflow:hidden;position:relative;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">{{ main }}</div></sc-if>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const h = React.createElement
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url("https://api.iconify.design/lucide/' + n + '.svg") center / contain no-repeat', mask: 'url("https://api.iconify.design/lucide/' + n + '.svg") center / contain no-repeat', ...(t ? P(t) : {}) } })
const mono = 'font-family:\'IBM Plex Mono\',monospace;'
const bh = m ? 44 : 32, ih = m ? 44 : 32, fs = m ? 16 : 13
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : k === 'ghost' ? 'transparent' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : k === 'ghost' ? 'transparent' : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const IB = (ic, o = {}) => B(null, { k: 'ghost', icon: ic, w: o.w || (m ? 44 : 28), h: o.h || (m ? 44 : 28), px: 0, dis: o.dis, ring: o.ring, z: o.z || 16 })
const Alert = (tone, icon, title, text, ...actions) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned);text-wrap:pretty', text)), actions.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...actions.map((a, i) => B(a, { icon: i === 0 && /again|Retry|Reload/.test(a) ? 'rotate-cw' : null, z: 14, h: m ? 44 : 28, fs: 12, px: 10, k: 'outline' })))))
const Later = (t = 'Later') => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);white-space:nowrap;flex-shrink:0;font:500 11px/16px \'IBM Plex Mono\',monospace', t)
const BDG = { req: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', uniq: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', neu: 'background:var(--rc-sand);color:var(--rc-on-sand)', chg: 'background:var(--rc-info-soft);color:var(--ui-info)', err: 'background:var(--rc-error-soft);color:var(--ui-error)', warn: 'background:var(--rc-warning-soft);color:var(--ui-warning)', built: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', ok: 'background:var(--rc-success-soft);color:var(--ui-success)' }
const Bd = (t, k, ic) => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BDG[k], ic && I(ic, 12), t)
const Seg = (opts, on, w) => D('display:flex;border:1px solid var(--ui-border-accented);width:' + (w || '100%'), ...opts.map(([k, t]) => S('flex:1;height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + fs + 'px', t)))
const SwR = (on, lab, help, dis) => D('display:flex;gap:16px;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--ui-border);opacity:' + (dis ? 0.6 : 1), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')))
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', lab && S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
const Chk = on => S('width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', on && I('check', 12))
const Skel = (w, hh = 14) => S('display:block;height:' + hh + 'px;width:' + w + ';background:var(--ui-bg-accented)')
const F = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', (lab || o.count) && D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, o.req && ' *', o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S(mono + 'font-size:12px;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count)),
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';min-height:' + (o.area ? (m ? 120 : 88) : ih) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;opacity:' + (o.dis ? 0.6 : 1), o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.ph || val === '' ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)') + ';' + (o.area ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;') + (o.mono ? mono + 'font-size:' + (m ? 14 : 12) + 'px;' : ''), val === '' && o.phText ? o.phText : val), o.auto && Bd('Auto', 'neu'), o.auto && I('pencil', 14, 'margin:0 10px 0 8px;color:var(--ui-text-muted)'), o.lock && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix && S('padding-right:10px;color:var(--ui-text-muted);font-size:12px', o.suffix), o.ico && I(o.ico, 14, 'margin-right:10px;color:var(--ui-text-muted)')),
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', o.help))
const Shell = (o, body) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? '0' : (o.top || 140) + 'px 16px 16px'),
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.later && Later(), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
const Sheet = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;align-items:flex-end;background:var(--rc-overlay)',
D('width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:4px 6px 4px 16px;height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), o.later && Later(), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('flex:1;min-height:0;overflow:hidden;padding:16px;display:flex;flex-direction:column;gap:16px', body), foot && D('flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', foot)))
const Toast = (tone, icon, title, text, action) => D('position:absolute;z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);' + (m ? 'left:16px;right:16px;bottom:' + (s.toastB || 16) + 'px' : 'right:24px;bottom:24px;width:356px'), S('margin-top:2px;display:flex;color:var(--ui-' + tone + ')', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))
const Offline = () => D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changes will be saved when you reconnect.'))
const Tip = t => D('position:absolute;z-index:30;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
const Page = (pad, ...c) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden;padding:' + pad + ';gap:16px', ...c)
const Title = (t, sub, ...acts) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', !m && S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), sub && S('color:var(--ui-text-muted)', sub)), ...acts)
const pad = m ? '16px' : '24px'
const Fab = l => m && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;z-index:5;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), l)
const Center = (icon, title, text, ...acts) => D('flex:1;display:flex;align-items:center;justify-content:center;padding:24px', D('max-width:420px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font:600 24px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);text-wrap:pretty', text), D('display:flex;gap:8px', ...acts)))
const Empty = (icon, title, text, action) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:360px;text-wrap:pretty', text), action && B(action, { k: 'primary', icon: 'plus', px: 16 }))
// ---------- roles data
BDG.lock = 'background:var(--ui-bg-elevated);color:var(--ui-text-toned)'; BDG.owner = 'background:var(--rc-sand);color:var(--rc-on-sand)'; BDG.admin = 'background:var(--rc-info-soft);color:var(--ui-info)'; BDG.own = 'border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned)'
const TY = ['Articles', 'Events', 'Case studies'], AL = ['read', 'create', 'update', 'delete', 'publish'], ALL = { read: 'Read', create: 'Create', update: 'Update', delete: 'Delete', publish: 'Publish' }, SC = { n: 'None', o: 'Own', a: 'All' }
const GEN = [
{ id: 'schema', name: 'Schema', items: [['View content types', 'See content types and their fields.', 'see content types and fields', 'b'], ['Create and edit content types', 'Add types and change their fields and rules.', 'create and edit content types', 'b'], ['Delete content types', 'Remove a type. Its entries go with it.', 'delete content types', 'b']] },
{ id: 'media', name: 'Media', items: [['View the media library', 'Browse images and files.', 'browse the media library', 'b'], ['Upload files', 'Add images and files to the library.', 'upload files', 'b'], ['Delete files', 'Remove files from the library.', 'delete files', 's']] },
{ id: 'authors', name: 'Authors', items: [['View authors', 'See author profiles.', 'see authors', 'b'], ['Create authors', 'Add an author profile.', 'create authors', 'b'], ['Edit authors', 'Change an author profile.', 'edit authors', 's'], ['Delete authors', 'Remove an author profile.', 'delete authors', 'b']] },
{ id: 'activity', name: 'Activity', items: [['See activity', 'Read the log of who did what.', 'read the activity log', 's'], ['Export activity', 'Download the log as a CSV file.', 'export the activity log', 'b']] },
{ id: 'users', name: 'Users and invites', items: [['View users', 'See members and pending invites.', 'see users and invites', 'b'], ['Invite users', 'Send an invitation with a role.', 'invite users', 'b'], ['Change a user\'s role', 'Move a user to another role.', 'change a user\'s role', 'b'], ['Remove users', 'Take a user off the site.', 'remove users', 'b']] },
{ id: 'settings', name: 'Settings', items: [['View settings', 'Open Settings pages, read-only.', 'read settings', 'b'], ['Edit general settings', 'Site name, language and defaults.', 'edit general settings', 'b'], ['Manage sites', 'Create, edit and set the default site.', 'manage sites', 'b'], ['See API keys', 'List the keys on this site.', 'see API keys', 's'], ['Create API keys', 'A key follows its creator\'s role.', 'create API keys', 'b'], ['Revoke API keys', 'Stop a key from working.', 'revoke API keys', 'b'], ['Delete a site', 'Remove a site and its content.', 'delete a site', 'o']] },
{ id: 'roles', name: 'Roles', items: [['View roles', 'See roles and what they allow.', 'see roles', 'b'], ['Manage roles', 'Create, edit, duplicate and delete roles.', 'manage roles', 'b'], ['Edit roles with owner-only power', 'Change a role that carries owner-only abilities.', 'edit roles with owner-only power', 'o']] },
{ id: 'system', name: 'System', items: [['Apply software updates', 'Install a new version of RenewCMS.', 'apply software updates', 'o'], ['Edit activity retention', 'How long the activity log is kept.', 'edit activity retention', 'o'], ['Transfer ownership', 'Make another member the owner.', 'transfer ownership', 'o']] }
]
const fullG = own => Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'a' : it[3] === 'o' ? (own ? '1' : '0') : '1').join('')]))
const mkT = (a, ov) => Object.fromEntries(TY.map(t => [t, (ov && ov[t]) || a]))
const ROLES = {
owner: { name: 'Owner', desc: 'Everything on the site. One per site.', built: 1, lock: 1, members: 1, g: fullG(true), all: 'a1aaa', t: mkT('a1aaa') },
admin: { name: 'Admin', desc: 'Everything except owner-only settings.', built: 1, members: 2, g: fullG(false), all: 'a1aaa', t: mkT('a1aaa') },
writer: { name: 'Writer', desc: 'Reads everything and writes content. Can\'t delete or publish.', built: 1, members: 5, g: { schema: '100', media: '11n', authors: '10o0', activity: 'o0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1ann', t: mkT('a1ann') },
contrib: { name: 'Contributor', desc: 'Creates content and edits only their own.', members: 4, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1onn', t: mkT('a1onn', { 'Case studies': 'a0nnn' }) },
lead: { name: 'Content lead', desc: 'Edits and publishes all content.', members: 1, g: { schema: '100', media: '11a', authors: '10a0', activity: 'a1', users: '1000', settings: '100n000', roles: '100', system: '000' }, all: 'a1aaa', t: mkT('a1aaa') },
junior: { name: 'Junior writer', desc: 'Writes articles and reads online ones and their own drafts.', members: 2, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'o1onn', t: mkT('o1onn') },
blank: { name: '', desc: '', members: 0, g: Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'n' : '0').join('')])), all: 'n0nnn', t: mkT('n0nnn') }
}
const ON = c => c !== '0' && c !== 'n'
const cnt = (R, g) => R.g[g.id].split('').filter(ON).length
const j = a => a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]
const phr = (c, art) => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [c[0] === 'o' ? (art ? 'read online articles and their own drafts' : 'read only entries they created') : f(c[0], 'read'), c[1] === '1' ? 'create entries' : null, f(c[2], 'edit'), f(c[3], 'delete'), f(c[4], 'publish')]; const cl = can.filter(Boolean), no = ['read', 'create', 'edit', 'delete', 'publish'].filter((x, i) => !can[i]); return cl.length ? 'Can ' + j(cl) + '.' + (no.length ? ' Can\'t ' + j(no) + '.' : '') : 'No access to entries.' }
const pv = R => {
const groups = {}; TY.forEach(t => { const p = phr(R.t[t], t === 'Articles'); (groups[p] = groups[p] || []).push(t) })
const content = Object.entries(groups).map(([p, ts]) => ({ pre: ts.length === TY.length ? 'All content types' : j(ts), txt: p }))
const other = [], none = []
GEN.forEach(g => { const say = []; g.items.forEach((it, i) => { const ch = R.g[g.id][i]; if (it[3] === 'o' || !ON(ch)) return; say.push(it[3] === 's' ? (ch === 'o' ? it[2] + ', only their own' : it[2]) : it[2]) }); if (say.length) other.push({ pre: g.name, txt: 'Can ' + j(say) + '.' }); else if (g.id !== 'system') none.push(g.name) })
return { content, other, none }
}
const RB = n => n === 'Owner' ? Bd('Owner', 'owner', 'crown') : n === 'Admin' ? Bd('Admin', 'admin', 'shield') : n === 'Writer' ? Bd('Writer', 'req', 'pencil') : Bd(n, 'own', 'user-round')
const IC = (ic, t) => S('display:inline-flex;margin-top:2px;color:' + (t || 'var(--ui-text-toned)'), I(ic, 16))
// ---------- shared pieces
const Tri = (v, o = {}) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;opacity:' + (o.dis ? 0.55 : 1) + ';' + (o.fill ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''),
...['n', 'o', 'a'].map(c => { const on = v === c, bl = (o.block || '').includes(c); return S('flex:1;min-width:' + (m ? 0 : 34) + 'px;height:' + (m ? 44 : 26) + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;gap:3px;font-size:' + (m ? 14 : 12) + 'px;font-weight:' + (on ? 600 : 400) + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';box-shadow:' + (on ? 'inset 0 -2px 0 var(--ui-primary)' : 'none') + ';opacity:' + (bl ? 0.45 : 1), bl && I('lock', 10), SC[c]) }))
const OnOff = (on, o = {}) => D('display:flex;align-items:center;gap:8px;opacity:' + (o.dis ? 0.55 : 1), S('width:32px;height:18px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), S('font-size:12px;min-width:20px;color:var(--ui-text-toned)', on ? 'On' : 'Off'))
const Card = (title, desc, right, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;flex-shrink:0;min-width:0', D('display:flex;gap:12px;align-items:flex-start', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), desc && S('color:var(--ui-text-muted);text-wrap:pretty', desc)), right), ...c)
const Menu = (right, top, items) => D('position:absolute;z-index:30;right:' + right + 'px;top:' + top + 'px;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items.map(([ic, t, dis, hot, danger]) => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;opacity:' + (dis ? 0.5 : 1) + ';background:' + (hot ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:' + (danger ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'), I(ic, 16), S('flex:1', t))))
const Banner = (tone, icon, title, text, ...acts) => Alert(tone, icon, title, text, ...acts)
const Link = t => S('text-decoration:underline;text-underline-offset:3px;font-weight:500;color:var(--ui-text-highlighted)', t)
const Pill = (t, on) => S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (on ? 600 : 500) + ';font-size:' + (m ? 14 : 12) + 'px;white-space:nowrap', on && I('check', 12), t)
const crumbs = (...c) => { shell.crumbs = c; shell.mTitle = c[c.length - 1] }
// ---------- preview panel
const PvBody = (R, o = {}) => { const p = pv(R); const L = (ic, pre, txt, chg) => D('display:flex;gap:10px', IC(ic), D('flex:1;display:flex;flex-direction:column;gap:1px;min-width:0', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', pre, chg && ' '), S('color:var(--ui-text-highlighted);text-wrap:pretty', txt)), chg && Bd('Changed', 'chg')); return D('display:flex;flex-direction:column;gap:12px',
D('display:flex;flex-direction:column;gap:10px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Content'), ...p.content.map((x, i) => L(R.all === 'n0nnn' && !R.t.Articles.includes('a') && x.txt.startsWith('No') ? 'ban' : 'file-text', x.pre, x.txt, o.chg && i === 0))),
p.other.length > 0 && D('display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', S('font-weight:600;color:var(--ui-text-highlighted)', 'Everything else'), ...p.other.map(x => L('check', x.pre, x.txt))),
p.none.length > 0 && D('display:flex;gap:10px;padding-top:12px;border-top:1px solid var(--ui-border)', IC('ban'), D('display:flex;flex-direction:column;gap:1px', S('font-size:12px;line-height:18px;font-weight:500;color:var(--ui-text-muted)', 'No access to'), S('color:var(--ui-text-highlighted)', j(p.none) + '.'))),
R.members > 0 && S('padding-top:12px;border-top:1px solid var(--ui-border);color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Applies to ' + R.members + (R.members === 1 ? ' member' : ' members') + ', and to the API keys they created.')) }
const PvPanel = (R, o = {}) => D('width:320px;flex-shrink:0;align-self:flex-start;max-height:100%;overflow:hidden;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px;' + (o.ro ? 'opacity:1;' : ''), D('display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'What this role can do'), S('color:var(--ui-text-muted)', o.ro ? 'A summary of what this role allows.' : 'Updates as you edit.')), PvBody(R, o))
const PvSheet = (R, o = {}) => Sheet({ title: o.title || 'What this role can do', z: 45 }, [S('color:var(--ui-text-muted)', o.sub || 'Updates as you edit.'), PvBody(R, o)], o.foot)
// ---------- content grid
const CG = (R, o = {}) => {
const chg = o.chg || [], esc = o.esc, base = R.all
const uni = i => TY.every(t => R.t[t][i] === R.t[TY[0]][i]) ? R.t[TY[0]][i] : null
const cell = (t, i, v) => { const a = AL[i], key = t + '.' + a, bl = esc && a === 'publish' ? 'a' : ''; const ctl = i === 1 ? OnOff(v === '1', { dis: o.ro }) : Tri(v, { dis: o.ro, block: bl, ring: o.ring === key }); return D('position:relative;display:flex;align-items:center', chg.includes(key) && S('position:absolute;left:-8px;top:50%;margin-top:-3px;width:6px;height:6px;background:var(--ui-info)'), ctl) }
const rdNote = TY.some(t => R.t[t][0] === 'o') && Alert('info', 'info', '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.')
const tcol = '118px 84px 118px 118px 118px'
const nameCell = (t, custom) => D('display:flex;flex-direction:column;gap:2px;min-width:0', D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', S('font-weight:500;color:var(--ui-text-highlighted)', t), t === 'Articles' && Bd('Built-in', 'built', 'lock'), custom && Bd('Custom', 'warn'), custom && Link('Reset')), t === 'Articles' && S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Schema locked'))
const presets = D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('color:var(--ui-text-muted);margin-right:4px', 'Quick presets'), Pill('Read only', base === 'a0nnn'), Pill('Write own', base === 'a1onn'), Pill('Full content', base === 'a1aaa'))
if (m) {
const card = (t, v, custom, all) => D('border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:10px;background:' + (all ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), all ? S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types') : nameCell(t, custom), ...AL.map((a, i) => D('display:flex;align-items:center;gap:10px', S('width:64px;color:var(--ui-text-toned);flex-shrink:0', ALL[a]), i === 1 ? OnOff(v[i] === '1', { dis: o.ro }) : Tri(v[i], { fill: true, dis: o.ro, block: esc && a === 'publish' ? 'a' : '' }))))
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...(o.noSchema ? ['Articles'] : TY).map(t => card(t, R.t[t], R.t[t] !== base)), o.noSchema && Alert('info', 'info', 'Custom content types aren\'t listed', 'Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.'), rdNote)
}
const hd = D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;align-items:center', S('', 'Content type'), ...AL.map(a => S('display:flex;align-items:center;gap:4px', ALL[a], !o.ro && I('chevron-down', 12))))
const row = (t, v, custom, all) => D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);align-items:center;background:' + (all ? 'var(--ui-bg-muted)' : 'var(--ui-bg)'), all ? D('display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types'), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Sets every row')) : nameCell(t, custom), ...AL.map((a, i) => v[i] === null ? D('display:flex;align-items:center;gap:6px', Tri(null, { dis: o.ro }), Bd('Mixed', 'warn')) : cell(t, i, v[i])))
const allv = AL.map((a, i) => uni(i))
const TYs = o.noSchema ? ['Articles'] : TY
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative;overflow-x:auto', D('min-width:720px', hd, row('', allv, false, true), ...TYs.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip))), o.noSchema && Alert('info', 'info', 'Custom content types aren\'t listed', 'Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.'), rdNote)
}
const Tip2 = t => D('position:absolute;z-index:30;right:8px;top:78px;width:272px;padding:10px 12px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
// ---------- general permissions
const GP = (R, o = {}) => { const open = o.open || ['schema']; return D('display:flex;flex-direction:column', ...GEN.map(g => { const isO = open.includes(g.id), c = cnt(R, g), tot = g.items.length - g.items.filter(it => it[3] === 'o').length || g.items.length; return D('border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 40) + 'px;padding:4px 0', I(isO ? 'chevron-down' : 'chevron-right', 16, 'color:var(--ui-text-muted)'), S('font-weight:600;color:var(--ui-text-highlighted);flex:1', g.name), g.id === 'system' && Bd('Owner only', 'lock', 'lock'), S('font-size:12px;color:var(--ui-text-muted)', g.id === 'system' ? '0 of 3 enabled' : c + ' of ' + tot + ' enabled')), isO && D('display:flex;flex-direction:column;padding:0 0 4px 24px', ...g.items.map((it, i) => { const ch = R.g[g.id][i], lockd = it[3] === 'o', ctl = lockd ? D('display:flex;align-items:center;gap:8px', Bd('Owner only', 'lock', 'lock'), OnOff(ch === '1', { dis: true })) : it[3] === 's' ? Tri(ch, { dis: o.ro, fill: m }) : OnOff(ON(ch), { dis: o.ro }); return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 8 : 16) + 'px;align-items:' + (m ? 'stretch' : 'center') + ';justify-content:space-between;padding:10px 0;border-top:1px solid var(--ui-border-muted)', D('display:flex;flex-direction:column;gap:2px;min-width:0;opacity:' + (lockd ? 0.7 : 1), S('font-weight:500;color:var(--ui-text-highlighted)', it[0]), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', it[1])), ctl) }))) })) }
// ---------- editor
const Editor = (R, o = {}) => {
const ro = !!o.ro, isNew = !!o.isNew
const head = D('display:flex;flex-direction:column;gap:12px;flex-shrink:0',
D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', D('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500', I('arrow-left', 16), 'Roles'), D('flex:1'), !m && Title0(R, o)),
m && Title0(R, o),
o.alerts && D('display:flex;flex-direction:column;gap:8px', ...o.alerts),
D('display:grid;grid-template-columns:' + (m ? '1fr' : 'minmax(0,280px) minmax(0,1fr)') + ';gap:12px', F('Name', R.name, { req: !ro, lock: ro, err: o.nameErr, focus: o.focus === 'name', phText: 'For example, Contributor', help: o.nameHelp }), F('Description', R.desc, { lock: ro, opt: !ro, phText: 'What this role is for', focus: o.focus === 'desc' })))
const sections = D('display:flex;flex-direction:column;gap:16px;opacity:' + (o.saving ? 0.6 : 1), o.showHead !== false && head,
!o.onlyGeneral && Card('Content permissions', 'What this role can do with entries of each content type. Own means entries the person created.', null, CG(R, o)),
Card('General permissions', 'Everything outside entries.', !m && !ro ? S('color:var(--ui-text-toned);font-weight:500', 'Expand all') : null, GP(R, { ro, open: o.open })))
const scroll = o.scroll ? D('margin-top:-' + o.scroll + 'px;display:flex;flex-direction:column;gap:16px', sections) : sections
const col = D('flex:1;min-width:0;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding-bottom:' + (o.bar ? 64 : 0) + 'px', scroll)
const bar = o.bar && D('position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;padding:' + (m ? '8px 16px' : '10px 24px') + ';background:var(--ui-bg);border-top:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', ...o.bar)
const body = m ? col : D('flex:1;min-height:0;display:flex;gap:16px;align-items:flex-start', col, o.noPv ? null : PvPanel(R, { chg: o.pvChg, ro }))
return [Page(m ? '16px' : '24px', body, bar), o.over]
}
const Title0 = (R, o) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.isNew ? 'New role' : R.name), !o.isNew && (R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), R.lock && Bd('Locked', 'lock', 'lock'), !o.isNew && S('color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members')))
const SaveBar = (n, o = {}) => [m ? B('What this role can do', { icon: 'list-checks', full: true, h: 44 }) : D('display:flex;align-items:center;gap:8px;flex:1', S('width:6px;height:6px;background:var(--ui-info)'), S('font-weight:500;color:var(--ui-text-highlighted)', o.msg || (n + (n === 1 ? ' unsaved change' : ' unsaved changes')))), !m && S('flex:0'), B('Discard', { dis: o.saving }), S('height:' + bh + 'px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:' + (o.saving || o.sdis ? 0.4 : 1), o.saving && I('loader-circle'), o.saving ? 'Saving…' : 'Save changes')]
// ---------- tables used by several screens
const Th = (cols, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...c.map(x => S('', x)))
const Tr = (cols, o, ...c) => D('display:grid;grid-template-columns:' + cols + ';gap:16px;padding:0 12px;min-height:48px;align-items:center;border-top:1px solid var(--ui-border);position:relative;background:' + (o.sel ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o.sel ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dim ? 0.55 : 1), ...c)
const Dots = ring => IB('ellipsis', { ring })
const Cd = (o, ...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:6px;opacity:' + (o.dim ? 0.55 : 1), ...c)
const Mu = t => S('color:var(--ui-text-muted)', t)
const Hi = t => S('font-weight:500;color:var(--ui-text-highlighted)', t)
const Add = (label, o = {}) => B(label, { k: 'primary', icon: 'plus', px: 14, dis: o.dis })
// ---------- data for the screens
const shell = { kind: 'slot', display: 'Marta Alves', role: s.role || 'owner', content: [{ key: 'events', name: 'Events', icon: 'files' }, { key: 'case-studies', name: 'Case studies', icon: 'files' }], contentOn: null, navBadge: false, nav: 'settings', sideOpen: true }
let main = null; const ov = []; let bare = false
const RL = ['owner', 'admin', 'writer', 'contrib', 'lead']
const k = s.k, v = s.v
if (v === 'list') {
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : k === 'tgone' ? RL.filter(r => r !== 'contrib') : RL
const sk = (w) => Skel(w, 14)
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Role', 'Description', 'Members', 'Type', ''),
...(k === 'loading' ? [1, 2, 3, 4, 5].map(() => Tr(cols, {}, sk('120px'), sk('70%'), sk('24px'), sk('64px'), sk('16px'))) : rows.map((r, i) => { const R = ROLES[r]; return Tr(cols, { sel: k === 'menu' && i === 3 }, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock')), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots(k === 'menu' && i === 3 || k === 'menub' && i === 2)) })),
k === 'menu' && Menu(24, 200, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', false, false, true]]),
k === 'menub' && Menu(24, 152, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true]]), k === 'menub' && D('position:absolute;z-index:31;right:260px;top:196px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
noEdit && k === 'noedit' && D('position:absolute;z-index:31;right:24px;top:-40px;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can see roles, but only people who manage roles can create or change them.'))
const cards = D('display:flex;flex-direction:column;gap:8px', ...(k === 'loading' ? [1, 2, 3].map(() => Cd({}, sk('120px'), sk('90%'), sk('60px'))) : rows.map((r, i) => { const R = ROLES[r]; return Cd({}, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock'), D('flex:1'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), IB('ellipsis')), Mu(R.desc), S('font-size:12px;color:var(--ui-text-muted)', R.members + (R.members === 1 ? ' member' : ' members'))) })))
const list = k === 'error' ? Alert('error', 'circle-x', 'Couldn\'t load roles', 'GET /v1/role failed. Check your connection and try again.', 'Try again') : m ? cards : tbl
main = Page(m ? '16px 16px 88px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' || noEdit })),
noEdit && Banner('info', 'lock', 'You can see roles, but only people who manage roles can create or change them.'),
k === 'empty' && Banner('info', 'info', 'No custom roles yet', 'The three built-in roles cover most sites. Create a role when someone needs something in between.', 'New role'),
list, m && Fab('New role'))
if (k === 'tedit') ov.push(Toast('warning', 'triangle-alert', 'Role changed elsewhere', 'Jonas Weber edited Contributor, so nothing was saved. Reload and try again.', 'Reload'))
if (k === 'tgone') ov.push(Toast('info', 'info', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'))
} else if (v === 'editor') {
const flow = s.flow
let R = ROLES.contrib, o = {}
crumbs('Settings', 'Roles', k === 'new' ? 'New role' : R.name)
if (k === 'new') { R = ROLES.blank; o = { isNew: true, focus: 'name', bar: SaveBar(0, { sdis: true, msg: 'Name your role to save it' }) }; shell.mTitle = 'New role' }
else if (k === 'custom') { o = { open: ['schema', 'media'] } }
else if (k === 'owner-edit') { R = ROLES.writer; o = { open: [], alerts: [Banner('warning', 'triangle-alert', 'This role has 5 members', 'Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default.')], bar: SaveBar(2) }; shell.mTitle = 'Writer'; R = { ...R, t: { ...R.t, Events: 'a1aan' } } }
else if (k === 'admin-ro') { R = ROLES.writer; shell.role = 'admin'; shell.display = 'Jonas Weber'; o = { ro: true, open: [], alerts: [Banner('info', 'lock', 'Only the owner can edit built-in roles', 'You can read what Writer allows. To change it, duplicate it into a custom role.', 'Duplicate role')] } }
else if (k === 'locked') { R = ROLES.owner; o = { ro: true, open: [], alerts: [Banner('info', 'lock', 'The Owner role is locked', 'It always has every permission. To hand it to someone else, transfer ownership from Users.', 'Go to Users')] } }
else if (k === 'dirty') { R = { ...R, t: { ...R.t, Events: 'a1aan' }, g: { ...R.g, media: '11o' } }; o = { open: ['media'], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(3) } }
else if (k === 'saving') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { saving: true, open: [], bar: SaveBar(3, { saving: true }) } }
else if (k === 'saved') { o = { open: [], over: Toast('success', 'circle-check', 'Role saved', 'Contributor is updated for 4 members.') } }
else if (k === 'req') { R = { ...ROLES.blank, name: '' }; o = { isNew: true, nameErr: 'Give the role a name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
else if (k === 'taken') { R = { ...ROLES.blank, name: 'Writer', desc: 'Reads and writes' }; o = { isNew: true, nameErr: 'A role named "Writer" already exists. Choose a different name.', bar: SaveBar(0, { sdis: true, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
else if (k === 'esc') { shell.display = 'Elena Rossi'; shell.role = 'admin'; R = ROLES.contrib; R = { ...R, name: 'Contributor', t: { ...R.t, Articles: 'a1ono' } }; o = { open: [], esc: true, ring: 'Articles.publish', tip: 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available.', bar: SaveBar(1) } }
else if (k === 'conflict') { o = { open: [], alerts: [Banner('warning', 'triangle-alert', 'This role changed while you were editing', 'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.', 'Reload role', 'Review differences')], bar: SaveBar(2, { sdis: true, msg: '2 unsaved changes. Save is off until you reload' }) } }
else if (k === 'lowered') { shell.display = 'Elena Rossi'; shell.role = 'admin'; o = { ro: true, open: [], alerts: [Banner('error', 'circle-x', 'Your access changed', 'An owner changed your role while this page was open. You can still see roles, but you can\'t change them. Your edits weren\'t saved.', 'Reload page')] } }
else if (k === 'general') { o = { onlyGeneral: false, scroll: 360, open: ['activity', 'users', 'settings'], showHead: false } ; R = ROLES.lead }
else if (k === 'owneronly') { shell.display = 'Jonas Weber'; shell.role = 'admin'; R = ROLES.admin; o = { scroll: 300, open: ['roles', 'system'], showHead: false, ro: false, alerts: null } }
else if (k === 'custom-row') { R = ROLES.contrib; o = { open: [], chg: ['Case studies.read'], pvChg: false } }
else if (k === 'readown') { R = ROLES.junior; crumbs('Settings', 'Roles', 'Junior writer'); o = { open: [], ring: 'Articles.read' } }
else if (k === 'review') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], alerts: [Banner('warning', 'triangle-alert', 'This role changed while you were editing', 'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.', 'Reload role', 'Review differences')], bar: SaveBar(2, { sdis: true, msg: '2 unsaved changes. Save is off until you reload' }), over: Shell({ title: 'How the saved role differs from your edits', desc: 'Jonas Weber saved changes to Contributor at 11:02.', close: true, cancel: 'Use the saved role', confirm: 'Keep my edits', top: 100, w: 520 }, D('border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Setting'), S('', 'Saved'), S('', 'Yours')), ...[['Events · Update', 'Own', 'All'], ['Events · Delete', 'None', 'Own']].map(r => D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)', S('font-weight:500', r[0]), S('', r[1]), S('font-weight:600', r[2]))))) } }
else if (k === 'noschema') { shell.display = 'Elena Rossi'; shell.role = 'admin'; o = { open: [], noSchema: true } }
else if (k === 'reset-confirm' || k === 'reset-toast') { R = ROLES.writer; shell.mTitle = 'Writer'; crumbs('Settings', 'Roles', 'Writer'); o = { open: [], alerts: [Banner('warning', 'triangle-alert', 'This role has 5 members', 'Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default.')], over: k === 'reset-confirm' ? Shell({ title: 'Reset Writer to default?', desc: 'Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Reset to default', tone: 'danger', top: 140 }, [Mu('Your unsaved changes to Writer are discarded.')]) : Toast('success', 'circle-check', 'Role reset', 'Writer is back to its defaults. Save to apply it to 5 members.') } }
else if (k === 'review-name') { o = { open: [], chg: ['Events.update'], alerts: [Banner('warning', 'triangle-alert', 'This role changed while you were editing', 'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.', 'Reload role', 'Review differences')], bar: SaveBar(3, { sdis: true, msg: '3 unsaved changes. Save is off until you reload' }), over: Shell({ title: 'How the saved role differs from your edits', desc: 'Jonas Weber saved changes to Contributor at 11:02.', close: true, cancel: 'Use the saved role', confirm: 'Keep my edits', top: 100, w: 520 }, D('border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr);gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Setting'), S('', 'Saved'), S('', 'Yours')), ...[['Name', 'Contributor', 'Event writer'], ['Description', 'Writes posts', 'Writes events'], ['Events · Update', 'Own', 'All']].map(r => D('display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr);gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)', S('font-weight:500', r[0]), S('', r[1]), S('font-weight:600', r[2]))))) } }
else if (k === 'dup') { R = { ...ROLES.contrib, name: 'Contributor copy', members: 0 }; crumbs('Settings', 'Roles', 'New role'); shell.mTitle = 'New role'; o = { isNew: true, open: [], alerts: [Banner('info', 'copy', 'Copied from Contributor', 'The permissions are the same. Give the copy a name that fits, then save.')], bar: SaveBar(0, { sdis: false, msg: 'Copy not saved yet' }) } }
else if (k === 'invname') { R = { ...ROLES.blank, name: '' }; o = { isNew: true, nameErr: 'Give the role a name.', bar: SaveBar(0, { sdis: false, msg: '1 problem to fix' }) }; shell.mTitle = 'New role' }
else if (k === 'e400') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], bar: SaveBar(2), over: Toast('error', 'circle-x', 'Couldn\'t save the role', 'Something in the form isn\'t valid. Check the highlighted fields.') } }
else if (k === 'nf') { crumbs('Settings', 'Roles'); main = Center('lock', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.', B('Back to Roles', { k: 'primary', icon: 'arrow-left', px: 14 })); R = null }
else if (k === 'kept') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(2), over: Toast('info', 'info', 'Role reloaded', 'Your 2 changes are still here. Review them, then save.') } }
else if (k === 'saveerr') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], alerts: [Banner('error', 'circle-x', 'Couldn\'t save the role', 'Your changes are still here. Try again in a moment.', 'Try again')], bar: SaveBar(2) } }
else if (flow) {
const st = flow
if (st === 2) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own' }; o = { isNew: true, focus: 'desc', open: [], bar: SaveBar(2) }; shell.mTitle = 'New role' }
if (st === 3) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own', all: 'a1onn', t: mkT('a1onn') }; o = { isNew: true, open: [], bar: SaveBar(7), chg: ['Articles.read', 'Articles.create', 'Articles.update'], pvChg: true }; shell.mTitle = 'New role' }
if (st === 4) { R = { ...ROLES.contrib, g: { ...ROLES.contrib.g, schema: '100' } }; o = { isNew: true, open: ['schema', 'media'], scroll: 0, bar: SaveBar(9), noPv: false }; shell.mTitle = 'New role' }
if (st === 5) { R = ROLES.contrib; o = { isNew: true, open: [], bar: SaveBar(9), ring: null }; shell.mTitle = 'New role' }
if (st === 6) { o = { open: [], over: Toast('success', 'circle-check', 'Role created', 'Contributor is ready. Assign it from Users.') } }
}
if (R) { const out = Editor(R, o)
main = out[0]; if (out[1]) ov.push(out[1]) }
if (s.sheet && m) ov.push(PvSheet(R, { chg: !!o.pvChg, foot: [B('Close', { full: true })] }))
} else if (v === 'pv') {
let R = ROLES.contrib; crumbs('Settings', 'Roles', 'Contributor')
if (k === 'empty') { R = { ...ROLES.blank, name: 'New role' } }
if (k === 'readonly') { R = ROLES.writer }
const o = { ro: k === 'readonly', noPv: false, open: [], pvChg: k === 'live', chg: k === 'live' ? ['Events.update'] : [] }
if (k === 'live') R = { ...R, t: { ...R.t, Events: 'a1aan' } }
if (k === 'empty') o.isNew = true
const out = Editor(R, o); main = out[0]
if (m) ov.push(PvSheet(R, { chg: k === 'live', ro: k === 'readonly', foot: [B('Close', { full: true })] }))
} else if (v === 'del') {
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px'
const rows = ['owner', 'admin', 'writer', 'contrib', 'lead']
main = Page(m ? '16px' : '24px', Title('Roles', 'Who can do what on this site. Each user has one role.', B('New role', { k: 'primary', icon: 'plus', px: 14 })), m ? D('display:flex;flex-direction:column;gap:8px', ...rows.slice(2).map(r => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(ROLES[r].name), D('flex:1'), ROLES[r].built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), Mu(ROLES[r].desc)))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Role', 'Description', 'Members', 'Type', ''), ...rows.map(r => { const R = ROLES[r]; return Tr(cols, {}, Hi(R.name), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots()) })))
const pick = (R, o = {}) => F('Move members to', o.val || '', { req: true, sel: true, phText: 'Choose a role', focus: o.open, help: 'They keep their API keys. Keys follow the new role.' })
const rp = (n, sum, dis, why) => D('padding:8px 10px;display:flex;flex-direction:column;gap:2px;opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 52 : 44) + 'px;border-top:1px solid var(--ui-border-muted)', D('display:flex;gap:6px;align-items:center', Hi(n), dis && I('lock', 12)), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', dis ? why : sum))
if (k === 'blocked' || k === 'picker') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 140 }, [pick(null, { open: k === 'picker' }), k === 'picker' && D('border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);background:var(--ui-bg);margin-top:-8px', rp('Writer', 'Reads everything and writes content. Can\'t delete or publish.'), rp('Content lead', 'Edits and publishes all content.'), rp('Admin', 'Everything except owner-only settings.', true, 'Your role can\'t assign a role with more power than yours.'))]))
if (k === 'confirm') ov.push(Shell({ title: 'Delete Intern?', desc: 'No one has this role. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete role', tone: 'danger', top: 140 }, [Mu('Deleting a role doesn\'t affect content.')]))
if (k === 'builtin') ov.push(Shell({ title: 'Delete Writer?', desc: 'Built-in roles can\'t be deleted.', close: true, cancel: 'Close', confirm: 'Delete role', cdis: true, tone: 'danger', top: 140 }, [Banner('info', 'lock', 'Writer is built in', 'Duplicate it to make a custom role you can change or delete.', 'Duplicate role')]))
const pk = (R2, o2) => pick(R2, o2)
if (k === 'gone') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 100 }, [Alert('error', 'circle-x', 'Writer no longer exists', 'It was deleted somewhere else. Choose another role to move the members to.'), pk(null, {})]))
if (k === 'newmem') ov.push(Shell({ title: 'Delete Intern?', close: true, cancel: 'Cancel', confirm: 'Move 1 member and delete role', cdis: true, tone: 'danger', top: 100 }, [Alert('warning', 'triangle-alert', 'Role still has members', 'Intern now has 1 member. Move them to another role first.'), pk(null, {})]))
if (k === 'moveerr') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: false, tone: 'danger', top: 100 }, [Alert('error', 'circle-x', 'Couldn\'t move the members', 'Nothing was changed. Try again in a moment.'), pk(null, { val: 'Writer' })]))
} else if (v === 'users') {
shell.nav = 'users'; shell.sideOpen = false; crumbs('Users'); const cols = 'minmax(0,1fr) minmax(0,1.2fr) 150px 110px 40px'
const U = [['Marta Alves', 'marta@renewbuild.example', 'Owner', 'Active'], ['Jonas Weber', 'jonas@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active'], ['Pedro Lima', 'pedro@renewbuild.example', 'Contributor', 'Active'], ['sofia@renewbuild.example', 'Invited', 'Content lead', 'Pending']]
if (k === 'rolechanged') U[2][2] = 'Content lead'
const rows = U.map((u, i) => i === 4 ? [u[0], '', u[2], 'Invite pending'] : u)
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...rows.map((u, i) => Tr(cols, { sel: k === 'menu' && i === 2 }, Hi(u[0]), Mu(u[1]), D('display:flex;align-items:center;gap:6px', RB(u[2]), i === 0 && I('lock', 14, 'color:var(--ui-text-muted)')), Mu(u[3]), Dots(k === 'menu' && i === 2 || k === 'ownermenu' && i === 0 || k === 'selfmenu' && i === 1))),
k === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
k === 'selfmenu' && Menu(24, 143, [['user-cog', 'Change role', true], ['user-x', 'Remove user', true]]), k === 'selfmenu' && D('position:absolute;z-index:31;right:260px;top:150px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can\'t change your own role'), k === 'selfmenu' && D('position:absolute;z-index:31;right:260px;top:196px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can\'t remove yourself. Ask another owner or admin.'),
k === 'ownermenu' && Menu(24, 96, [['user-cog', 'Change role', true], ['crown', 'Transfer ownership', false, true], ['user-x', 'Remove user', true]]), k === 'ownermenu' && D('position:absolute;z-index:31;right:260px;top:140px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'The owner can\'t be changed or removed. Transfer ownership first.'))
const cards = D('display:flex;flex-direction:column;gap:8px', ...rows.map(u => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(u[0]), D('flex:1'), IB('ellipsis')), u[1] && Mu(u[1]), D('display:flex;gap:8px;align-items:center', RB(u[2]), Mu(u[3])))))
main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14 })), m ? cards : tbl, m && Fab('Invite user'))
const radio = (n, sum, on, dis, why, sel) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on || sel ? 'var(--ui-primary)' : 'var(--ui-border)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 56 : 52) + 'px', S('margin-top:2px;width:16px;height:16px;flex-shrink:0;border:1px solid var(--ui-border-accented);border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ui-bg)', (on || sel) && S('width:8px;height:8px;border-radius:50%;background:var(--ui-primary)')), D('flex:1;display:flex;flex-direction:column;gap:1px', D('display:flex;gap:6px;align-items:center', S('font-weight:600', n), dis && I('lock', 12), on && S('font-size:12px;font-weight:500', '· Current')), S('font-size:12px;line-height:18px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), dis ? why : sum)))
const SUM = { Owner: ROLES.owner.desc, Admin: ROLES.admin.desc, Writer: ROLES.writer.desc, Contributor: ROLES.contrib.desc, 'Content lead': ROLES.lead.desc }
if (k === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead']))))
if (k === 'changeblock') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin, false, false), radio('Owner', SUM.Owner, false, true, 'There is one owner. Transfer ownership to change who it is.'), radio('Writer', SUM.Writer, true), radio('Content lead', SUM['Content lead'], false, true, 'It can publish. Your role can\'t grant that.')))) }
if (k === 'invite') ov.push(Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 120 }, [F('Email', 'sofia@renewbuild.example', {}), F('Role', 'Writer', { sel: true, help: SUM.Writer }), Mu('Writer is the default. You can change a role later.')]))
const chOpts = [radio('Admin', SUM.Admin, true), radio('Writer', SUM.Writer), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true)]
if (k === 'changesame') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('info', 'info', 'Ines Duarte is now Admin', 'Someone changed their role while this dialog was open. Your choice is already their role, so there is nothing to change.'), radio('Admin', SUM.Admin, true), radio('Writer', SUM.Writer), radio('Contributor', SUM.Contributor))))
if (k === 'changestale') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Ines Duarte is now Admin. Your choice is kept. Check it and try again.'), ...chOpts)))
if (k === 'changeerr') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('error', 'circle-x', 'Couldn\'t change the role', 'Nothing was changed. Try again in a moment.'), radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true))))
const inv = (email, eerr, role, rerr, alert) => Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 80 }, [alert, F('Email', email, { err: eerr }), F('Role', role, { sel: true, err: rerr, help: rerr ? null : SUM[role] }), Mu('Writer is the default. You can change a role later.')].filter(Boolean))
if (k === 'invbad') ov.push(inv('sofia@renewbuild', 'Enter a valid email address.', 'Writer'))
if (k === 'invdup') ov.push(inv('ines@renewbuild.example', 'This address already has an account.', 'Writer'))
if (k === 'invesc') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(inv('sofia@renewbuild.example', null, 'Content lead', 'Your role can\'t assign a role with more power than yours.')) }
if (k === 'inverr') ov.push(inv('sofia@renewbuild.example', null, 'Writer', null, Alert('error', 'circle-x', 'Couldn\'t send the invite', 'Try again in a moment.')))
if (k === 'tr2stale') ov.push(Shell({ title: 'Transfer ownership to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 40 }, [Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.', 'Reload member'), D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless they transfer it.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild.example', { focus: true, help: 'The email must match exactly.' })]))
if (k === 'rolechanged') ov.push(Toast('success', 'circle-check', 'Role changed', 'Ines Duarte is now Content lead, and their API keys follow the new role.'))
if (k === 'tstale') ov.push(Toast('warning', 'triangle-alert', 'Role not changed', 'Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again.', 'Reload'))
if (k === 'ttr') ov.push(Toast('warning', 'triangle-alert', 'Ownership not transferred', 'Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again.', 'Reload'))
if (k === 'selfmenu') { shell.role = 'admin'; shell.display = 'Jonas Weber' }
const rem = (o, alert) => Shell({ title: 'Remove Ines Duarte?', desc: 'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.', close: true, cancel: 'Cancel', confirm: 'Remove user', tone: 'danger', top: 100, ...o }, [alert].filter(Boolean))
if (k === 'remdlg') ov.push(rem({}))
if (k === 'remsend') ov.push(rem({ confirm: 'Removing…', loading: true }))
if (k === 'remerr') ov.push(rem({}, Alert('error', 'circle-x', 'Couldn\'t remove the user', 'Nothing was changed. Try again in a moment.')))
if (k === 'remok') ov.push(Toast('success', 'circle-check', 'User removed', 'Ines Duarte no longer has access.'))
if (k === 'trem') ov.push(Toast('warning', 'triangle-alert', 'User not removed', 'Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again.', 'Reload'))
if (k === 'pending') { shell.display = 'Marta Alves'; ov.push(D('display:contents')) }
if (k === 'tr1' || k === 'tr2' || k === 'tr3') {
const tit = 'Transfer ownership'
if (k === 'tr1') ov.push(Shell({ title: tit, desc: 'Choose who becomes the owner. Only members can be chosen.', close: true, cancel: 'Cancel', confirm: 'Continue', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Jonas Weber', 'jonas@renewbuild.example · Admin', true), radio('Ines Duarte', 'ines@renewbuild.example · Writer'), radio('Pedro Lima', 'pedro@renewbuild.example · Contributor'))))
if (k === 'tr2') ov.push(Shell({ title: tit + ' to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 80 }, [D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless they transfer it.', 'Only the owner can apply updates, delete a site and change activity retention.', 'Your API keys follow your new role.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild', { focus: true, help: 'The email must match exactly.' })]))
if (k === 'tr3') { main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14, dis: false })), m ? cards : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...[['Jonas Weber', 'jonas@renewbuild.example', 'Owner', 'Active'], ['Marta Alves', 'marta@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active']].map(u => Tr(cols, {}, Hi(u[0]), Mu(u[1]), RB(u[2]), Mu(u[3]), Dots())))); shell.role = 'admin'; ov.push(Toast('success', 'circle-check', 'Ownership transferred', 'Jonas Weber is now the owner. You are an Admin.')) }
}
} else if (v === 'shell') {
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.nav = k.startsWith('art') ? 'articles' : 'content'; shell.sideOpen = false
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
const lk = (t) => S('display:inline-flex;align-items:center;gap:4px;color:var(--ui-text-muted);font-size:12px', I('lock', 12), t)
const entry = (t, by, st, own, o2 = {}) => Tr(cols, { dim: false, sel: o2.sel }, D('display:flex;align-items:center;gap:8px;min-width:0', Hi(t), !own && lk('View only')), Mu(by), Bd(st, st === 'Published' ? 'ok' : 'req'), Mu('Oct 14'), Dots(o2.menu))
const cardE = (t, by, st, own) => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(t), D('flex:1'), IB('ellipsis')), D('display:flex;gap:8px;align-items:center', Bd(st, st === 'Published' ? 'ok' : 'req'), Mu(by), !own && lk('View only')))
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Published', 1], ['Choosing lime plaster', 'Pedro Lima', 'Draft', 1], ['Retrofit grants in 2027', 'Jonas Weber', 'Published', 1], ['Site safety checklist', 'Pedro Lima', 'Draft', 1]]
const own = k === 'own' || k === 'ownmenu'
const E = [['Lime plaster workshop', 'Pedro Lima', 'Draft', 1], ['Open site day', 'Ines Duarte', 'Published', 0], ['Retrofit talk', 'Jonas Weber', 'Published', 0], ['Tool library evening', 'Pedro Lima', 'Draft', 1]]
const data = own ? E : A
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map((r, i) => entry(r[0], r[1], r[2], r[3], { menu: (k === 'artmenu' && i === 1) || (k === 'ownmenu' && i === 1) })),
k === 'artmenu' && Menu(24, 140, [['pencil', 'Edit', false, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
k === 'artmenu' && D('position:absolute;z-index:31;right:260px;top:196px;width:230px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'Your role can\'t delete articles.'),
k === 'ownmenu' && Menu(24, 140, [['pencil', 'Edit', true, true], ['copy', 'Duplicate'], ['trash-2', 'Delete', true, false, true]]),
k === 'ownmenu' && D('position:absolute;z-index:31;right:260px;top:150px;width:240px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'You can only edit content you created.'))
if (k === 'art' || k === 'artmenu' || k === 'own' || k === 'ownmenu') {
const ttl = own ? 'Events' : 'Articles'; crumbs(own ? 'Content' : 'Articles', ...(own ? ['Events'] : [])); if (own) shell.contentOn = 'events'
main = Page(m ? '16px 16px 88px' : '24px', Title(ttl, own ? 'You can create entries and edit your own.' : 'You can write and edit all articles. Publishing is done by an editor.', B(own ? 'New entry' : 'New article', { k: 'primary', icon: 'plus', px: 14 })), own && Banner('info', 'info', 'Entries by others are view only', 'Your role lets you edit entries you created.'), m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => cardE(r[0], r[1], r[2], r[3]))) : tbl, m && Fab(own ? 'New entry' : 'New article'))
}
if (k === 'entry') {
crumbs('Articles', 'Choosing lime plaster'); shell.mLead = 'arrow-left'
const side = D('width:' + (m ? 'auto' : '280px') + ';flex-shrink:0;display:flex;flex-direction:column;gap:12px;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Status'), D('display:flex;align-items:center;gap:8px', Bd('Draft', 'req'), Mu('Saved at 11:02')), D('display:flex;gap:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px', I('info', 14, 'margin-top:2px'), 'Ask an editor to publish.'))
const ed = D('flex:1;min-width:0;display:flex;flex-direction:column;gap:12px', F('Title', 'Choosing lime plaster', { req: true }), D('border:1px solid var(--ui-border-accented);background:var(--ui-bg);min-height:' + (m ? 220 : 380) + 'px;padding:12px;color:var(--ui-text-toned);text-wrap:pretty', 'Lime plaster lets a wall move moisture in and out. Before you pick one, check what the wall is made of.'))
main = Page(m ? '16px 16px 88px' : '24px', Title('Choosing lime plaster', null, B('Save draft', { k: 'primary', px: 14 })), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px;align-items:flex-start;flex:1;min-height:0', ed, side))
}
if (k === 'rule') {
shell.role = 'user'; crumbs('Home'); shell.nav = 'home'
const col = (t, sub, ...c) => D('flex:1;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), Mu(sub), ...c)
const r = (ic, t, ex) => D('display:flex;gap:8px;align-items:flex-start', IC(ic), D('display:flex;flex-direction:column;gap:2px', Hi(t), Mu(ex)))
main = Page(m ? '16px' : '24px', Title('Home', 'Pedro Lima · Contributor'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px', col('Hidden', 'The person can never use it.', r('eye-off', 'Sections of the sidebar', 'Schema, Users, Integration and Settings are not listed.'), r('eye-off', 'Buttons for another area', 'No Publish button when the role can\'t publish.')), col('Disabled, with a reason', 'The person sees the thing but can\'t act on it.', r('ban', 'Delete on an entry they can see', 'Your role can\'t delete articles.'), r('ban', 'Edit on someone else\'s entry', 'You can only edit content you created.')), col('Read-only', 'The person may look but not change.', r('lock', 'Settings pages for an admin', 'A banner says who can edit.'))))
}
} else if (v === 'rdown') {
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false
const ent = k === 'ent' || k === 'ent0'
shell.nav = ent ? 'content' : 'articles'; if (ent) shell.contentOn = 'events'
crumbs(...(ent ? ['Content', 'Events'] : ['Articles']))
const Tabs = (items, on) => D('display:flex;gap:' + (m ? 16 : 24) + 'px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...items.map(([tt, n]) => D('display:flex;align-items:center;gap:6px;height:' + (m ? 44 : 36) + 'px;margin-bottom:-1px;border-bottom:2px solid ' + (on === tt ? 'var(--ui-primary)' : 'transparent') + ';color:' + (on === tt ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';font-weight:' + (on === tt ? 600 : 500), tt, S('font-size:12px;font-weight:400;color:var(--ui-text-muted)', String(n)))))
if (!ent) {
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Online'], ['Retrofit grants in 2027', 'Jonas Weber', 'Online'], ['Winter checklist for timber windows', 'Marta Alves', 'Online'], ['Choosing lime plaster', 'Pedro Lima', 'Draft'], ['Site safety checklist', 'Pedro Lima', 'Draft']]
const d0 = k === 'draft0'
const data = d0 ? [] : A
const body = d0 ? Empty('file-text', 'You have no drafts', 'Drafts you create appear here. Your role can\'t see drafts by other people.', 'New article') : m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => Cd({}, Hi(r[0]), D('display:flex;gap:8px;align-items:center', Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu(r[1]))))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu('Oct 14'), Dots())))
main = Page(m ? '16px 16px 88px' : '24px', Title('Articles', 'Online articles and the drafts you created.', B('New article', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Drafts by others are hidden', 'Your role reads online articles and the drafts you created. The counts follow the same rule.'), Tabs(d0 ? [['All', 3], ['Online', 3], ['Draft', 0]] : [['All', 5], ['Online', 3], ['Draft', 2]], d0 ? 'Draft' : 'All'), body, m && Fab('New article'))
} else {
const cols = 'minmax(0,1fr) 140px 120px 40px'
const En = [['Lime plaster workshop', '21 Nov 2026', 'Oct 14'], ['Tool library evening', '5 Dec 2026', 'Oct 12'], ['Site safety talk', '18 Dec 2026', 'Oct 9']]
const e0 = k === 'ent0'
const body = e0 ? Empty('files', '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.', 'New entry') : D('display:flex;flex-direction:column;gap:8px', Mu('3 entries'), m ? D('display:flex;flex-direction:column;gap:8px', ...En.map(r => Cd({}, Hi(r[0]), Mu(r[1] + ' · updated ' + r[2])))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Starts on', 'Updated', ''), ...En.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Mu(r[2]), Dots()))))
main = Page(m ? '16px 16px 88px' : '24px', Title('Events', 'Your role shows only entries you created.', B('New entry', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Entries by others aren\'t shown', 'Your role reads only entries you created. Ask an owner or admin if you need to see more.'), body, m && Fab('New entry'))
}
} else if (v === 'admin') {
shell.role = 'admin'; shell.display = 'Jonas Weber'; shell.nav = 'settings'; shell.sideOpen = true
if (k === 'ro') { crumbs('Settings', 'General'); main = Page(m ? '16px 16px 88px' : '24px', Title('General', 'Site name, language and defaults.'), Banner('info', 'lock', 'You can read these settings but not change them', 'Ask the owner to change them, or ask for a role that can edit settings.'), Card('Site', null, null, F('Site name', 'Renew Build', { lock: true }), F('Default language', 'English', { lock: true, sel: false })), D('display:flex;gap:8px;opacity:0.4', B('Save changes', { k: 'primary' }))) }
if (k === 'owneronly') { crumbs('Settings', 'Software'); main = Page(m ? '16px' : '24px', Title('Software', 'Version 2.8.0'), Card('Update available', 'Version 2.9.0 is ready to install.', Bd('Owner only', 'lock', 'lock'), D('display:flex;align-items:center;gap:12px;flex-wrap:wrap', B('Apply update', { k: 'primary', icon: 'download', dis: true }), S('color:var(--ui-text-muted)', 'Only the owner can apply updates.'))), Card('Activity retention', 'How long the activity log is kept.', Bd('Owner only', 'lock', 'lock'), F('Keep activity for', '12 months', { lock: true, help: 'Admins can read the log. Only the owner can change how long it is kept.' }))) }
} else if (v === 'forbid') {
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; shell.nav = 'home'; crumbs('Roles')
if (k === 'page') main = Center('lock', 'You don\'t have access to this page', 'Your role, Contributor, doesn\'t include it. Ask an owner or admin if you need it.', B('Go to Articles', { k: 'primary', px: 16 }), B('See what I can do'))
if (k === 'missing') { crumbs('Articles', 'Choosing lime plaster'); shell.nav = 'articles'; main = Page(m ? '16px' : '24px', Title('Choosing lime plaster'), F('Title', 'Choosing lime plaster', {}), D('background:var(--ui-bg);border:1px solid var(--ui-border);min-height:180px;padding:12px;color:var(--ui-text-toned)', 'Lime plaster lets a wall move moisture in and out.')); ov.push(Toast('error', 'circle-x', 'You don\'t have permission to do that', 'Your role doesn\'t include Delete entries. Ask an owner or admin.')) }
if (k === 'locked') { shell.role = 'admin'; shell.display = 'Jonas Weber'; crumbs('Settings', 'Roles', 'Owner'); main = Page(m ? '16px' : '24px', Title('Owner'), F('Name', 'Owner', { lock: true })); ov.push(Toast('error', 'lock', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')) }
} else if (v === 'act') {
shell.nav = 'activity'; shell.sideOpen = false; crumbs('Activity'); const cols = '28px minmax(0,1fr) 140px 120px 40px'
const OKR = [['shield', 'Role Contributor created', 'Marta Alves', 'Oct 14, 10:41'], ['pencil', 'Role Contributor edited', 'Marta Alves', 'Oct 14, 10:52'], ['user-cog', 'Ines Duarte\'s role changed from Writer to Content lead', 'Marta Alves', 'Oct 14, 11:02'], ['crown', 'Ownership transferred from Marta Alves to Jonas Weber', 'Marta Alves', 'Oct 15, 09:10'], ['users', 'Moved 3 members from Intern to Writer', 'Marta Alves', 'Oct 15, 09:29'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
const FLR = [['shield', 'Failed to create the role Contributor', 'Marta Alves', 'Oct 16, 09:02', 1], ['pencil', 'Failed to edit the role Contributor', 'Marta Alves', 'Oct 16, 09:20', 1], ['user-cog', 'Failed to change the role of Ines Duarte', 'Jonas Weber', 'Oct 16, 10:05', 1], ['crown', 'Failed to transfer ownership to Jonas Weber', 'Marta Alves', 'Oct 16, 10:30', 1], ['users', 'Failed to move 3 members from Intern to Writer', 'Marta Alves', 'Oct 16, 11:12', 1], ['trash-2', 'Failed to delete the role Intern', 'Marta Alves', 'Oct 16, 11:13', 1]]
const E = k === 'failed' || k === 'faildetail' ? FLR : OKR
const hot = (k === 'detail' || k === 'faildetail') ? 1 : -1
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, '', 'Event', 'By', 'When', ''), ...E.map((e, i) => Tr(cols, { sel: i === hot }, I(e[0], 16, 'color:var(--ui-text-toned)'), D('display:flex;align-items:center;gap:8px;min-width:0', Hi(e[1]), e[4] && Bd('Failed', 'err', 'circle-x')), Mu(e[2]), Mu(e[3]), I('chevron-right', 16, 'color:var(--ui-text-muted)'))))
const cards = D('display:flex;flex-direction:column;gap:8px', ...E.map(e => Cd({}, D('display:flex;gap:8px', IC(e[0]), Hi(e[1])), D('display:flex;gap:8px;align-items:center', e[4] && Bd('Failed', 'err', 'circle-x'), Mu(e[2] + ' · ' + e[3])))))
const CATS = ['Articles', 'Media', 'Users and invites', 'Roles', 'API keys', 'Authentication', 'System']
const fbar = k === 'cats' && D('position:relative;flex-shrink:0;height:' + (m ? 44 : 32) + 'px;width:' + (m ? '100%' : '240px'), F('', 'Roles', { sel: true, ico: 'layout-list', focus: true }), D('position:absolute;z-index:30;left:0;right:0;top:' + (m ? 48 : 36) + 'px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;color:var(--ui-text-muted)', 'All categories'), ...CATS.map(c => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);background:' + (c === 'Roles' ? 'var(--ui-bg-elevated)' : 'transparent'), S('flex:1', c), c === 'Roles' && I('check', 16)))))
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), fbar, m ? cards : tbl)
if (k === 'detail' || k === 'faildetail') { const fd = k === 'faildetail'; const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi(fd ? 'Failed to edit the role Contributor' : 'Role Contributor edited'), Mu(fd ? 'Marta Alves · Oct 16, 09:20' : 'Marta Alves · Oct 14, 10:52')), fd ? Alert('error', 'circle-x', 'Why it failed', 'Jonas Weber saved changes to Contributor first, so this edit was refused.') : D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-weight:600;color:var(--ui-text-highlighted)', 'What changed'), D('display:flex;gap:8px', Bd('Added', 'ok', 'plus'), S('color:var(--ui-text-highlighted)', 'Delete entries (Own)')), D('display:flex;gap:8px', Bd('Removed', 'err', 'minus'), S('color:var(--ui-text-highlighted)', 'Publish'))), Mu(fd ? 'The role was not changed.' : '4 members have this role.')); ov.push(m ? Sheet({ title: 'Activity details' }, body, [B('Close', { full: true })]) : D('position:absolute;inset:0;z-index:40;background:var(--rc-overlay);display:flex;justify-content:flex-end', D('width:440px;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('padding:20px 24px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), IB('x')), D('padding:20px 24px', body)))) }
} else if (v === 'prof') {
shell.nav = 'profile'; shell.role = k === 'owner' ? 'owner' : 'user'; shell.display = k === 'owner' ? 'Marta Alves' : 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
const R = k === 'owner' ? ROLES.owner : ROLES.contrib
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account on this RenewCMS workspace.'), Card('Display name', 'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.', null, F('Display name', k === 'dn-long' ? 'Marta Alves, Head of Renovations, Brussels and Wallonia office' : k === 'owner' || k === 'dn-removed' ? '' : shell.display, { opt: true, phText: 'e.g. Marta Alves', err: k === 'dn-long' ? 'Display name must be 60 characters or fewer.' : undefined })), Card('Account', 'Set when your account was created. You log in with your username, even after adding a display name.', null, F('Username', k === 'owner' ? 'marta' : 'pedro', { lock: true }), F('Email', k === 'owner' ? 'marta@renewbuild.example' : 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('display:inline-flex;align-items:center;height:22px;padding:0 8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:400 12px \'IBM Plex Mono\',monospace', R.name.toLowerCase()), R.lock && Bd('Locked', 'lock', 'lock'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), D('display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500', I('list-checks', 16), Link('See what I can do')), Mu('Your API keys follow this role. If your role changes, they change too.')), Card('Author profile', 'Your public byline on articles. It\'s separate from your account.', null, Mu('You don\'t have an author profile yet. Create one to be credited on your own articles.'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';align-items:' + (m ? 'stretch' : 'center') + ';gap:12px', B('Create my author profile', { k: 'primary', icon: 'feather', px: 12, full: m }), Mu(k === 'owner' ? 'Uses your username, marta. You can change it afterwards.' : 'Uses your display name, ' + shell.display + '. You can change it afterwards.'))))
if (k === 'dn-saved') ov.push(Toast('success', 'circle-check', 'Display name saved', 'You now appear as ' + shell.display + ' across RenewCMS and in emails.'))
if (k === 'dn-removed') ov.push(Toast('success', 'circle-check', 'Display name removed', 'You now appear as your username, pedro.'))
if (k === 'sheet') { const body = [PvBody(R, { ro: true })]; ov.push(m ? PvSheet(R, { ro: true, title: 'What you can do', sub: 'This is a summary of your role.', foot: [B('Close', { full: true })] }) : Shell({ title: 'What you can do', desc: 'A summary of your role, Contributor. Read-only.', close: true, cancel: 'Close', top: 60 }, body)) }
} else if (v === 'keys') {
shell.nav = 'settings'; shell.sideOpen = true; crumbs('Settings', 'API keys'); const cols = 'minmax(0,1fr) 140px 130px 110px 40px'
main = Page(m ? '16px' : '24px', Title('API keys', 'Keys for apps that read or write your content.', B('New key', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'info', 'Keys follow their creator\'s role', 'A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it.'), m ? D('display:flex;flex-direction:column;gap:8px', Cd({}, Hi('Website build'), Mu('Marta Alves · Owner')), Cd({}, Hi('Newsletter sync'), Mu('Pedro Lima · Contributor'))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Created by', 'Follows role', 'Created', ''), Tr(cols, {}, Hi('Website build'), Mu('Marta Alves'), RB('Owner'), Mu('Sep 2'), Dots()), Tr(cols, {}, Hi('Newsletter sync'), Mu('Pedro Lima'), RB('Contributor'), Mu('Oct 3'), Dots())))
}
if (m && shell.drawer === undefined && s.drawer) shell.drawer = true
const framed = true
return { framed, bare: false, shell, mob: m, main, ov: D('display:contents', ...ov), bw: '1440px', bh: '900px' }
}
}
</script>
</body>
</html>
+14 -5
View File
@@ -209,7 +209,7 @@ class Component extends DCLogic {
const AF = cr ? [['Title', 'title', 'text', 'Required · unique · up to 80 characters'], ['Starts on', 'starts_on', 'date', 'Required'], ['Category', 'category', 'select', 'Talk, Workshop, Meetup'], ['Description', 'description', 'richtext', ''], ['Thumbnail', 'thumbnail', 'media', 'One image from the library']] : [['Title', 'title', 'text', 'Required · up to 200 characters'], ['Slug', 'slug', 'text', 'Required · unique'], ['Body', 'body', 'richtext', 'Up to 10,000 characters'], ['Online', 'online', 'boolean', 'Off by default'], ['Published on', 'published_on', 'date', '']] const AF = cr ? [['Title', 'title', 'text', 'Required · unique · up to 80 characters'], ['Starts on', 'starts_on', 'date', 'Required'], ['Category', 'category', 'select', 'Talk, Workshop, Meetup'], ['Description', 'description', 'richtext', ''], ['Thumbnail', 'thumbnail', 'media', 'One image from the library']] : [['Title', 'title', 'text', 'Required · up to 200 characters'], ['Slug', 'slug', 'text', 'Required · unique'], ['Body', 'body', 'richtext', 'Up to 10,000 characters'], ['Online', 'online', 'boolean', 'Off by default'], ['Published on', 'published_on', 'date', '']]
const cols = '28px minmax(0,1fr) minmax(0,1fr) 120px minmax(0,1.2fr)' const cols = '28px minmax(0,1fr) minmax(0,1fr) 120px minmax(0,1.2fr)'
main = Page(pad, D('display:flex;align-items:center;gap:12px;flex-shrink:0', I('lock', 18, 'color:var(--ui-text-muted)'), S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', cr ? 'Events' : 'Articles'), cr ? Bd('Read only', 'built', 'lock') : Bd('Built-in', 'built', 'lock')), main = Page(pad, D('display:flex;align-items:center;gap:12px;flex-shrink:0', I('lock', 18, 'color:var(--ui-text-muted)'), S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', cr ? 'Events' : 'Articles'), cr ? Bd('Read only', 'built', 'lock') : Bd('Built-in', 'built', 'lock')),
cr ? Alert('info', 'info', 'You can read these fields here', 'Only an owner or admin can change a content type.') : Alert('info', 'info', 'Managed by RenewCMS', 'Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.'), cr ? Alert('info', 'lock', 'You can read these fields here', 'Your role doesn\'t include Edit content types. Ask an owner or admin.') : Alert('info', 'info', 'Managed by RenewCMS', 'Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.'),
m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...AF.map(f => D('display:flex;gap:12px;align-items:center;padding:12px 16px;border-top:1px solid var(--ui-border);min-height:64px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated)', I(FT[f[2]][0], 16)), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500', f[0]), S('font-size:12px;color:var(--ui-text-muted)', S(mono, f[1]), f[3] ? ' · ' + f[3] : ''))))) : m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...AF.map(f => D('display:flex;gap:12px;align-items:center;padding:12px 16px;border-top:1px solid var(--ui-border);min-height:64px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated)', I(FT[f[2]][0], 16)), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500', f[0]), S('font-size:12px;color:var(--ui-text-muted)', S(mono, f[1]), f[3] ? ' · ' + f[3] : ''))))) :
D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', ''), S('', 'Label'), S('', 'Key'), S('', 'Type'), S('', 'Rules')), ...AF.map(f => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(FT[f[2]][0], 16, 'color:var(--ui-text-toned)'), S('font-weight:500', f[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f[1]), S('', FT[f[2]][1]), S('color:var(--ui-text-muted)', f[3] || '–')))), S('color:var(--ui-text-muted);font-size:12px', 'Entries of built-in types are managed from their own screens, like Articles and Authors.')) D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', ''), S('', 'Label'), S('', 'Key'), S('', 'Type'), S('', 'Rules')), ...AF.map(f => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(FT[f[2]][0], 16, 'color:var(--ui-text-toned)'), S('font-weight:500', f[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f[1]), S('', FT[f[2]][1]), S('color:var(--ui-text-muted)', f[3] || '–')))), S('color:var(--ui-text-muted);font-size:12px', 'Entries of built-in types are managed from their own screens, like Articles and Authors.'))
} else { } else {
@@ -234,6 +234,7 @@ class Component extends DCLogic {
if (k === 'media' || k === 'mreq') { fl = [...EV, k === 'mreq' ? { ...THUMB, req: true } : THUMB]; sel = 7 } if (k === 'media' || k === 'mreq') { fl = [...EV, k === 'mreq' ? { ...THUMB, req: true } : THUMB]; sel = 7 }
if (k === 'mnew') { fl = [...EV, { key: 'thumbnail', label: 'Thumbnail', type: 'media', isNew: true }]; sel = 7; st = 'dirty' } if (k === 'mnew') { fl = [...EV, { key: 'thumbnail', label: 'Thumbnail', type: 'media', isNew: true }]; sel = 7; st = 'dirty' }
if (['dirty', 'saving', 'saved', 'fail', 'incompat', 'offline'].includes(k)) { fl = DIRTY; sel = 4; st = k === 'incompat' ? 'dirty' : k; removed = EV[6] } if (['dirty', 'saving', 'saved', 'fail', 'incompat', 'offline'].includes(k)) { fl = DIRTY; sel = 4; st = k === 'incompat' ? 'dirty' : k; removed = EV[6] }
if (k === 'saving') info = false
if (k === 'saved') { removed = null; st = 'saved'; fl = DIRTY.map(f => ({ ...f, isNew: false, changed: false, saved: true })); toast = Toast('success', 'circle-check', 'Content type saved', 'Events now has 8 fields.') } if (k === 'saved') { removed = null; st = 'saved'; fl = DIRTY.map(f => ({ ...f, isNew: false, changed: false, saved: true })); toast = Toast('success', 'circle-check', 'Content type saved', 'Events now has 8 fields.') }
if (k === 'fail') banner = Alert('error', 'circle-x', 'Couldn\'t save the content type', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again') if (k === 'fail') banner = Alert('error', 'circle-x', 'Couldn\'t save the content type', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again')
if (k === 'incompat') { banner = Alert('error', 'circle-x', 'Nothing was changed', 'Some entries don\'t fit the new fields, so the whole change was refused. RenewCMS can\'t tell which entries. The changes that can cause this are marked.', 'Review changes'); cause = true } if (k === 'incompat') { banner = Alert('error', 'circle-x', 'Nothing was changed', 'Some entries don\'t fit the new fields, so the whole change was refused. RenewCMS can\'t tell which entries. The changes that can cause this are marked.', 'Review changes'); cause = true }
@@ -264,8 +265,9 @@ class Component extends DCLogic {
shell.nav = 'schema'; shell.crumbs = ['Schema', isNew ? 'New content type' : name]; shell.mTitle = isNew ? 'New content type' : name; shell.mLead = 'arrow-left'; shell.mAction = 'ellipsis' shell.nav = 'schema'; shell.crumbs = ['Schema', isNew ? 'New content type' : name]; shell.mTitle = isNew ? 'New content type' : name; shell.mLead = 'arrow-left'; shell.mAction = 'ellipsis'
shell.offline = false shell.offline = false
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', st === 'offline' && Offline(), top, infoBar, banner && D('padding:12px ' + (m ? 16 : 24) + 'px 0', banner), D('flex:1;min-height:0;display:flex;' + (m ? 'flex-direction:column' : ''), list, inspPane), m ? bar : ApiPanel(fl, key || 'events', api)) main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', st === 'offline' && Offline(), top, infoBar, banner && D('padding:12px ' + (m ? 16 : 24) + 'px 0', banner), D('flex:1;min-height:0;display:flex;' + (m ? 'flex-direction:column' : ''), list, inspPane), m ? bar : ApiPanel(fl, key || 'events', api))
if (m && f && (s.sheet || (v === 'builder' && ['edit-sheet', 'choices', 'media', 'mreq', 'mnew'].includes(k)))) ov.push(Sheet({ title: 'Field: ' + (f.label || 'Untitled field') }, Insp(f), [B('Done', { k: 'primary', full: true })])) if (m && f && (s.sheet || (v === 'builder' && ['edit-sheet', 'choices', 'media', 'mreq', 'mnew'].includes(k)))) ov.push(Sheet({ title: 'Field: ' + (f.label || 'Untitled field') }, Insp(f), [D('display:flex;gap:8px;width:100%', B('Move up', { icon: 'arrow-up', h: 44, full: true, dis: sel === 0 }), B('Move down', { icon: 'arrow-down', h: 44, full: true, dis: sel === fl.length - 1 }), B('Remove', { icon: 'trash-2', k: 'del', h: 44, full: true })), B('Done', { k: 'primary', full: true })]))
if (m && api) ov.push(Sheet({ title: 'API preview' }, [S('color:var(--ui-text-muted);font-size:12px', 'GET /v1/content-type/' + key + '/entry'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key))], [B('Copy JSON', { k: 'primary', full: true, icon: 'copy' })])) if (m && api) ov.push(Sheet({ title: 'API preview' }, [S('color:var(--ui-text-muted);font-size:12px', 'GET /v1/content-type/' + key + '/entry'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key))], [B('Copy JSON', { k: 'primary', full: true, icon: 'copy' })]))
if (v === 'builder' && k === 'saving') ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: 560, top: 60, cancel: 'Back to editing', confirm: 'Applying…', loading: true, cdis: true }, [D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Add 1 field, remove 1'), S('color:var(--ui-text-muted)', 'Events has no entries yet, so nothing is checked.'))]))
if (toast) ov.push(toast) if (toast) ov.push(toast)
} }
if (v === 'builder') { bld() } if (v === 'builder') { bld() }
@@ -400,12 +402,19 @@ class Component extends DCLogic {
D('display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--ui-bg-elevated)', S('', 'Each Event has', sel('one'), 'Venue'), S('', 'Each Venue has', sel('many'), 'Events', S('color:var(--ui-text-muted)', ' · shown on Venue as the field "Events"'))), D('display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--ui-bg-elevated)', S('', 'Each Event has', sel('one'), 'Venue'), S('', 'Each Venue has', sel('many'), 'Events', S('color:var(--ui-text-muted)', ' · shown on Venue as the field "Events"'))),
D('display:flex;gap:12px;flex-direction:' + (m ? 'column' : 'row'), card_('Event', S('color:var(--ui-text-muted);font-size:12px', 'Venue'), D('display:flex;gap:6px', chipR('Rue Verte hall'))), card_('Venue', S('color:var(--ui-text-muted);font-size:12px', 'Events'), D('display:flex;gap:6px;flex-wrap:wrap', chipR('Open day'), chipR('Lime plaster workshop'), S('font-size:12px;color:var(--ui-text-muted);align-self:center', '+1'))))) D('display:flex;gap:12px;flex-direction:' + (m ? 'column' : 'row'), card_('Event', S('color:var(--ui-text-muted);font-size:12px', 'Venue'), D('display:flex;gap:6px', chipR('Rue Verte hall'))), card_('Venue', S('color:var(--ui-text-muted);font-size:12px', 'Events'), D('display:flex;gap:6px;flex-wrap:wrap', chipR('Open day'), chipR('Lime plaster workshop'), S('font-size:12px;color:var(--ui-text-muted);align-self:center', '+1')))))
main = Page(pad, Title('Edit content type', 'Events · Relation field, shown as it will work later.'), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;overflow:hidden', insp)) main = Page(pad, Title('Edit content type', 'Events · Relation field, shown as it will work later.'), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;overflow:hidden', insp))
} else if (k === 'form') { } else if (k === 'form' || k === 'more') {
shell.nav = 'content'; shell.crumbs = ['Content', 'Events', 'Lime plaster workshop']; shell.mTitle = 'Lime plaster workshop'; shell.contentOn = 'events' shell.nav = 'content'; shell.crumbs = ['Content', 'Events', 'Lime plaster workshop']; shell.mTitle = 'Lime plaster workshop'; shell.contentOn = 'events'
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...[['Rue Verte hall', 'Lyon · 120 seats'], ['Rue Verte yard', 'Lyon · outdoor'], ['Rue du Port studio', 'Nantes · 24 seats']].map(([a, b], i) => D('min-height:' + (m ? 48 : 40) + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', a), S('font-size:12px;color:var(--ui-text-muted)', b)))) const NM = ['Rue Verte hall', 'Rue Verte yard', 'Rue du Port studio', 'Rue Verte annex', 'Rue de la Gare depot', 'Rue Verte workshop', 'Rue du Port loft', 'Rue des Halles hall', 'Rue Verte garden room', 'Rue du Moulin barn', 'Rue de Nantes studio', 'Rue Verte gallery', 'Rue du Port quay', 'Rue des Tanneurs hall', 'Rue Verte cellar', 'Rue du Four kitchen', 'Rue Verte terrace', 'Rue du Port warehouse', 'Rue des Écoles hall', 'Rue Verte pavilion', 'Rue du Lac studio', 'Rue Verte shed', 'Rue de la Paix room', 'Rue Verte forge', 'Rue du Port atelier']
const VN = NM.map((n, i) => [n, ['Lyon', 'Nantes', 'Lille'][i % 3] + ' · ' + (24 + (i * 17) % 120) + ' seats'])
const more = k === 'more', merr = k === 'moreerr', rh = m ? 48 : 40
const rw = ([x, y], i) => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 && !more ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', x), S('font-size:12px;color:var(--ui-text-muted)', y))
const sk = w => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;gap:6px', S('display:block;height:12px;width:' + w + ';background:var(--ui-bg-accented)'), S('display:block;height:10px;width:40%;background:var(--ui-bg-accented)'))
const rows = more ? [...VN.slice(22).map(rw), sk('55%'), sk('70%'), sk('45%')] : merr ? VN.slice(18).map(rw) : VN.slice(0, 5).map(rw)
const lm = S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-weight:500;font-size:12px;white-space:nowrap;opacity:' + (more ? 0.4 : 1), more && I('loader-circle', 14), more ? 'Loading…' : merr ? 'Try again' : 'Load more')
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop)', D('position:relative;padding:4px', ...rows, !more && !merr && S('position:absolute;right:3px;top:6px;width:4px;height:' + rh * 1.5 + 'px;background:var(--ui-bg-accented)')), D('display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border-top:1px solid var(--ui-border)', merr ? S('font-size:12px;line-height:16px;color:var(--ui-error);min-width:0;text-wrap:pretty', 'Couldn\'t load more results. The 25 shown are still here.') : S('font-size:12px;color:var(--ui-text-muted)', 'Showing 25 of 62'), lm))
const chipX = t => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:6px;background:var(--rc-sand);color:var(--rc-on-sand);font-weight:500', t, I('x', 14)) const chipX = t => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:6px;background:var(--rc-sand);color:var(--rc-on-sand);font-weight:500', t, I('x', 14))
main = Page(pad, Title('Lime plaster workshop', 'Events · Entry 42', Later('Relation field: later')), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:' + (m ? 16 : 20) + 'px;max-width:760px;overflow:visible;flex:1;min-height:0', Ctl(EV[0], { v: 'Lime plaster workshop', count: '21 / 80' }), main = Page(pad, Title('Lime plaster workshop', 'Events · Entry 42', Later('Relation field: later')), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:' + (m ? 16 : 20) + 'px;max-width:760px;overflow:visible;flex:1;min-height:0', Ctl(EV[0], { v: 'Lime plaster workshop', count: '21 / 80' }),
D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:176px')), D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:' + (m ? 300 : 280) + 'px')),
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Speakers'), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center;min-height:' + ih + 'px;padding:4px 8px;border:1px solid var(--ui-border-accented)', chipX('Inès Duarte'), chipX('Jonas Pereira'), S('color:var(--ui-text-muted);margin-left:4px', 'Search to add')), S('color:var(--ui-text-muted);font-size:12px', 'An event can have many speakers.')))) D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Speakers'), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center;min-height:' + ih + 'px;padding:4px 8px;border:1px solid var(--ui-border-accented)', chipX('Inès Duarte'), chipX('Jonas Pereira'), S('color:var(--ui-text-muted);margin-left:4px', 'Search to add')), S('color:var(--ui-text-muted);font-size:12px', 'An event can have many speakers.'))))
} else { } else {
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = 'events' shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = 'events'
+7 -6
View File
@@ -32,7 +32,7 @@ class Component extends DCLogic {
const BADGE = { const BADGE = {
ok: ['background:var(--rc-success-soft);color:var(--ui-success)', 'circle-check'], none: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-dashed'], ok: ['background:var(--rc-success-soft);color:var(--ui-success)', 'circle-check'], none: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-dashed'],
busy: ['background:var(--rc-info-soft);color:var(--ui-info)', 'loader-circle'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'], busy: ['background:var(--rc-info-soft);color:var(--ui-info)', 'loader-circle'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'],
used: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'], used: ['border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-toned)'], dup: ['background:var(--rc-info-soft);color:var(--ui-info)', 'copy'], unused: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'unlink'],
vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'], off: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-off'], warn: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'triangle-alert'] vars: ['background:var(--rc-info-soft);color:var(--ui-info)', 'layers'], off: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'circle-off'], warn: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'triangle-alert']
} }
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t) const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BADGE[k][0], BADGE[k][1] && I(BADGE[k][1], 12), t)
@@ -107,7 +107,7 @@ class Component extends DCLogic {
S('color:var(--ui-text-toned);' + MONO + ';font-size:12px', dim(v) + ' · ' + v.fit + ' · ' + v.fmt + (v.q ? ' · q' + v.q : '')), S('color:var(--ui-text-toned);' + MONO + ';font-size:12px', dim(v) + ' · ' + v.fit + ' · ' + v.fmt + (v.q ? ' · q' + v.q : '')),
D('display:flex;align-items:center', S('flex:1;color:var(--ui-text-muted)', v.files.toLocaleString('en-US') + ' generated · ' + v.size), act(i)))), S('color:var(--ui-text-muted);padding:4px 0', '6 variants · 1,284 generated files · 212 MB')) D('display:flex;align-items:center', S('flex:1;color:var(--ui-text-muted)', v.files.toLocaleString('en-US') + ' generated · ' + v.size), act(i)))), S('color:var(--ui-text-muted);padding:4px 0', '6 variants · 1,284 generated files · 212 MB'))
const RefCard = () => { const RefCard = () => {
const rows = [['/media/photo.png?v=thumb', '200', 'Image', 'ok'], ['Unknown or disabled variant', '400', 'Unknown variant', 'err'], ['width, height, format or quality', '400', 'Unsupported parameter', 'err'], ['Missing source image', '404', 'Not found', 'err']] const rows = [['/static/uploadedImages/photo.png?v=thumb', '200', 'Image', 'ok'], ['Unknown or disabled variant', '400', 'Unknown variant', 'err'], ['width, height, format or quality', '400', 'Unsupported parameter', 'err'], ['Missing source image', '404', 'Not found', 'err']]
const json = '{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}' const json = '{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}'
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:600;color:var(--ui-text-highlighted)', 'How requests are handled'), return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:600;color:var(--ui-text-highlighted)', 'How requests are handled'),
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1.3fr) minmax(0,1fr)') + ';gap:16px', D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...rows.map(([a, b, c, k], i) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 44 : 36) + 'px;padding:6px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;' + (i === 0 ? MONO + ';font-size:12px' : '') + ';color:var(--ui-text-highlighted)', a), Bd(b, k === 'ok' ? 'ok' : 'fail'), S('width:' + (m ? 120 : 130) + 'px;color:var(--ui-text-toned)', c)))), D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1.3fr) minmax(0,1fr)') + ';gap:16px', D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...rows.map(([a, b, c, k], i) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 44 : 36) + 'px;padding:6px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;min-width:0;' + (i === 0 ? MONO + ';font-size:12px' : '') + ';color:var(--ui-text-highlighted)', a), Bd(b, k === 'ok' ? 'ok' : 'fail'), S('width:' + (m ? 120 : 130) + 'px;color:var(--ui-text-toned)', c)))),
@@ -177,19 +177,20 @@ class Component extends DCLogic {
const sortLbl = ML.sort === 'total' ? (m ? 'Largest total size' : 'Largest total size (original + variants)') : 'Newest first' const sortLbl = ML.sort === 'total' ? (m ? 'Largest total size' : 'Largest total size (original + variants)') : 'Newest first'
const sortBox = D('position:relative;' + (m ? 'flex:1' : ''), D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;', I('arrow-up-down', 14), S('flex:1', sortLbl), I('chevron-down', 14)), const sortBox = D('position:relative;' + (m ? 'flex:1' : ''), D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;', I('arrow-up-down', 14), S('flex:1', sortLbl), I('chevron-down', 14)),
ML.menu && D('position:absolute;z-index:20;top:' + (m ? 48 : 34) + 'px;right:0;width:' + (m ? '100%' : '340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...['Newest first', 'Oldest first', 'Filename', 'Largest total size (original + variants)'].map((t, i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 32) + 'px;padding:0 8px;' + (i === 3 ? 'background:var(--ui-bg-elevated);font-weight:500;' : '') + 'color:var(--ui-text-highlighted)', S('width:16px;display:flex', i === 3 && I('check', 14)), t, i === 3 && S('margin-left:auto;font-size:11px;color:var(--ui-text-muted);' + MONO, 'new'))))) ML.menu && D('position:absolute;z-index:20;top:' + (m ? 48 : 34) + 'px;right:0;width:' + (m ? '100%' : '340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...['Newest first', 'Oldest first', 'Filename', 'Largest total size (original + variants)'].map((t, i) => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 32) + 'px;padding:0 8px;' + (i === 3 ? 'background:var(--ui-bg-elevated);font-weight:500;' : '') + 'color:var(--ui-text-highlighted)', S('width:16px;display:flex', i === 3 && I('check', 14)), t, i === 3 && S('margin-left:auto;font-size:11px;color:var(--ui-text-muted);' + MONO, 'new')))))
return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:8px;align-items:' + (m ? 'stretch' : 'center') + ';flex-shrink:0', D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-muted);' + (m ? '' : 'width:240px'), I('search', 14), 'Search by filename'), !m && D('flex:1'), m ? D('display:flex;gap:8px', seg) : seg, sortBox) return D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:8px;align-items:' + (m ? 'stretch' : 'center') + ';flex-shrink:0', D('height:' + (m ? 44 : 30) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-muted);' + (m ? '' : 'width:240px'), I('search', 14), 'Search by filename'), !m && D('flex:1'), m ? D('display:flex;gap:8px', seg) : seg, D('display:flex;gap:8px', sortBox, D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...[['layout-grid', 'var(--rc-sand)', 'var(--rc-on-sand)'], ['list', 'var(--ui-bg)', 'var(--ui-text-toned)']].map(([ic, bg, fg]) => S('width:' + (m ? 44 : 30) + 'px;height:' + (m ? 42 : 28) + 'px;display:flex;align-items:center;justify-content:center;background:' + bg + ';color:' + fg, I(ic))))))
} }
const TabsRow = () => D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...[['All', '12', 1], ['Unused', '2', 0], ['Duplicates', '2', 0]].map(([t, n, on]) => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (on ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (on ? 500 : 400) + ';color:' + (on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), t, S('font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', n))))
const MCard = im => { const MCard = im => {
const hover = ML.sort === 'total' const hover = ML.sort === 'total'
return D('display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border);min-width:0', Th(im.tone, 'aspect-ratio:4/3;border-bottom:1px solid var(--ui-border)'), return D('display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border);min-width:0', Th(im.tone, 'aspect-ratio:4/3;border-bottom:1px solid var(--ui-border)'),
D('padding:10px 12px;display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px;color:var(--ui-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis', hover ? im.tot + ' total' + (im.vc ? ' · ' + im.sz + ' + ' + im.vs : '') : im.sz + ' · ' + im.dim)), D('padding:10px 12px;display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', im.n), S('font-size:12px;color:var(--ui-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis', hover ? im.tot + ' total' + (im.vc ? ' · ' + im.sz + ' + ' + im.vs : '') : im.sz + ' · ' + im.dim)),
D('display:flex;flex-wrap:wrap;gap:4px', im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused'), im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars')))) D('display:flex;flex-wrap:wrap;gap:4px', [5, 6, 7, 9, 12].includes(im.id) && Bd('Duplicate', 'dup'), im.used ? Bd('Used in ' + im.used, 'used') : Bd('Unused', 'unused'), im.vc > 0 && Bd(im.vc + (im.vc === 1 ? ' variant' : ' variants'), 'vars'))))
} }
const MediaPage = () => { const MediaPage = () => {
let ids = ML.ids || IM.map(x => x.id) let ids = ML.ids || IM.map(x => x.id)
const body = [PH('Media', 'Images you upload are kept as they are. Variants are generated from them.', B('Upload', { k: 'primary', icon: 'upload' })), Tb()] const body = [PH('Media', 'Images you upload are kept as they are. Variants are generated from them.', B('Upload', { k: 'primary', icon: 'upload' })), TabsRow(), Tb()]
if (ML.empty) body.push(Empty('layers', 'No images with variants', 'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.', 56, B('Show all images'))) if (ML.empty) body.push(Empty('layers', 'No images with variants', 'Variants are generated when an image is requested with ?v=<name>, or from its Variants tab.', 56, B('Show all images')))
else body.push(D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...ids.map(byId).map(MCard))) else body.push(D('display:flex;align-items:center;gap:10px;color:var(--ui-text-muted)', S('width:18px;height:18px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);flex-shrink:0'), S('color:var(--ui-text-highlighted);font-weight:500', 'Select all'), S('', ids.length + ' images')), D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 5) + ',minmax(0,1fr));gap:' + (m ? 10 : 16) + 'px', ...ids.map(byId).map(MCard)))
return D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Pg(...body), ML.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:16px' : 'left:248px;bottom:20px'), Note(ML.ann))) return D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, Pg(...body), ML.ann && D('position:absolute;z-index:20;' + (m ? 'left:16px;bottom:16px' : 'left:248px;bottom:20px'), Note(ML.ann)))
} }
+101
View File
@@ -0,0 +1,101 @@
window.ACCOUNT_COPY = {
en: {
emailLabel: 'Email',
changeEmail: 'Change email',
newEmail: 'New email address',
newEmailPh: 'name@example.com',
password: 'Current password',
formHelp: "We'll send a verification link to the new address. Your email stays the same until you open it.",
cancel: 'Cancel',
send: 'Send verification link',
sending: 'Sending…',
errFormat: 'Enter a full email address, like name@example.com.',
errSame: "That's already your email address.",
errPwEmpty: 'Enter your current password.',
errPwWrong: "That password isn't correct. Try again.",
rate: 'Too many requests. Try again in a few minutes.',
server: 'Something went wrong on our side. Nothing was changed. Try again in a moment.',
pendTitle: 'Waiting for verification',
pendText: 'We sent a link to {new}. Until you open it, you keep logging in with {old}.',
resend: 'Resend link',
resendRate: 'Resend in 4:30',
cancelChange: 'Cancel change',
sentT: 'Verification link sent',
sentB: 'Check {new}. The link works once, within 2 hours.',
resentT: 'Link sent again',
resentB: 'The previous link no longer works.',
cancelledT: 'Email change cancelled',
cancelledB: 'You keep logging in with {old}.',
dataTitle: 'Your data',
dataDesc: 'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
dataItems: [
['user', 'Profile', 'Username, display name, email, role and author profile.'],
['file-text', 'Authored content', 'Your articles and entries.'],
['image', 'Uploaded media', 'References to the files you uploaded.'],
['history', 'Account activity', 'Your log-ins and the actions recorded under your name.']
],
download: 'Download my data',
preparing: 'Preparing download…',
dlHint: 'Ready in a few seconds.',
dlT: 'Download started',
dlB: 'renewcms-export-marta-2026-10-29.zip is in your downloads.',
exRateT: 'You can export once every 15 minutes',
exRateB: 'Your last export was at 09:41. Try again in 12 minutes.',
exRateBtn: 'Available in 12:00',
exErrT: "Couldn't prepare your data",
exErrB: 'Nothing was changed. Try again in a moment.',
request: 'Request my data',
asyncHint: "Larger exports are prepared in the background. We'll email a download link to {old} when it's ready. You'll need to be logged in to download it.",
queuedT: "We're preparing your export",
queuedB: "This can take a few minutes. We'll email a link to {old} when it's ready.",
queuedBtn: 'Export requested',
inprogT: 'An export is already being prepared',
inprogB: "We'll email a link to {old} when it's ready.",
queuedToastT: 'Export requested',
queuedToastB: 'Check {old} in a few minutes.',
dzTitle: 'Danger zone',
dzDesc: "Delete your account and your personal data. This can't be undone.",
dzBtn: 'Delete my account',
delTitle: 'Delete your account?',
delDesc: "This permanently deletes your account. It can't be undone.",
delList: ['Your username, display name and email address', 'Your author profile and API keys', "Your sessions: you'll be logged out everywhere"],
delKeepT: 'Your articles and media stay, without your name',
delKeepB: 'They stay on the site and are credited to "Deleted user". Your name is removed from them and this cannot be reversed.',
delExport: 'Export my data first',
delPw: 'Current password',
delWordLabel: 'Type DELETE to confirm',
delWord: 'DELETE',
delKeep: 'Keep my account',
delConfirm: 'Delete my account',
deleting: 'Deleting…',
delServer: "Couldn't delete your account. Nothing was deleted. Try again in a moment.",
blockT: "You can't delete your account yet",
blockB: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.",
blockBack: 'Back to profile',
blockGo: 'Go to Users',
protT: "This account can't be deleted",
protB: "RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
vVerifying: 'Confirming your new email…',
vOkT: 'Your email address has changed',
vOkB: 'Your account now uses {new}. Use it the next time you log in.',
vOkBtn: 'Go to profile',
vOkToastT: 'Email address changed',
vOkToastB: 'Your account now uses {new}.',
vBadT: "This link doesn't work",
vBadB: 'It may have expired (links last 2 hours), been cancelled or already been used.',
vBadN: 'Request a new link from your profile.',
vNoT: "We couldn't change your email address",
vNoB: "This address can't be used for your account. Go back to your profile and try a different one.",
vLoginT: 'Log in to confirm your new email',
vLoginB: "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
vLoginBtn: 'Log in and confirm',
vMissT: 'This link is incomplete',
vMissB: 'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
vMissBtn: 'Go to log in',
vErrT: "Couldn't confirm the change",
vErrB: 'Something went wrong on our side. Nothing was changed. Try again in a moment.',
vErrBtn: 'Try again',
goneT: 'Your account has been deleted',
goneB: 'Your account has been deleted. Your articles and media were kept without your name.'
}
}
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your account has been deleted</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Your RenewCMS account was deleted and your personal data removed.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your account has been deleted</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Your RenewCMS account at {{.Host}} has been deleted.</p>
<p class="rc-text rc-rule" style="margin:0;padding-top:0;border-top:0;font-size:14px;line-height:22px;color:#222222;">If you didn't ask for this, contact an administrator of {{.Host}}.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your email address change was requested</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">A change of email address was requested for your RenewCMS account. Nothing has changed yet.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your email address change was requested</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">A request was made to change the email address of the RenewCMS account {{.Username}} at {{.Host}} from this address to {{.NewEmail}}. Nothing has changed yet: you keep logging in with this address until the new one is confirmed.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/profile" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Review my profile</a>
</td>
</tr>
</table>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this was you, no action is needed. If it wasn't, log in, cancel the change from your profile and change your password.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your data export is ready</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Your RenewCMS data export is ready to download. The link expires in 24 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your data export is ready</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">The export you requested for the RenewCMS account {{.Username}} at {{.Host}} is ready. It contains your profile, authored content, uploaded media references and account activity. You need to be logged in to download it.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/account/export/{{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Download my data</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 24 hours and only works while you are logged in.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/account/export/{{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If you didn't request this export, log in and change your password.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Confirm your new email address</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm this address to finish changing your RenewCMS email. The link expires in 2 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Confirm your new email address</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, asked to change the email address of the RenewCMS account {{.Username}} at {{.Host}} to this address. Confirm it to finish the change.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/account/email/confirm?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Confirm new email address</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/account/email/confirm?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+301
View File
@@ -0,0 +1,301 @@
const N = 'available now', L = 'later'
const st = (name, scope, trigger, s, copy) => ({ name, scope, trigger, s, copy })
const E = (name, k, trigger, copy, extra = {}) => st(name, N, trigger, { v: 'editor', k, ...extra }, copy)
const CONTRIB = 'Name Contributor · Description Creates content and edits only their own. · Content permissions · What this role can do with entries of each content type. Own means entries the person created. · Quick presets · Read only · Write own · Full content · Content type · Read · Create · Update · Delete · Publish · All content types · Sets every row · Articles · Built-in · Schema locked · None · Own · All · On · Off · General permissions · Everything outside entries. · Expand all'
export const groups = [
{ label: '1 Roles list', title: '1 Roles list', route: '/settings/roles', api: 'GET /v1/role', auth: 'Roles: view', note: 'Settings > Roles. Visible to anyone who can view roles. Owner carries a Locked badge. The row menu has Edit, Duplicate and Delete. Mobile: one card per role.', states: [
st('Default', N, 'Owner opens /settings/roles.', { v: 'list', k: 'ok' }, 'Roles · Who can do what on this site. Each user has one role. · New role · Role · Description · Members · Type · Owner · Locked · Admin · Writer · Contributor · Content lead · Built-in · Custom'),
st('Loading', N, 'First load or retry. Skeleton rows after 150 ms. New role disabled.', { v: 'list', k: 'loading' }, '(skeleton) · New role (disabled)'),
st('Only built-in roles', N, 'GET /v1/role returns the three built-ins and no custom role.', { v: 'list', k: 'empty' }, 'No custom roles yet · The three built-in roles cover most sites. Create a role when someone needs something in between. · New role'),
st('Row menu: custom role', N, 'Open the row menu on Contributor.', { v: 'list', k: 'menu' }, 'Edit · Duplicate · Delete'),
st('Row menu: built-in role', N, 'Open the row menu on Writer as owner. Delete is disabled and its tooltip shows on focus or hover.', { v: 'list', k: 'menub' }, 'Edit · Duplicate · Delete (disabled) · Built-in roles can\'t be deleted. Duplicate it to make a custom role.'),
st('No permission to edit', N, 'User has Roles: view but not Roles: manage. New role is disabled with a tooltip.', { v: 'list', k: 'noedit', role: 'admin' }, 'ReadOnlyBanner: You can see roles, but only people who manage roles can create or change them.'),
st('Error', N, '500 or network failure.', { v: 'list', k: 'error' }, 'Couldn\'t load roles · GET /v1/role failed. Check your connection and try again. · Try again')
] },
{ label: '2 Role editor', title: '2 Role editor', route: '/settings/roles/new, /settings/roles/:id', api: 'POST /v1/role, GET/PUT /v1/role/{id}', auth: 'Roles: manage (view for read-only)', note: 'Name and description, then Content permissions (the grid moved here from /settings/permissions) and General permissions. Desktop: the plain-language panel sits on the right. Mobile: the grid is one card per type, and a sticky bar holds "What this role can do" and Save. The old route /settings/permissions redirects here (301 to /settings/roles).', states: [
E('New role (empty)', 'new', 'Owner or admin opens /settings/roles/new. Nothing is granted. Save is off until the role has a name.', 'New role · Name · For example, Contributor · Description (optional) · What this role is for · Name your role to save it · Discard · Save changes · ' + CONTRIB),
E('Editing a custom role', 'custom', 'Open Contributor. Two groups open. Case studies shows Custom with a Reset link.', CONTRIB + ' · Custom · Reset · 4 members · Custom'),
E('Built-in role, as owner', 'owner-edit', 'The owner opens Writer. A warning shows how many members are affected.', 'This role has 5 members · Changes apply to all of them when you save, and to the API keys they created. Built-in roles can be restored with Reset to default. · 2 unsaved changes'),
E('Built-in role, as admin (read-only)', 'admin-ro', 'An admin opens Writer. All controls are disabled and the fields are locked.', 'Only the owner can edit built-in roles · You can read what Writer allows. To change it, duplicate it into a custom role. · Duplicate role · Built-in'),
E('Owner role (locked)', 'locked', 'Anyone with Roles: view opens Owner.', 'The Owner role is locked · It always has every permission. To hand it to someone else, transfer ownership from Users. · Go to Users · Locked'),
E('General permissions', 'general', 'Scrolled to General permissions. Groups collapse; each shows "x of y enabled". Scoped rows use the same None / Own / All control.', 'Activity · See activity · Read the log of who did what. · Export activity · Download the log as a CSV file. · Users and invites · View users · Invite users · Change a user\'s role · Remove users · Settings · View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site · Owner only · 2 of 2 enabled'),
E('Owner-only items', 'owneronly', 'Roles and System groups, as an admin. Owner-only items are disabled with a lock and an Owner only tag. They stay off even for Admin.', 'Roles · View roles · See roles and what they allow. · Manage roles · Create, edit, duplicate and delete roles. · Edit roles with owner-only power · Change a role that carries owner-only abilities. · System · Apply software updates · Install a new version of RenewCMS. · Edit activity retention · How long the activity log is kept. · Transfer ownership · Make another member the owner. · Owner only'),
E('Unsaved changes', 'dirty', 'Three edits. Changed cells carry a square marker and the preview tags the changed line.', '3 unsaved changes · Discard · Save changes · Changed'),
E('Saving', 'saving', 'PUT in flight. Fields dim; Save shows the loader.', 'Saving… · Discard (disabled)'),
E('Saved', 'saved', '200. The bar closes and a toast confirms.', 'Role saved · Contributor is updated for 4 members.'),
E('Name required', 'req', 'Save with an empty name.', 'Give the role a name. · 1 problem to fix'),
E('Name already taken', 'taken', '409 role_name_taken on POST or PUT.', 'A role named "Writer" already exists. Choose a different name. · 1 problem to fix'),
E('Escalation blocked', 'esc', 'The editor holds Roles: manage but can publish only their own entries, so Publish: All is disabled with a lock.', 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available. · All (locked)'),
E('Conflict', 'conflict', '409 role_conflict on PUT (version mismatch).', 'This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload'),
E('Permission lowered while open (403)', 'lowered', '403 missing_permission on PUT or on refresh. The page becomes read-only.', 'Your access changed · An owner changed your role while this page was open. You can still see roles, but you can\'t change them. Your edits weren\'t saved. · Reload page')
] },
{ label: '2b Role editor: app sync', title: '2b Role editor: app sync', route: '/settings/roles/new?from=<id>, /settings/roles/:id', api: 'POST /v1/role, PUT /v1/role/{id}, GET /v1/role/{id}', auth: 'Roles: manage', note: 'Presets: Read only sets Read: All and everything else None for every type; Write own sets Read: All, Create: On, Update: Own, Delete and Publish: None; Full content sets Read, Update, Delete and Publish: All and Create: On. A preset overwrites every type row, including custom rows, and counts every cell it changes as an unsaved change. "N unsaved changes" counts cells in the content grid plus general toggles whose value differs from the loaded role; name and description each count as one. Changing a cell back to its loaded value removes it from the count. The footer bar is pinned to the bottom. The grid scrolls sideways inside its card below 720 px. There is no leave guard when deleting a role: that dialog holds no unsaved edits.', states: [
E('No Schema: view', 'noschema', 'The editor lacks Schema: view, so custom content types can\'t be listed.', 'Custom content types aren\'t listed · Your role doesn\'t include Schema: view, so only Articles is shown. The permissions of custom types stay as they are.', {}),
E('Reset to default: confirm', 'reset-confirm', 'Reset to default on a built-in role (owner only).', 'Reset Writer to default? · Writer goes back to its built-in permissions. 5 members and the API keys they created are affected as soon as you save. This can\'t be undone. · Your unsaved changes to Writer are discarded. · Cancel · Reset to default', {}),
E('Reset to default: done', 'reset-toast', 'The grid shows the defaults; Save applies them.', 'Role reset · Writer is back to its defaults. Save to apply it to 5 members.', {}),
E('Review differences: name and description', 'review-name', 'Name and description differences appear as rows above the permission rows, with the saved value and yours.', 'How the saved role differs from your edits · Name · Contributor · Event writer · Description · Writes posts · Writes events · Events · Update · Own · All', {}),
E('Duplicate', 'dup', 'Duplicate on a role opens /settings/roles/new?from=<id>. The name is prefilled with "<role> copy" and nothing is saved until Save changes.', 'Copied from Contributor · The permissions are the same. Give the copy a name that fits, then save.', {}),
E('Role not found (404)', 'nf', 'Opening /settings/roles/:id for a role that was deleted.', 'That role no longer exists · It was deleted somewhere else, so we removed it from the list. · Back to Roles', {}),
E('invalid_role_name (400)', 'invname', 'The server refuses a blank or invalid name. The field shows the same text as the client check.', 'Give the role a name.', {}),
E('Generic 400', 'e400', 'Any other 400 on save.', 'Couldn\'t save the role · Something in the form isn\'t valid. Check the highlighted fields.', {}),
st('Change role: choice is now the current role', N, 'After the re-read, the chosen role equals the member\'s current role. Change role is disabled.', { v: 'users', k: 'changesame' }, 'Ines Duarte is now Admin · Someone changed their role while this dialog was open. Your choice is already their role, so there is nothing to change. · Change role (disabled)')
] },
{ label: '3 Plain-language preview', title: '3 Plain-language preview', route: 'Panel inside the role editor; bottom sheet on mobile', api: 'None. Computed in the browser from the form', auth: 'Roles: view', note: 'Title "What this role can do". Two parts: Content (one sentence per group of types that share the same permissions) and Everything else (one sentence per group). A last line lists areas with no access. Lines that change while editing carry a Changed tag. Sentences are built from fixed templates, never from keys.', states: [
st('Live panel', N, 'Editing Contributor. Events: Update changed from Own to All. The first line is tagged.', { v: 'pv', k: 'live' }, 'What this role can do · Updates as you edit. · Content · All content types · Can read any entry, create entries and edit only their own. Can\'t delete and publish. · Everything else · Schema · Can see content types and fields. · Media · Can browse the media library and upload files. · Authors · Can see authors and edit authors, only their own. · No access to Activity, Users and invites, Settings, Roles. · Applies to 4 members, and to the API keys they created. · Changed'),
st('Read-only summary', N, 'Opened from a built-in role in read-only mode. No "Updates as you edit".', { v: 'pv', k: 'readonly', role: 'admin' }, 'What this role can do · A summary of what this role allows.'),
st('Nothing granted', N, 'New role with every permission off.', { v: 'pv', k: 'empty' }, 'Content · All content types · No access to entries. · No access to Schema, Media, Authors, Activity, Users and invites, Settings, Roles.'),
st('Mobile sheet', N, 'Tap "What this role can do" in the sticky bar. 92% sheet with grabber and close.', { v: 'editor', k: 'dirty', sheet: true }, 'What this role can do · Updates as you edit. · Close')
] },
{ label: '4 Delete role', title: '4 Delete role', route: 'Dialog over /settings/roles', api: 'POST /v1/role/{id}/reassign, DELETE /v1/role/{id}', auth: 'Roles: manage', note: 'A role with members is blocked until they move. Choosing a target role enables a single action that moves them and deletes the role. Roles with more power than the user are disabled in the picker with a reason.', states: [
st('Blocked: has members', N, 'Delete Contributor, 4 members.', { v: 'del', k: 'blocked' }, 'Delete Contributor? · 4 members have this role. Move them to another role first. Then Contributor can be deleted. · Move members to · Choose a role · They keep their API keys. Keys follow the new role. · Cancel · Move 4 members and delete role (disabled until a role is chosen)'),
st('Role picker open', N, 'The picker shows each role\'s summary. Admin is disabled for an admin.', { v: 'del', k: 'picker' }, 'Writer · Reads everything and writes content. Can\'t delete or publish. · Content lead · Edits and publishes all content. · Admin · Your role can\'t assign a role with more power than yours.'),
st('Confirm: no members', N, 'Delete Intern, 0 members.', { v: 'del', k: 'confirm' }, 'Delete Intern? · No one has this role. This can\'t be undone. · Deleting a role doesn\'t affect content. · Cancel · Delete role'),
st('Built-in role', N, 'Delete is disabled in the menu. If the dialog is reached by link it shows this state. There is no leave guard: closing it needs no confirmation.', { v: 'del', k: 'builtin' }, 'Delete Writer? · Built-in roles can\'t be deleted. · Writer is built in · Duplicate it to make a custom role you can change or delete. · Duplicate role · Close')
] },
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', note: 'The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.', states: [
st('List with roles', N, 'Owner opens /users.', { v: 'users', k: 'list' }, 'Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending'),
st('Row menu', N, 'Open the menu on Ines Duarte.', { v: 'users', k: 'menu' }, 'Change role · Send password reset · Remove user'),
st('Owner row menu', N, 'Owner row: Change role and Remove are disabled; Transfer ownership appears.', { v: 'users', k: 'ownermenu' }, 'Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can\'t be changed or removed. Transfer ownership first.'),
st('Change role dialog', N, 'Each option shows the role\'s one-line summary. The current role is marked.', { v: 'users', k: 'change' }, 'Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role'),
st('Change role: options blocked', N, 'An admin opens the dialog. Roles with more power are disabled with a reason.', { v: 'users', k: 'changeblock' }, 'Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can\'t grant that.'),
st('Invite dialog', N, 'Role picker defaults to Writer and shows its summary.', { v: 'users', k: 'invite' }, 'Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite'),
st('Transfer ownership: pick member', N, 'Owner menu > Transfer ownership. Only members can be chosen.', { v: 'users', k: 'tr1' }, 'Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue'),
st('Transfer ownership: confirm', N, 'Consequences listed. The action stays off until the email is retyped exactly.', { v: 'users', k: 'tr2' }, 'Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can\'t take ownership back unless they transfer it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership'),
st('Transfer ownership: done', N, 'The former owner is now Admin. The list shows the new owner.', { v: 'users', k: 'tr3' }, 'Ownership transferred · Jonas Weber is now the owner. You are an Admin.')
] },
{ label: '6 Role-aware shell', title: '6 Role-aware shell', route: 'Every page', api: 'GET /v1/auth/me returns role and permission keys', auth: 'Any', note: 'Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.', states: [
st('The rule', N, 'Reference card used across the app.', { v: 'shell', k: 'rule' }, 'Hidden · The person can never use it. · Disabled, with a reason · The person sees the thing but can\'t act on it. · Read-only · The person may look but not change.'),
st('Writer: Articles list', N, 'Create and edit all, no delete, no publish. No Schema, Users, Integration or Settings in the sidebar.', { v: 'shell', k: 'art' }, 'Articles · You can write and edit all articles. Publishing is done by an editor. · New article · Created by · Status · Draft · Published'),
st('Writer: row menu', N, 'Delete is disabled with a reason.', { v: 'shell', k: 'artmenu' }, 'Edit · Duplicate · Delete (disabled) · Your role can\'t delete articles.'),
st('Writer: entry editor', N, 'Publish is hidden. Status reads Draft with a hint.', { v: 'shell', k: 'entry' }, 'Save draft · Status · Draft · Saved at 11:02 · Ask an editor to publish.'),
st('Own scope: entry list', N, 'Contributor can edit only entries they created. Others show a View only mark.', { v: 'shell', k: 'own' }, 'Events · You can create entries and edit your own. · Entries by others are view only · Your role lets you edit entries you created. · New entry · View only'),
st('Own scope: row menu', N, 'On an entry by someone else, Edit and Delete are disabled.', { v: 'shell', k: 'ownmenu' }, 'Edit (disabled) · Duplicate · Delete (disabled) · You can only edit content you created.'),
st('Admin: read-only settings', N, 'An admin without Edit general settings. Fields locked, Save off.', { v: 'admin', k: 'ro' }, 'You can read these settings but not change them · Ask the owner to change them, or ask for a role that can edit settings. · Save changes (disabled)'),
st('Admin: owner-only settings', N, 'Software and Retention show an Owner only tag. The action stays visible and disabled.', { v: 'admin', k: 'owneronly' }, 'Owner only · Apply update (disabled) · Only the owner can apply updates. · Keep activity for · Admins can read the log. Only the owner can change how long it is kept.')
] },
{ label: '7 No access', title: '7 No access: 403 copy', route: 'Any page', api: '403 missing_permission, 403 role_locked', auth: 'Any', note: 'A direct link to a page the person can\'t use shows the page-level state with a link to a page they can use. A blocked action shows a toast.', states: [
st('Page: no access', N, 'Contributor opens /settings/roles by link.', { v: 'forbid', k: 'page' }, 'You don\'t have access to this page · Your role, Contributor, doesn\'t include it. Ask an owner or admin if you need it. · Go to Articles · See what I can do'),
st('Action: missing_permission', N, 'An action was refused with 403 missing_permission.', { v: 'forbid', k: 'missing' }, 'You don\'t have permission to do that · Your role doesn\'t include Delete entries. Ask an owner or admin.'),
st('Action: role_locked', N, 'An attempt to edit or delete the Owner role.', { v: 'forbid', k: 'locked' }, 'This role is locked · The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')
] },
{ label: '8 Activity', title: '8 Activity: role events', route: '/activity', api: 'GET /v1/activity', auth: 'Activity: own or all', note: 'New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.', states: [
st('List with role events', N, 'Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.', { v: 'act', k: 'list' }, 'Role Contributor created · Role Contributor edited · Ines Duarte\'s role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted'),
st('Detail: role diff', N, 'Open "Role Contributor edited".', { v: 'act', k: 'detail' }, 'Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.'),
st('Failed role events', N, 'The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.', { v: 'act', k: 'failed' }, 'Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed'),
st('Failed event: details', N, 'Open "Failed to edit the role Contributor". The reason comes from the API.', { v: 'act', k: 'faildetail' }, 'Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.'),
st('Category filter', N, 'The Category select open. Roles is a new category; Users & invites is renamed Users and invites.', { v: 'act', k: 'cats' }, 'All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System')
] },
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/auth/me', auth: 'Any', note: 'The person sees their own role and a read-only summary. Page order: Display name, Account, Your role, Author profile.', states: [
st('Role card: custom role', N, 'Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.', { v: 'prof', k: 'card' }, 'Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
st('Role card: Owner', N, 'The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.', { v: 'prof', k: 'owner' }, 'Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
st('Display name too long', N, 'Over 60 characters. Save stays off.', { v: 'prof', k: 'dn-long' }, 'Display name must be 60 characters or fewer.'),
st('Display name saved', N, '200.', { v: 'prof', k: 'dn-saved', toastB: 80 }, 'Display name saved · You now appear as Pedro Lima across RenewCMS and in emails.'),
st('Display name removed', N, 'Saved empty: back to the username.', { v: 'prof', k: 'dn-removed', toastB: 80 }, 'Display name removed · You now appear as your username, pedro.'),
st('See what I can do', N, 'Opens the summary. Dialog on desktop, sheet on mobile.', { v: 'prof', k: 'sheet' }, 'What you can do · A summary of your role, Contributor. Read-only. · Close')
] },
{ label: '10 API keys', title: '10 API keys: inherited role', route: '/settings/api-keys', api: 'GET /v1/api-key', auth: 'Settings: API keys', note: 'Keys follow their creator\'s role live. The fact is stated in an info alert and each row shows the creator\'s role.', states: [
st('Keys follow the role', N, 'Always shown on the API keys screen.', { v: 'keys', k: 'ok' }, 'Keys follow their creator\'s role · A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it. · Follows role')
] },
{ label: '11 Create a role in under a minute', title: '11 Flow: create "Contributor" in under a minute', route: '/settings/roles, /settings/roles/new', api: 'POST /v1/role', auth: 'Roles: manage', note: 'Six steps, about 40 seconds: 1 New role. 2 Name and description. 3 Choose the preset Write own. 4 Check the preview and the Schema and Media defaults. 5 Save. 6 Done. A new role starts with nothing granted; presets fill the grid in one tap.', states: [
st('1 Roles list: New role', N, 'Click New role.', { v: 'list', k: 'ok' }, 'New role'),
E('2 Name it', 'flow', 'Type a name and a one-line description.', 'Name Contributor · Description Writes posts and edits only their own', { flow: 2 }),
E('3 Tap the preset: Write own', 'flow', 'The preset sets Read: All, Create: On, Update: Own, Delete and Publish: None for every type.', 'Write own · 7 unsaved changes', { flow: 3 }),
E('4 Check General permissions', 'flow', 'Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.', 'What this role can do · Can read any entry, create entries and edit only their own. Can\'t delete and publish.', { flow: 4 }),
E('5 Save', 'flow', 'Save changes. The role appears in the list and in the invite picker.', 'Save changes', { flow: 5 }),
E('6 Done', 'flow', 'Toast, and the bar closes.', 'Role created · Contributor is ready. Assign it from Users.', { flow: 6 })
] },
{ label: '12 Toasts', title: '12 Toasts: role changed and stale state', route: 'Any page', api: 'PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".', states: [
st('Role changed', N, '200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.', { v: 'users', k: 'rolechanged', toastB: 80 }, `Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.`),
st('Role edited elsewhere (409)', N, '409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.', { v: 'list', k: 'tedit', toastB: 80 }, `Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload`),
st('Role deleted elsewhere', N, 'Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.', { v: 'list', k: 'tgone', toastB: 80 }, `That role no longer exists · It was deleted somewhere else, so we removed it from the list.`),
st('Role not changed: member\'s role changed first (409)', N, 'Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.', { v: 'users', k: 'tstale', toastB: 80 }, `Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload`),
st('Ownership not transferred: target\'s role changed first (409)', N, 'Transfer ownership was confirmed after the target\'s role changed, and the dialog was closed in the meantime. Nothing was transferred.', { v: 'users', k: 'ttr', toastB: 80 }, `Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload`),
st('User not removed: member\'s role changed first (409)', N, 'Remove user was confirmed after the member\'s role changed. Nothing was removed.', { v: 'users', k: 'trem', toastB: 80 }, `User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload`)
] },
{ label: '12b Remove user', title: '12b Remove user', route: '/settings/users', api: 'DELETE /v1/user/{id}?expected_role_id=N', auth: 'Users and invites: remove users', note: 'Remove user is in the row menu. It is disabled on your own row. The request carries expected_role_id, so a role change made first returns 409 target_role_not_found. The name in the dialog and toast is the display name, falling back to the username, never the email. "Removed {0}" is the activity-log sentence for user.remove, not a toast.', states: [
st('Own row: remove and change role disabled', N, 'Row menu on your own row. Both items are disabled, each with its own reason.', { v: 'users', k: 'selfmenu' }, `Change role (disabled) · You can't change your own role · Remove user (disabled) · You can't remove yourself. Ask another owner or admin.`),
st('Remove user: confirm', N, 'Remove user in the row menu of another member.', { v: 'users', k: 'remdlg' }, `Remove {0}? · They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay. · Cancel · Remove user`),
st('Remove user: removing', N, 'DELETE in flight. Buttons disabled, dialog can\'t be dismissed.', { v: 'users', k: 'remsend' }, `Removing…`),
st('Remove user: 500', N, '500 or network failure. Inline error, nothing was removed, the button stays on.', { v: 'users', k: 'remerr' }, `Couldn't remove the user · Nothing was changed. Try again in a moment.`),
st('Remove user: target_role_not_found (409)', N, 'The member\'s role changed between loading and confirming. The dialog closes and this warning toast stays until dismissed or Reload is pressed.', { v: 'users', k: 'trem', toastB: 80 }, `User not removed · {0}'s role changed in the meantime, so nothing was removed. Check it and try again. · Reload`),
st('Remove user: done', N, '204. The row leaves the list. Toast closes after 6 s.', { v: 'users', k: 'remok', toastB: 80 }, `User removed · {0} no longer has access.`)
] },
{ label: '13 Inline errors', title: '13 Inline errors: editor and dialogs', route: 'Role editor, Change role, Delete role, Invite', api: 'POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite', auth: 'As the action', note: 'Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.', states: [
st('Role editor: save failed (500)', N, '500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.', { v: 'editor', k: 'saveerr' }, `Couldn't save the role · Your changes are still here. Try again in a moment. · Try again`),
st('Change role: failed (500)', N, '500 or network failure. The choice is kept.', { v: 'users', k: 'changeerr' }, `Couldn't change the role · Nothing was changed. Try again in a moment.`),
st('Delete role: target role deleted', N, '409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.', { v: 'del', k: 'gone' }, `Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.`),
st('Delete role: role gained members', N, '409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.', { v: 'del', k: 'newmem' }, `Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)`),
st('Delete role: move failed (500)', N, '500 or network failure on reassign. The chosen role is kept.', { v: 'del', k: 'moveerr' }, `Couldn't move the members · Nothing was changed. Try again in a moment.`),
st('Invite: invalid email', N, '400 or client check. Shown under Email.', { v: 'users', k: 'invbad' }, `Enter a valid email address.`),
st('Invite: address already has an account (409)', N, 'The address belongs to a user. Shown under Email. Same text as the API.', { v: 'users', k: 'invdup' }, `This address already has an account.`),
st('Invite: role above yours (403)', N, 'An admin picked a role with more power than their own. Shown under Role.', { v: 'users', k: 'invesc', role: 'admin' }, `Your role can't assign a role with more power than yours.`),
st('Invite: send failed (500)', N, '500 or network failure. Email and role are kept.', { v: 'users', k: 'inverr' }, `Couldn't send the invite · Try again in a moment.`)
] },
{ label: '14 Read = Own', title: '14 Read = Own: what the person sees', route: '/articles, /content/:key, /settings/roles/:id', api: 'GET /v1/article/, GET /v1/content-type/{key}/entry', auth: 'As the role', note: 'A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people\'s rows are still listed with a View only mark.', states: [
E('Role editor: Read set to Own', 'readown', 'Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.', `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. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.`),
st('Articles list: Read = Own', N, 'Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.', { v: 'rdown', k: 'art' }, `Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article`),
st('Articles list: no drafts of their own', N, 'The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.', { v: 'rdown', k: 'draft0' }, `You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0`),
st('Entry list: Read = Own', N, 'Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.', { v: 'rdown', k: 'ent' }, `Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry`),
st('Entry list: no entries of their own', N, 'The person has created none. Other people\'s entries exist but are not hinted at.', { v: 'rdown', k: 'ent0' }, `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. · New entry`)
] },
{ label: '15 409 conflicts', title: '15 Role 409 conflicts: stale save, change and transfer', route: '/settings/roles/:id, /users', api: 'PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.', states: [
E('Stale save: banner', 'conflict', '409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.', `This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload`),
E('Stale save: review differences', 'review', 'Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.', `How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits`),
E('Stale save: edits kept after reload', 'kept', 'After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.', `Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes`),
st('Change role: member\'s role changed first (409)', N, 'Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.', { v: 'users', k: 'changestale' }, `Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role`),
st('Transfer ownership: target\'s role changed first (409)', N, 'Transfer confirmed after the target\'s role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.', { v: 'users', k: 'tr2stale' }, `Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)`)
] }
]
const T = (title, tpl, cols, rows) => ({ title, tpl, cols, rows })
export const tables = [
T('Permission catalogue (names shown to people)', '170px 1fr 1fr', ['Group', 'Items (human name)', 'Scope'], [
['Content, per type', 'Read entries · Create entries · Update entries · Delete entries · Publish entries', 'None / Own / All, except Create (on or off)'],
['Schema', 'View content types · Create and edit content types · Delete content types', 'On or off'],
['Media', 'View the media library · Upload files · Delete files', 'Delete: None / Own / All'],
['Authors', 'View authors · Create authors · Edit authors · Delete authors', 'Edit: None / Own / All'],
['Activity', 'See activity · Export activity', 'See: None / Own / All'],
['Users and invites', 'View users · Invite users · Change a user\'s role · Remove users', 'On or off'],
['Settings', 'View settings · Edit general settings · Manage sites · See API keys · Create API keys · Revoke API keys · Delete a site (owner only)', 'API keys: None / Own / All'],
['Roles', 'View roles · Manage roles · Edit roles with owner-only power (owner only)', 'On or off'],
['System (owner only)', 'Apply software updates · Edit activity retention · Transfer ownership', 'Locked off for everyone but the owner']]),
T('Built-in roles', '140px 1fr 160px', ['Role', 'What it has', 'Editable by'], [
['Owner', 'Everything. One per site. Ownership can be transferred; the former owner becomes Admin.', 'No one (locked)'],
['Admin', 'Everything except the owner-only list. Can see all settings, read-only where they can\'t edit.', 'Owner'],
['Writer', 'Reads schema and all content; creates and edits all content. No delete, no publish; no schema, settings, sites, users or integrations. Authors: view, edit own (assumed).', 'Owner (duplicate for variants)']]),
T('No-escalation feedback', '220px 1fr 1fr', ['Rule', 'Where it shows', 'Copy'], [
['Grant only what you hold', 'Disabled segment or switch with a lock; popover on focus or hover', 'You can only grant what your role has. Your role can publish only entries you created, so All isn\'t available.'],
['No role above yours', 'Disabled option in the role picker (Change role, Delete reassign, Invite)', 'Your role can\'t assign a role with more power than yours.'],
['No editing your own role', 'Editor opens read-only with a banner', 'You can\'t change the permissions of your own role. Ask another owner or admin.'],
['No delete while it has members', 'Delete dialog, blocked state', '4 members have this role. Move them to another role first. Then Contributor can be deleted.'],
['Owner-only list', 'Disabled item with an Owner only tag and a lock', 'Only the owner can apply updates.']]),
T('Hidden or disabled', '240px 1fr 1fr', ['Case', 'Treatment', 'Example'], [
['Whole area the role never has', 'Hidden (no sidebar item, no page card). Direct link shows the 403 page.', 'Schema for a Writer'],
['Action on something visible, blocked by role', 'Disabled with a reason in a tooltip or row text', 'Delete on an article for a Writer'],
['Action that only exists above their level', 'Hidden', 'Publish for a Writer; status shows Draft and "Ask an editor to publish."'],
['Row the person can read but not edit (Own)', 'View only mark on the row; Edit and Delete disabled in the menu', 'You can only edit content you created.'],
['Settings page they can read', 'Read-only: locked fields, banner, Save off', 'Admin on General without Edit general settings'],
['Owner-only action visible to an admin', 'Disabled, Owner only tag', 'Apply update'],
['Item the role may not read (Read = Own)', 'Not listed and not counted. No View only mark, no hint that it exists.', 'Another person\'s draft article, or an entry created by someone else']]),
T('HTTP status to copy (additions)', '180px 120px 1fr 1fr', ['Code', 'Status', 'Title', 'Body'], [
['missing_permission', '403', 'You don\'t have permission to do that', 'Your role doesn\'t include {permission name}. Ask an owner or admin.'],
['role_locked', '403', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.'],
['role_has_members', '409', 'Role still has members', '{n} members have this role. Move them to another role first.'],
['role_name_taken', '409', 'Name already used', 'A role named "{name}" already exists. Choose a different name.'],
['role_conflict', '409', 'This role changed while you were editing', '{name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits.'],
['role_conflict (change role)', '409', 'Role not changed', 'Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed.'],
['role_conflict (remove, transfer)', '409', 'User not removed / Ownership not transferred', '{user}\'s role changed in the meantime, so nothing was removed (transferred). Check it and try again.'],
['role_not_found', '404', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'],
['escalation', '403', 'You can\'t grant that', 'You can only grant what your role has.'],
['(page level)', '403', 'You don\'t have access to this page', 'Your role, {role}, doesn\'t include it. Ask an owner or admin if you need it.']]),
T('Components (new) and Nuxt UI v4 mapping', '200px 1fr 1fr', ['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']]),
T('Accessibility: tri-state cell', '220px 1fr', ['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.']]),
T('Needs backend', '240px 1fr', ['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). The same 409 covers a change of role or a removal when the member\'s role changed first, and an ownership transfer when the target\'s role changed first.'],
['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. The Failed outcome is a field on the event, not a separate name.']]),
T('Existing screens that change', '220px 1fr', ['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', 'New "Roles" item. "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.']]),
T('Activity events: sentences, EN and FR', '190px 1fr 1fr', ['Event name','EN','FR'], [
['role.create', 'Role Contributor created', 'Rôle Contributor créé'],
['role.edit', 'Role Contributor edited', 'Rôle Contributor modifié'],
['role.delete', 'Role Intern deleted', 'Rôle Intern supprimé'],
['role.reassign', 'Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer)', '3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer)'],
['user.change_role', 'Ines Duarte\'s role changed from Writer to Content lead', 'Le rôle d\'Ines Duarte est passé de Writer à Content lead'],
['user.transfer_ownership', 'Ownership transferred from Marta Alves to Jonas Weber', 'Propriété transférée de Marta Alves à Jonas Weber']]),
T('Activity events: failed sentences, EN and FR', '190px 1fr 1fr', ['Event name (outcome failed)','EN','FR'], [
['Template', 'Failed to {verb} {object}', 'Échec de {action} {objet}'],
['role.create', 'Failed to create the role Contributor', 'Échec de la création du rôle Contributor'],
['role.edit', 'Failed to edit the role Contributor', 'Échec de la modification du rôle Contributor'],
['role.delete', 'Failed to delete the role Intern', 'Échec de la suppression du rôle Intern'],
['role.reassign', 'Failed to move 3 members from Intern to Writer', 'Échec du déplacement de 3 membres de Intern vers Writer'],
['user.change_role', 'Failed to change the role of Ines Duarte', 'Échec du changement de rôle d\'Ines Duarte'],
['user.transfer_ownership', 'Failed to transfer ownership to Jonas Weber', 'Échec du transfert de la propriété à Jonas Weber'],
['user.invite', 'Failed to invite sofia@renewbuild.example', 'Échec de l\'invitation de sofia@renewbuild.example'],
['user.remove', 'Failed to remove Ines Duarte', 'Échec du retrait d\'Ines Duarte'],
['Reason: role_conflict', 'Jonas Weber saved changes to Contributor first, so this edit was refused.', 'Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée.'],
['Reason: role_locked', 'The Owner role can\'t be changed or deleted.', 'Le rôle Owner ne peut être ni modifié ni supprimé.'],
['Reason: missing_permission', 'The role of this person doesn\'t include that permission.', 'Le rôle de cette personne n\'inclut pas cette autorisation.']]),
T('Activity filter: category names, EN and FR', '190px 1fr 1fr', ['Value','EN','FR'], [
['(none)', 'All categories', 'Toutes les catégories'],
['articles', 'Articles', 'Articles'],
['media', 'Media', 'Médias'],
['users', 'Users and invites (was "Users & invites")', 'Utilisateurs et invitations'],
['roles (new, to confirm)', 'Roles', 'Rôles'],
['api_keys', 'API keys', 'Clés API'],
['auth', 'Authentication', 'Authentification'],
['system', 'System', 'Système']]),
T('Final copy: toasts and errors, EN and FR', '230px 1fr 1fr', ['State','EN','FR'], [
['Toast: role changed', 'Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.', 'Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle.'],
['Toast: role edited elsewhere (409)', 'Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload', 'Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
['Toast: role deleted elsewhere', 'That role no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Ce rôle n\'existe plus · Il a été supprimé ailleurs, nous l\'avons donc retiré de la liste.'],
['Toast: member\'s role changed before a change of role (409)', 'Role not changed · Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again. · Reload', 'Rôle non modifié · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
['Toast: target\'s role changed before a transfer (409)', 'Ownership not transferred · Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again. · Reload', 'Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n\'a été transféré. Vérifiez-le et réessayez. · Recharger'],
['Toast: member\'s role changed before a removal (409)', 'User not removed · Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again. · Reload', 'Utilisateur non retiré · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été retiré. Vérifiez-le et réessayez. · Recharger'],
['Editor: name required', 'Give the role a name.', 'Donnez un nom au rôle.'],
['Editor: name taken (409)', 'A role named "Writer" already exists. Choose a different name.', 'Un rôle nommé « Writer » existe déjà. Choisissez un autre nom.'],
['Editor: save failed (500)', 'Couldn\'t save the role · Your changes are still here. Try again in a moment. · Try again', 'Impossible d\'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer'],
['Editor: stale save banner (409)', 'This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences', 'Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences'],
['Editor: review differences', 'How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits', 'Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications'],
['Editor: edits kept after reload', 'Role reloaded · Your 2 changes are still here. Review them, then save.', 'Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.'],
['Editor: Read = Own note', '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.', 'Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n\'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle.'],
['Change role: member\'s role changed (409)', 'Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again.', 'Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.'],
['Change role: failed (500)', 'Couldn\'t change the role · Nothing was changed. Try again in a moment.', 'Impossible de modifier le rôle · Rien n\'a été modifié. Réessayez dans un instant.'],
['Change role: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
['Delete role: target deleted', 'Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.', 'Writer n\'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.'],
['Delete role: role gained members (409)', 'Role still has members · Intern now has 1 member. Move them to another role first.', 'Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d\'abord vers un autre rôle.'],
['Delete role: move failed (500)', 'Couldn\'t move the members · Nothing was changed. Try again in a moment.', 'Impossible de déplacer les membres · Rien n\'a été modifié. Réessayez dans un instant.'],
['Invite: invalid email', 'Enter a valid email address.', 'Saisissez une adresse e-mail valide.'],
['Invite: account exists (409)', 'This address already has an account.', 'Cette adresse a déjà un compte.'],
['Invite: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
['Invite: send failed (500)', 'Couldn\'t send the invite · Try again in a moment.', 'Impossible d\'envoyer l\'invitation · Réessayez dans un instant.'],
['Transfer: target\'s role changed (409)', 'Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member', 'Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre']]),
T('Read = Own: what each surface returns', '230px 1fr 1fr', ['Surface','Read = All','Read = Own'], [
['Articles list', 'Every article, including drafts by others', 'Online articles plus the person\'s own drafts'],
['Status counts (All, Online, Draft)', 'Counted over every article', 'Counted over the visible articles only: Draft counts own drafts, Online counts every online article'],
['Custom type entry list', 'Every entry', 'Only entries the person created. The count line and pager use that number.'],
['API list calls and X-Total-Count', 'Every item', 'Only the visible items. The total does not reveal hidden ones.'],
['Direct link to a hidden item (proposed)', 'Opens', 'The existing 404 page: Entry not found. No hint that the item exists.'],
['Empty state', 'Existing empty states', 'You have no drafts · You haven\'t created an event yet. New article or New entry stays available when Create is on.']]),
T('Decisions to confirm', '60px 1fr 1fr', ['#', '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', 'Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted.', 'Hide other people\'s entries for Update and Delete too.'],
['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.']])
]
+7 -5
View File
@@ -10,7 +10,7 @@ export const groups = [
st('Error', N, '500 or network failure on the list request.', { v: 'types', k: 'error' }, 'Couldn\'t load content types · GET /v1/content-type failed. Check your connection and try again. · Try again'), st('Error', N, '500 or network failure on the list request.', { v: 'types', k: 'error' }, 'Couldn\'t load content types · GET /v1/content-type failed. Check your connection and try again. · Try again'),
st('Forbidden (403)', N, 'A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting.', { v: 'types', k: 'forbidden' }, 'You don\'t have access to Schema · Only owners and admins can change content types. You can still add and edit entries from the Content menu. · Go to Home'), st('Forbidden (403)', N, 'A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting.', { v: 'types', k: 'forbidden' }, 'You don\'t have access to Schema · Only owners and admins can change content types. You can still add and edit entries from the Content menu. · Go to Home'),
st('Built-in type: read-only view', N, 'Click Articles in the list. No edit, no delete, no add field.', { v: 'types', k: 'builtin' }, 'Articles · Built-in · Managed by RenewCMS · Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields. · Label · Key · Type · Rules · Entries of built-in types are managed from their own screens, like Articles and Authors.'), st('Built-in type: read-only view', N, 'Click Articles in the list. No edit, no delete, no add field.', { v: 'types', k: 'builtin' }, 'Articles · Built-in · Managed by RenewCMS · Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields. · Label · Key · Type · Rules · Entries of built-in types are managed from their own screens, like Articles and Authors.'),
st("Custom type: read-only view", N, "The fields table of a custom type with a media field. Same table as the built-in view; the Media type shows the image icon and the name \"Media\".", {"v":"types","k":"customro"}, "Events · Read only · You can read these fields here · Only an owner or admin can change a content type. · Label · Key · Type · Rules · Thumbnail · thumbnail · Media · One image from the library") st("Custom type: read-only view", N, "The fields table of a custom type with a media field. Same table as the built-in view; the Media type shows the image icon and the name \"Media\".", {"v":"types","k":"customro"}, "Events · Read only · You can read these fields here · Your role doesn't include Edit content types. Ask an owner or admin. (proposal; shown in the ReadOnlyBanner) · Label · Key · Type · Rules · Thumbnail · thumbnail · Media · One image from the library")
] }, ] },
{ label: '2 Schema builder', title: '2 Schema builder', route: '/schema/new, /schema/:key', api: 'POST /v1/content-type, GET/PUT /v1/content-type/{key}', auth: 'Owner or admin', note: 'One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.', states: [ { label: '2 Schema builder', title: '2 Schema builder', route: '/schema/new, /schema/:key', api: 'POST /v1/content-type, GET/PUT /v1/content-type/{key}', auth: 'Owner or admin', note: 'One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.', states: [
st('New type', N, '/schema/new. One starter field is added because a type needs at least one.', { v: 'builder', k: 'new' }, 'New content type · Name · For example, Events · Key · events · Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save. · Fields · 1 of 50 · Add field · Field: Title · Label · Key · The name used in the API. Starts with a letter. Lowercase letters, digits and underscores. · Type · Required · An entry can\'t be saved with this field empty. · Unique values · Default value · Used when an entry is created and this field is left out. · Shortest · Longest · Counted in characters. Entries show a counter. · Save · Discard'), st('New type', N, '/schema/new. One starter field is added because a type needs at least one.', { v: 'builder', k: 'new' }, 'New content type · Name · For example, Events · Key · events · Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save. · Fields · 1 of 50 · Add field · Field: Title · Label · Key · The name used in the API. Starts with a letter. Lowercase letters, digits and underscores. · Type · Required · An entry can\'t be saved with this field empty. · Unique values · Default value · Used when an entry is created and this field is left out. · Shortest · Longest · Counted in characters. Entries show a counter. · Save · Discard'),
@@ -23,7 +23,7 @@ export const groups = [
st('Choice list editor', N, 'A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row.', { v: 'builder', k: 'choices', sheet: true }, 'Choices · 5 of 50 · Add a choice · Add · Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save. · Choices can\'t be blank. · Two choices are the same.'), st('Choice list editor', N, 'A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row.', { v: 'builder', k: 'choices', sheet: true }, 'Choices · 5 of 50 · Add a choice · Add · Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save. · Choices can\'t be blank. · Two choices are the same.'),
st('Keyboard reorder', N, 'Focus the grip, press Space. Up and Down move, Space drops, Esc cancels. Move up, Move down and Remove buttons are on the selected row.', { v: 'builder', k: 'kb' }, 'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite)'), st('Keyboard reorder', N, 'Focus the grip, press Space. Up and Down move, Space drops, Esc cancels. Move up, Move down and Remove buttons are on the selected row.', { v: 'builder', k: 'kb' }, 'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite)'),
st('API preview open', N, 'The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry.', { v: 'builder', k: 'api' }, 'API preview · Where entries of this type live · A sample entry. Values come from each field\'s type and default. · Updates as you edit. Fields you add or remove here appear after you save. · Copy JSON · Copied'), st('API preview open', N, 'The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry.', { v: 'builder', k: 'api' }, 'API preview · Where entries of this type live · A sample entry. Values come from each field\'s type and default. · Updates as you edit. Fields you add or remove here appear after you save. · Copy JSON · Copied'),
st('Saving', N, 'PUT in flight. Inputs dim, Save shows the loader, nothing can be dragged.', { v: 'builder', k: 'saving' }, 'Saving…'), st('Saving', N, 'Save always opens the review first, even for a type with no entries. Apply is in flight: the dialog shows Applying…, inputs dim, nothing can be dragged. No entries info bar.', { v: 'builder', k: 'saving' }, 'Review changes to Events · Events has no entries yet, so nothing is checked. · Applying…'),
st('Saved', N, '200. A toast confirms, the badges clear.', { v: 'builder', k: 'saved' }, 'Saved at 11:02 · Content type saved · Events now has 8 fields.'), st('Saved', N, '200. A toast confirms, the badges clear.', { v: 'builder', k: 'saved' }, 'Saved at 11:02 · Content type saved · Events now has 8 fields.'),
st('Save failed', N, '500 or network failure. Nothing was sent as saved; the edits stay.', { v: 'builder', k: 'fail' }, 'Save failed · Couldn\'t save the content type · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again'), st('Save failed', N, '500 or network failure. Nothing was sent as saved; the edits stay.', { v: 'builder', k: 'fail' }, 'Save failed · Couldn\'t save the content type · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again'),
st('Key already taken', N, '409 content_type_key_taken on POST.', { v: 'builder', k: 'taken' }, 'The key "events" is already used by another content type. Choose a different key.'), st('Key already taken', N, '409 content_type_key_taken on POST.', { v: 'builder', k: 'taken' }, 'The key "events" is already used by another content type. Choose a different key.'),
@@ -84,9 +84,11 @@ export const groups = [
st("Media control: saving", N, "PUT in flight. The thumbnail and its buttons are disabled with the rest of the form.", {"v":"entry","k":"saving","m":"filled"}, "Saving…"), st("Media control: saving", N, "PUT in flight. The thumbnail and its buttons are disabled with the rest of the form.", {"v":"entry","k":"saving","m":"filled"}, "Saving…"),
st("Media control: read-only, type changed", N, "422 unknown_field. The form is read-only until reloaded.", {"v":"entry","k":"changed","m":"filled"}, "The fields of Events changed · 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. · Reload form") st("Media control: read-only, type changed", N, "422 unknown_field. The form is read-only until reloaded.", {"v":"entry","k":"changed","m":"filled"}, "The fields of Events changed · 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. · Reload form")
] }, ] },
{ label: '7 Relations', title: '7 Relation field and picker', route: '/schema/:key, /content/:key/:id', api: 'not built', auth: 'Owner or admin (picker), any verified user (form)', note: 'All later. The model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.', states: [ { label: '7 Relations', title: '7 Relation field and picker', route: '/schema/:key, /content/:key/:id', api: 'not built', auth: 'Owner or admin (picker), any verified user (form)', note: 'The picker is built; the model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.', states: [
st('Relation in the builder', L, 'A seventh type, Relation, in the type gallery.', { v: 'rel', k: 'builder' }, 'Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events"'), st('Relation in the builder', L, 'A seventh type, Relation, in the type gallery.', { v: 'rel', k: 'builder' }, 'Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events"'),
st('Relation in the entry form', L, 'Search and pick; chips for many.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers.'), st('Relation in the entry form', N, 'Search and pick; chips for many. The picker loads 25 results per page; the list scrolls and ends with Load more.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more'),
st('Relation picker: loading more', N, 'Load more pressed. The next page of 25 is requested. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, and the footer count stays until the new rows arrive. Focus stays on the button, then moves to the first new row.', { v: 'rel', k: 'more' }, 'Showing 25 of 62 · Loading… · (skeleton rows)'),
st('Relation picker: load more failed', N, 'Load more failed. The loaded rows stay. An inline error replaces the count and the button reads Try again. Focus stays on the button; after a successful retry it moves to the first new row.', { v: 'rel', k: 'moreerr' }, 'Couldn\'t load more results. The 25 shown are still here. · Try again'),
st('Relation in the list', L, 'One chip, two chips and +N, or a dash.', { v: 'rel', k: 'list' }, 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.') st('Relation in the list', L, 'One chip, two chips and +N, or a dash.', { v: 'rel', k: 'list' }, 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.')
] }, ] },
{ label: '8 Permissions', title: '8 Permissions grid', route: '/settings/permissions', api: 'not built', auth: 'Owner or admin', note: 'All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.', states: [ { label: '8 Permissions', title: '8 Permissions grid', route: '/settings/permissions', api: 'not built', auth: 'Owner or admin', note: 'All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.', states: [
@@ -201,7 +203,7 @@ export const tables = [
['9', 'The Schema item sits after Users in the sidebar. The Content group sits under the main items.'], ['9', 'The Schema item sits after Users in the sidebar. The Content group sits under the main items.'],
['10', 'The list shows 4 field columns by default and at most 6.']] }, ['10', 'The list shows 4 field columns by default and at most 6.']] },
{ title: 'Accessibility', tpl: '220px minmax(0,1fr)', cols: ['Topic', 'Rule'], rows: [ { title: 'Accessibility', tpl: '220px minmax(0,1fr)', cols: ['Topic', 'Rule'], rows: [
['Reorder', 'The grip is a button with aria-label "Move Ends on". Space picks up, Up and Down move, Space drops, Esc cancels, with a polite live region ("Moving Ends on. Position 2 of 7."). Move up, Move down and Remove buttons exist on the selected row and in the mobile sheet.'], ['Reorder', 'The grip is a button with aria-label "Move Ends on". Space picks up, Up and Down move, Space drops, Esc cancels, with a polite live region ("Moving Ends on. Position 2 of 7."). On mobile the field sheet carries Move up, Move down and Remove (44 px) above Done. Move up, Move down and Remove buttons exist on the selected row and in the mobile sheet.'],
['Focus order', 'Name, key, Save, Discard, field list (one tab stop, arrows move between rows), inspector top to bottom, API preview. Opening the inspector sheet on mobile moves focus to its title; closing returns it to the row.'], ['Focus order', 'Name, key, Save, Discard, field list (one tab stop, arrows move between rows), inspector top to bottom, API preview. Opening the inspector sheet on mobile moves focus to its title; closing returns it to the row.'],
['Focus', '2 px --ui-primary, offset 2 px, as everywhere.'], ['Focus', '2 px --ui-primary, offset 2 px, as everywhere.'],
['Labels and errors', 'Every control has a visible label. Error text is tied to its input with aria-describedby and aria-invalid. Rows with errors also show an icon, not only a red border.'], ['Labels and errors', 'Every control has a visible label. Error text is tied to its input with aria-describedby and aria-invalid. Rows with errors also show an icon, not only a red border.'],
+84 -4
View File
@@ -6,6 +6,8 @@ Every state in the three boards is drawn in light and dark (theme switch) at 144
Only individual states that render a relative time or a countdown are marked "Clock-sensitive" on the board: Articles "Published" (Updated reads "Just now"), Media "Upload: too many requests (429)" and Settings "Too many requests (429)" (countdowns), Users "Renew invite: done", and the Settings update states that show "Last checked today at 09:41" or "yesterday" (Up to date, Check failed, Update available, Update available with notes open, Update done, Plain restart: confirm, Admin view, Role changed, Update already running). Capture and test them with a frozen clock at 13 Oct 2026, 09:41. Frames that merely list dated rows are not marked. Only individual states that render a relative time or a countdown are marked "Clock-sensitive" on the board: Articles "Published" (Updated reads "Just now"), Media "Upload: too many requests (429)" and Settings "Too many requests (429)" (countdowns), Users "Renew invite: done", and the Settings update states that show "Last checked today at 09:41" or "yesterday" (Up to date, Check failed, Update available, Update available with notes open, Update done, Plain restart: confirm, Admin view, Role changed, Update already running). Capture and test them with a frozen clock at 13 Oct 2026, 09:41. Frames that merely list dated rows are not marked.
## Sites and authors: roles ## Sites and authors: roles
Permission model: access follows the role's permissions (Manage sites, Manage authors). The built-in Owner and Admin roles hold them, so the copy below still says "owner" and "owner or admin", as the app ships it. Moving the copy to permission wording is a separate app change (proposal).
Sites: only the owner can create, edit, delete or set the default; other roles have no Sites entry and get a 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 see the list and detail read-only and can create and edit their own author only. The site key is immutable after creation. Sites: only the owner can create, edit, delete or set the default; other roles have no Sites entry and get a 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 see the list and detail read-only and can create and edit their own author only. The site key is immutable after creation.
## Copy glossary ## Copy glossary
@@ -38,8 +40,8 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
| 400 invalid_site_url | "Use https. For example https://blog.example.com." / "Remove the path. Use only the host, like https://blog.example.com." / "Enter a full URL, like https://blog.example.com." (inline under base url) | | 400 invalid_site_url | "Use https. For example https://blog.example.com." / "Remove the path. Use only the host, like https://blog.example.com." / "Enter a full URL, like https://blog.example.com." (inline under base url) |
| 409 site_key_taken | "A site with the key "journal" already exists. Choose another key." (inline under key, with a suggestion) | | 409 site_key_taken | "A site with the key "journal" already exists. Choose another key." (inline under key, with a suggestion) |
| 409 site_has_articles | "Choose where the articles go. This site has 38 articles." (inside the delete dialog) | | 409 site_has_articles | "Choose where the articles go. This site has 38 articles." (inside the delete dialog) |
| 403 "Only the owner can do this." (page) | "You don't have access to Sites" / "Only the owner can manage sites. Ask the owner of this site if you need a change." / "Back to Settings" (403 page on /settings/sites) | | 403 owner_only (page; raised when the role lacks Manage sites) | "You don't have access to Sites" / "Only the owner can manage sites. Ask the owner of this site if you need a change." / "Back to Settings" (403 page on /settings/sites) |
| 403 "Only the owner can do this." (action) | "Only the owner can manage sites" / "Ask the owner of this site." (error toast, role changed while the page was open) | | 403 owner_only (action) | "Only the owner can manage sites" / "Ask the owner of this site." (error toast, role changed while the page was open) |
| 403 author_rights | "Only an owner or admin can change authors" / "You can still edit your own author profile." (error toast, role changed while the page was open) | | 403 author_rights | "Only an owner or admin can change authors" / "You can still edit your own author profile." (error toast, role changed while the page was open) |
| 400 key_immutable | "Used in API paths, so it can't change." (not shown: the edit form has no editable key) | | 400 key_immutable | "Used in API paths, so it can't change." (not shown: the edit form has no editable key) |
| 400 invalid_locale | "Use a language tag like en or fr-CH." (inline under locale) | | 400 invalid_locale | "Use a language tag like en or fr-CH." (inline under locale) |
@@ -54,6 +56,7 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
| 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). | | 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). |
| 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) | | 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) |
| 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." | | 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." |
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
| 404 content_type_not_found | Page: "Content type not found · It may have been deleted, or the address may be mistyped." | | 404 content_type_not_found | Page: "Content type not found · It may have been deleted, or the address may be mistyped." |
| 404 entry_not_found | Page: "Entry not found · It may have been deleted by someone else." | | 404 entry_not_found | Page: "Entry not found · It may have been deleted by someone else." |
| 409 content_type_key_taken | Under the key: "The key "events" is already used by another content type. Choose a different key." | | 409 content_type_key_taken | Under the key: "The key "events" is already used by another content type. Choose a different key." |
@@ -67,7 +70,6 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
| 422 invalid_entry: key: options | "Choose one of: Talk, Workshop or Meetup." | | 422 invalid_entry: key: options | "Choose one of: Talk, Workshop or Meetup." |
| 409 invalid_entry: key: unique | "Another entry already uses this Title. Enter a different one." plus the alert "Not saved" | | 409 invalid_entry: key: unique | "Another entry already uses this Title. Enter a different one." plus the alert "Not saved" |
| 422 invalid_entry: key: unknown_field | Alert "The fields of Events changed", button Reload form | | 422 invalid_entry: key: unknown_field | Alert "The fields of Events changed", button Reload form |
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
## Public image 400 bodies ## Public image 400 bodies
`{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback. `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback.
@@ -95,7 +97,7 @@ Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sit
## Custom content types ## Custom content types
Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (3), 8 Permissions (2), 9 Export and import (3). 65 states, 56 available now and 9 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md. Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (4), 8 Permissions (2), 9 Export and import (3). 66 states, 56 available now and 10 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md.
Roles: owners and admins see Schema and edit types. Any verified user sees the Content group and reads and writes entries. A user who is not owner or admin and opens /schema gets "You don't have access to Schema". Roles: owners and admins see Schema and edit types. Any verified user sees the Content group and reads and writes entries. A user who is not owner or admin and opens /schema gets "You don't have access to Schema".
@@ -107,3 +109,81 @@ Entry rules, 422 except unique (409): required, type, min, max, options, unique,
## Media field (pass 5) ## Media field (pass 5)
Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules for a media field: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options and unique do not apply. A required media field added to a type with entries is refused client side in the review ("Can't be applied") and by the server as 409 schema_incompatible. Image deleted while entries use it: the control shows "This image is no longer in the library"; saving is refused only when the field is required. HTTP statuses for the picker and the field use the table above (400, 401, 403, 404, 409, 413, 429, 500, network). Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules for a media field: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options and unique do not apply. A required media field added to a type with entries is refused client side in the review ("Can't be applied") and by the server as 409 schema_incompatible. Image deleted while entries use it: the control shows "This image is no longer in the library"; saving is refused only when the field is required. HTTP statuses for the picker and the field use the table above (400, 401, 403, 404, 409, 413, 429, 500, network).
## Roles and permissions
90 states, all Available now, listed with trigger and copy in `screens/roles.md`. Groups: Roles list 7, Role editor 15, Plain-language preview 4, Delete role 4, Users 9, Role-aware shell 8, No access 3, Activity 5, Profile 3, API keys 1, Create-a-role flow 6, Toasts 6, Inline errors 9, Read = Own 5, 409 conflicts 5. None is clock-sensitive (times are fixed strings).
Hidden or disabled:
| Case | Treatment | Example |
|---|---|---|
| Whole area the role never has | Hidden (no sidebar item, no page card). Direct link shows the 403 page. | Schema for a Writer |
| Action on something visible, blocked by role | Disabled with a reason in a tooltip or row text | Delete on an article for a Writer |
| Action that only exists above their level | Hidden | Publish for a Writer; status shows Draft and "Ask an editor to publish." |
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
No-escalation feedback:
| Rule | Where it shows | Copy |
|---|---|---|
| Grant only what you hold | Disabled segment or switch with a lock; popover on focus or hover | You can only grant what your role has. Your role can publish only entries you created, so All isn't available. |
| No role above yours | Disabled option in the role picker (Change role, Delete reassign, Invite) | Your role can't assign a role with more power than yours. |
| No editing your own role | Editor opens read-only with a banner | You can't change the permissions of your own role. Ask another owner or admin. |
| No delete while it has members | Delete dialog, blocked state | 4 members have this role. Move them to another role first. Then Contributor can be deleted. |
| Owner-only list | Disabled item with an Owner only tag and a lock | Only the owner can apply updates. |
HTTP status to copy (additions):
| Code | Status | Title | Body |
|---|---|---|---|
| missing_permission | 403 | You don't have permission to do that | Your role doesn't include {permission name}. Ask an owner or admin. |
| role_locked | 403 | This role is locked | The Owner role can't be changed or deleted. Transfer ownership from Users instead. |
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| escalation | 403 | You can't grant that | You can only grant what your role has. |
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
## Roles sync (pass 6)
### Wording
People are referred to as "they" everywhere: "Their API keys follow the new role." "You can't take ownership back unless they transfer it." No "he", "she", "his" or "her" in any frame or copy table. French uses "vous"; possessives agree with the noun, so they stay neutral ("ses clés API"). Built-in role names stay in English in the French copy until the translation of Owner, Admin and Writer is decided; custom names are user data and are never translated.
### Activity event names
Present tense, as the backend emits them. The outcome (succeeded or failed) is a field, so a failed event keeps its name.
| Event | Sentence | Failed sentence |
|---|---|---|
| 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 | 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.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
Failed template: "Failed to {verb} {object}". Category names in the filter: Articles, Media, Users and invites, Roles, API keys, Authentication, System. French for every string is in the board table "Final copy: toasts and errors, EN and FR" and in screens/roles.md.
### Stale state
Nothing is changed when a request is stale. The message names who or what changed, says nothing was changed, and ends with the recovery. Where it shows: banner in the role editor, alert inside a dialog, toast only when the surface is no longer open. The form keeps what the person entered. Recovery: Reload (toast), Reload role or Review differences (editor), Reload member (transfer), retry (change role).
| Case | Status | Where | Copy |
|---|---|---|---|
| Stale role save | 409 role_conflict | Banner in the editor | This role changed while you were editing · {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
| Change of role, member's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. |
| Transfer, target's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. |
| Removal, member's role changed first | 409 | Toast | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. |
| Role deleted by someone else | 404 | Info toast | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
### Read = Own
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so hidden items are not hinted at. Update or Delete set to Own still list other people's rows with the View only mark. Frames: group "14 Read = Own" on the Roles board.
## Wording status
Strings that match messages.ts are verbatim. "Only the owner can manage sites…" and other board-only wording are proposals. Moving the app to permission-based wording is a separate code change.
## Account: change email, export, delete
States and copy in screens/account.md. Board: RenewCMS Screens Account.dc.html (33 states) and the Auth board groups "Verify new email" (7) and "Account deleted" (1). Direct-download rate limit state is clock-sensitive (frozen clock 09:41).
| Status | Copy |
|---|---|
| 403 wrong_password | "That password isn't correct. Try again." (inline under password) |
| 429 | existing 429 copy, inline in the form or the dialog |
| 500, network | existing 500 copy, or "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
| 409 last_admin | "You can't delete your account yet" (non-dismissable dialog) |
| 409 email_unavailable | Landing only: "We couldn't change your email address" (same for in use and rejected) |
+68
View File
@@ -0,0 +1,68 @@
# Gotchas
> What trips up API clients: session-only routes, error texts, rate limits and draft visibility.
## Session-only routes
An API key cannot call these routes. They answer `403` with a plain-text body and no JSON:
```
HTTP/1.1 403 Forbidden
This action needs a real session.
```
- `/v1/api-key/*`, `/v1/system/*`, `POST /v1/auth/verify`, `DELETE /v1/auth/register`.
- Every activity route under `/v1/activity`.
- Writes on sites (`POST`, `PATCH`, `DELETE` under `/v1/site`, so a key cannot create a site) and on authors (`POST`, `PATCH`, `DELETE` under `/v1/author`).
- `PATCH /v1/settings`.
Variant routes under `/v1/media/{id}/variants` answer `401` to a key instead. The sign-in routes (login, logout, register, password reset) ignore a key and give no `403`.
When a task needs one of these routes, stop and tell the user to do it in the app. Do not try to log in with a password.
## Error texts are plain text
Errors are not JSON. The body is one sentence; branch on the status code, and show the sentence to the user.
| Status | Typical text |
| --- | --- |
| `400` | `The request cannot be processed due to a mismatch in the format of the body.` |
| `401` | `You must be logged in to access this resource.` |
| `403` | `This action needs a real session.`, `Only the owner can do this.`, `Only owners and admins can do this.`, `You must verify your account to access this resource.` |
| `404` | `Article not found`, `Image not found` |
| `429` | `Too many requests. Please try again later.` |
An invalid, unknown, expired or revoked key answers `401`, even on public routes. It is never treated as anonymous.
## Rate limit: 429
Each key may make 120 requests per minute. The `429` response carries a `Retry-After` header holding the seconds to wait:
```
HTTP/1.1 429 Too Many Requests
Retry-After: 46
Too many requests. Please try again later.
```
Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop. Prefer one list call with `size=100` over many single reads.
## Draft versus online
- Anonymous callers and unverified users only see online articles. A draft answers `404` with `Article not found`, the same as an id that does not exist.
- A verified session or a key sees the drafts its role may read: every draft with `content.read` at all, only its own with `content.read` at own. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page.
- A role without `content.update`, `content.delete` or `content.publish` for an article gets `403` with the body `missing_permission` and the header `X-Missing-Permission`. Saving a draft never publishes it.
- `POST /v1/article/` always creates a draft. Publishing is a separate call, `PATCH /v1/article/{id}/publish`.
- Sitemaps list online articles only.
## Writes
- A title cannot be changed after creation. Create a new article instead.
- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
- Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally.
- Ids in paths are unsigned integers. Anything else answers `400`.
## Permissions follow the key's creator
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
+81
View File
@@ -0,0 +1,81 @@
# Recipe: create, edit and publish an article
> Create a draft, replace its body, check its SEO, publish it and read it back, with an API key.
Set the key once. Every request below is real output from a running instance; ids and timestamps differ on yours.
```sh
export RENEWCMS_API_KEY=rcms_...
```
## 1. Create a draft
Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
```sh
curl -X POST "{{BASE_URL}}/v1/article/" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"How we price a bathroom renovation","body":"<p>A bathroom renovation is priced by room size, plumbing changes and finish level.</p>"}'
```
Status `201`:
```json
{"id":1,"title":"How we price a bathroom renovation","body":"A bathroom renovation is priced by room size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":1,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.767761797+02:00","slug":"how-we-price-a-bathroom-renovation","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":[]}
```
The article starts offline (`is_online: false`) on the default site.
## 2. Replace the body, move it to a site, set the excerpt
`PATCH` takes only `body`, `site_id`, `author_ids` and the SEO fields. At least one is required; `{}` answers `400`. Send `{"body": ""}` to empty a body.
```sh
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","site_id":2,"excerpt":"What drives the price of a bathroom renovation."}'
```
Status `200`:
```json
{"id":1,"title":"How we price a bathroom renovation","body":"## What drives the price\n\nRoom size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.771008847+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"What drives the price of a bathroom renovation.","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":[]}
```
## 3. Check the SEO before publishing
An empty JSON object checks the saved article. Pass any SEO field to check a change without saving it.
```sh
curl -X POST "{{BASE_URL}}/v1/article/1/seo/check" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
Status `200`:
```json
{"status":"suggestions","suggestions":[{"code":"desc_short","field":"seo_description","params":{"length":47,"min":70}},{"code":"cover_none","field":"cover_image_id","params":{}},{"code":"no_au","field":"author_ids","params":{}},{"code":"short","field":"body","params":{"length":66,"min":300}}],"slug":{"available":true,"suggestion":null}}
```
Suggestions never block publishing.
## 4. Publish
```sh
curl -X PATCH "{{BASE_URL}}/v1/article/1/publish" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
Status `200`, same article object with `"is_online":true`. `PATCH /v1/article/{id}/unpublish` reverses it.
## 5. Read it back without credentials
```sh
curl "{{BASE_URL}}/v1/article/1?format=markdown"
```
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
+62
View File
@@ -0,0 +1,62 @@
# Recipe: upload an image and attach it to an article
> Upload a file with multipart form data, find it in the library and use it as an article cover.
## 1. Upload
Send the file in a form field named `file`. PNG, JPEG, WEBP and SVG are accepted.
```sh
curl -X POST "{{BASE_URL}}/v1/media/" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-F "file=@north-wall.png"
```
Status `201`:
```json
{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0}
```
`url` is relative to the instance: the public address is `{{BASE_URL}}` followed by `url`. `duplicate: true` means the same bytes were already in the library.
## 2. Find an image
```sh
curl "{{BASE_URL}}/v1/media/?size=5" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
Status `200`:
```json
{"items":[{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0}],"total":1,"page":1,"size":5,"counts":{"all":1,"unused":1,"duplicates":0}}
```
Filter with `q`, `hash`, `filter` (`all`, `unused`, `duplicates`), `sort` and `variants`.
## 3. Attach it as the cover
An image is attached by id. `cover_image_alt` is the alternative text.
```sh
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cover_image_id":1,"cover_image_alt":"A tiled bathroom wall"}'
```
Status `200`, the article object with `"cover_image_id":1` and `"cover_image_alt":"A tiled bathroom wall"`. Use `social_image_id` for the sharing image, and `null` to remove either image.
## 4. See where an image is used
```sh
curl "{{BASE_URL}}/v1/media/1/usage" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
```json
[{"type":"article","id":1,"title":"How we price a bathroom renovation","published":true,"fields":["cover"]}]
```
Deleting or merging an image that is in use changes those articles, so read this first.
+67
View File
@@ -0,0 +1,67 @@
# Recipe: paginate, filter and choose a body format
> List articles page by page, search and sort them, and read bodies as markdown, HTML or JSON blocks.
`GET /v1/article/` always answers an envelope, never a bare array:
```sh
curl "{{BASE_URL}}/v1/article/?format=html&size=5&page=1" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
```json
{"items":[{"id":1,"title":"How we price a bathroom renovation","body":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","body_format":"html","is_online":true,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:24.236138156+02:00","excerpt":"What drives the price of a bathroom renovation.","slug":"how-we-price-a-bathroom-renovation","seo_status":"suggestions","seo_suggestions":3}],"total":1,"page":1,"size":5,"counts":{"all":1,"online":1,"draft":0,"suggestions":1}}
```
In JSON, `<` and `>` are escaped as `<` and `>`; a JSON parser returns the plain characters.
## Walking every page
1. Request `page=1` with the `size` you want (`5`, `25`, `50` or `100`).
2. Read `total` and `size`. The number of pages is `ceil(total / size)`.
3. Request the next pages until `page * size >= total`. A page past the end answers `200` with `items: []`, so an empty `items` also ends the loop.
Ordering is applied to the whole list before slicing and ends with the id, so pages never overlap or skip.
## Filters
Combine them freely.
| Parameter | Example | Effect |
| --- | --- | --- |
| `q` | `q=grout` | Title contains the text, ignoring case and accents. Up to 200 characters. |
| `status` | `status=draft` | `online` or `draft`. |
| `site` | `site=renew-blog` | Only that site's articles. |
| `seo` | `seo=suggestions` | `ready`, `suggestions` or `hidden`. |
| `sort` | `sort=title:asc` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc`. Default `updated_at:desc`. |
`counts` ignores every filter, so tab totals do not move while searching. `total` follows the filters.
```sh
curl "{{BASE_URL}}/v1/article/?site=renew-blog&status=draft&sort=title:asc&q=grout" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
```json
{"items":[{"id":2,"title":"Draft about grout","body":"x","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:12:24.922895943+02:00","updated_at":"2026-10-23T01:12:24.926728869+02:00","excerpt":"","slug":"draft-about-grout","seo_status":"suggestions","seo_suggestions":4}],"total":1,"page":1,"size":25,"counts":{"all":2,"online":1,"draft":1,"suggestions":2}}
```
## The `format` parameter
| Value | `body` | `body_format` |
| --- | --- | --- |
| absent or `markdown` | markdown string | `markdown` |
| `html` | the stored HTML | `html` |
| `json` | array of blocks | `json` |
It applies to every article route, including the writes. Request bodies of `POST` and `PATCH` stay HTML whatever the format. An invalid `format` is reported before any other error.
## Invalid values
A bad `page`, `size`, `status`, `sort` or `seo` answers `400` with a plain-text body:
```
The request cannot be processed due to a mismatch in the format of the body.
```
Media lists paginate the same way (`GET /v1/media/?page=2&size=50`) with their own `counts`.
+66
View File
@@ -0,0 +1,66 @@
# Recipe: sites and sitemaps
> Create a site (session only), move an article onto it and read its public sitemap.
An article belongs to one site. A site has a `key` (a slug used in URLs) and a `base_url` (the public address its pages are served from).
## 1. List the sites
```sh
curl "{{BASE_URL}}/v1/site/" \
-H "Authorization: Bearer $RENEWCMS_API_KEY"
```
```json
[{"id":1,"name":"Default","key":"default","base_url":"http://localhost:18080","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":true,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""}]
```
## 2. Create a site: not with a key
Creating, editing, defaulting and deleting a site need a browser session from the owner. A key answers `403`:
```sh
curl -X POST "{{BASE_URL}}/v1/site/" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"x","key":"x","base_url":"https://x.example"}'
```
```
HTTP/1.1 403 Forbidden
This action needs a real session.
```
Ask the owner to create the site in the app, or send the request with the owner's `jwt` cookie. With a session the same request answers `201`:
```json
{"id":2,"name":"Renew blog","key":"renew-blog","base_url":"https://blog.renew.example","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":false,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""}
```
## 3. Put an article on the site
Use the site `id` as `site_id`:
```sh
curl -X PATCH "{{BASE_URL}}/v1/article/1" \
-H "Authorization: Bearer $RENEWCMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"site_id":2}'
```
## 4. Read the sitemap
The sitemap is public and lists the online articles of one site, by its `key`:
```sh
curl "{{BASE_URL}}/v1/site/renew-blog/sitemap"
```
```json
[{"url":"https://blog.renew.example/how-we-price-a-bathroom-renovation","modified_at":"2026-10-22T23:11:24Z","noindex":false}]
```
`url` is the site's `base_url` plus the article slug. Drafts are not listed. The response header `X-Total-Count` holds the number of entries. Page with `page` and `size` (`25`, `50`, `100` or `500`, default `100`); any other `size` answers `400`. An unknown key answers `404` with `site_not_found`.
List only one site's articles with `GET /v1/article/?site=renew-blog`.
+22
View File
@@ -0,0 +1,22 @@
# LLM documentation
A running instance serves its own documentation for language models, versioned with the binary:
| URL | Content |
| --- | --- |
| `/llms.txt` | Short index with links, following llmstxt.org. |
| `/llms-full.txt` | Every page below in one file. |
| `/llms/<page>.md` | One page: `api-reference`, `api`, `articles`, `api-keys`, `gotchas`, `recipe-articles`, `recipe-media`, `recipe-sites`, `recipe-pagination`, `configuration`. |
| `/openapi.json` | OpenAPI 3.1 description of the routes in the catalogue. |
All of them are public. `{{VERSION}}` and `{{BASE_URL}}` in the sources are replaced by the binary's version and `PUBLIC_URL` when served.
## How it is built
`task build:llms` runs `scripts/generate-llms.ts` (bun) and writes into `internal/infrastructure/llmdocs/generated/`, which is gitignored and embedded in the binary. `task build`, `task release`, `task dev:api` and the Docker image run it first. Without it the routes answer 404 with a notice.
- The API reference and `openapi.json` come from `web/src/data/apiCatalogue.ts`, the same file the Integration page renders. Edit the catalogue, not the output.
- `docs/api.md`, `docs/api/*.md`, the variables table of `docs/configuration.md` and every `docs/llm/*.md` are included as written. A page in `docs/llm/` starts with `# Title`, then a `> one-line summary` used by `llms.txt`.
- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes.
`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented.
+37
View File
@@ -0,0 +1,37 @@
# MCP server
`cmd/mcp` builds `renewcms-mcp`, a stdio [MCP](https://modelcontextprotocol.io) server for Claude Desktop, Claude Code, Cursor and similar clients. It is an HTTP client of the public API: it holds no database access and sends the API key as `Authorization: Bearer`, so what the key's user may do is the limit of what the model may do.
Build it with `task build:mcp` (`bin/renewcms-mcp`).
## Configuration
| Name | Description |
| --- | --- |
| `RENEWCMS_URL` | Address of the instance, for example `https://cms.example`. |
| `RENEWCMS_API_KEY` | An API key created at `/integration/api-keys`. |
They belong to the MCP binary only and are not server variables.
```json
{
"mcpServers": {
"renewcms": {
"command": "/path/to/renewcms-mcp",
"env": { "RENEWCMS_URL": "https://cms.example", "RENEWCMS_API_KEY": "rcms_..." }
}
}
}
```
## Tools
`list_articles`, `get_article`, `create_article` (always a draft), `update_article_body`, `publish_article`, `check_seo`, `list_media`, `upload_media` (local `path`, or `base64` with `filename`), `list_sites`, `get_sitemap`.
`--allow-destructive` adds `delete_article` and `delete_media`.
Routes that need a browser session, such as creating a site, are not exposed. API errors come back as tool errors with the status and the plain-text message.
## Resources and prompts
The resource `renewcms://docs/llms-full.txt` is the instance's own [`/llms-full.txt`](llms.md), so the model reads the documentation of the exact version it talks to. The prompt `write_and_publish_article` drafts, checks the SEO and asks before publishing.
+8 -8
View File
@@ -15,14 +15,15 @@ Readers of your public site are not tracked by RenewCMS: the public routes set n
| Data | Where | Kept | | Data | Where | Kept |
|---|---|---| |---|---|---|
| Username, email, display name, role, bcrypt password hash | `users` table | Until the account is deleted (see [Gaps](#gaps)). | | Username, email, display name, role, bcrypt password hash | `users` table | Until the user deletes the account (see Erasure under [Answering requests from the people concerned](#answering-requests-from-the-people-concerned)). |
| Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. | | Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. |
| Pending email change: the new address (`pending_email`), its bcrypt-hashed code (`email_change_code`) and the expiry (`email_change_expiration`) | `users` table | Two hours, then useless; cleared when confirmed, cancelled or replaced. |
| Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. | | Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. |
| Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. | | Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. |
| Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. | | Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. |
| API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. | | API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. |
| Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. | | Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. |
| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. | | Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. When the linked user deletes their account, the author is deleted if no article credits it, otherwise it is kept as "Deleted user" with no bio, avatar, website or links. |
| Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). | | Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). |
| Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. | | Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. |
@@ -45,7 +46,7 @@ RenewCMS sets no analytics, advertising or third-party cookie, so no consent ban
| Destination | When | What | | Destination | When | What |
|---|---|---| |---|---|---|
| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset | Recipient address, the message (with a one-time link). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. | | Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset, email change, account deleted | Recipient address, the message (with a one-time link, except the account-deleted notice, which holds the site address only). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. |
| Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. | | Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. |
| `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. | | `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. |
@@ -62,16 +63,15 @@ The back office loads nothing from third parties: fonts, icons and scripts are s
## Answering requests from the people concerned ## Answering requests from the people concerned
- **Access and portability (art. 15 and 20)**: there is no self-service export yet. The data of a user is their row in `users`, their author profile, their API keys and the activity events where they are the actor; an owner can export the activity log as CSV from the Activity page and filter it by person. - **Access and portability (art. 15 and 20)**: every verified user downloads a copy of their data from the Your data card on Profile (`GET /v1/user/me/export`). It is a ZIP of four pretty-printed JSON files: `profile.json` (account, role, author profile, API-key metadata without the key or its hash), `authored-content.json` (every article they created, drafts included, the published articles crediting their author profile, and entries they created; credited authors appear as id, name and slug only; other people's drafts are never included), `uploaded-media.json` (references to the files they uploaded, no bytes) and `account-activity.json` (the events where they are the actor). It never holds the password hash, any code, the token version or other people's data, and it leaves out anonymous failed-login events (their actor is not an account), events whose target is another user, an invite or another person's author profile, and soft-deleted articles and images. The archive is built in memory, and at most 2 exports are built at the same time across all users; a request whose wait for a free slot is cancelled or times out answers 503 and does not use the user's slot. One export is allowed per user every 15 minutes, and each one is recorded as `user.export_data`. An owner can also export the activity log as CSV from the Activity page and filter it by person.
- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit. - **Rectification (art. 16)**: users can change their display name, author profile and email address themselves. An email change needs the current password and a link sent to the new address; the old address is told about the request and keeps working until the link is opened.
- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps). - **Erasure (art. 17)**: every verified user deletes their own account from the Danger zone card on Profile (`DELETE /v1/user/me`), with their password and the typed word `DELETE`; an unverified account can still cancel its own registration. The last Owner, and the last administrator (built-in Admin role with no Owner), are refused until another verified user holds the role, so the workspace is never left without one. In one atomic operation, **removed**: the user row (username, display name, email, password hash, codes), every API key, every session, and the linked author profile when no article credits it. An author profile credited on articles is **scrubbed**, not deleted: it becomes "Deleted user" with the slug `deleted-user-<id>` (that prefix is reserved, so nobody can pick it), no bio, website, social links or user link. Its **avatar image** is deleted (the media row in the same operation, the stored file and its generated variants right after the commit; a storage failure is logged and does not undo the deletion) unless the image is also used by an article (body, cover or social image), another author, a site's default social image or a custom entry, in which case it stays. In the **activity log** the events where the user is the actor or the target lose their name (it becomes "Deleted user"), their IP address and browser, and their `params` and `changes` (which can hold display names, author names, bios and invitee emails); author events the user made on their own profile also lose the profile name. The events themselves (action, outcome, time) stay, and so do author events another person made on that profile, and events written by other people about the user, which keep their text but not the user's name. A mail "Your RenewCMS account has been deleted" (the site address only) is sent to the address the account had, and the deletion is recorded as a `user.delete_account` event under "Deleted user", with the IP address and browser of the request: it is the one trace of the erasure.
What remains, honestly: articles, entries and media the user created keep the old user id in `created_by` / `updated_by` / `uploaded_by_id` (shown as "Deleted user"; the id no longer points to anyone); `invites.invited_by` can point at the deleted id; anonymous failed-login events keep the username that was typed (see [Gaps](#gaps)); soft-deleted articles and images stay as rows (see [Gaps](#gaps)); and the browser of the person may still hold unsaved drafts and interface choices in `localStorage` / `sessionStorage` on their device, which RenewCMS cannot reach.
## Gaps ## Gaps
These are known and tracked; until they are fixed, handle them by hand. These are known and tracked; until they are fixed, handle them by hand.
- No way to delete a verified user or remove an admin from the interface.
- No self-service export or email change.
- Soft-deleted articles and images keep their rows indefinitely. - Soft-deleted articles and images keep their rows indefinitely.
- The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens. - The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens.
- Images uploaded before metadata stripping keep their metadata. - Images uploaded before metadata stripping keep their metadata.
+6
View File
@@ -43,3 +43,9 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v3.4.0-beta` | Custom content types | | `v3.4.0-beta` | Custom content types |
| `v3.4.1-beta` | Media field and security updates | | `v3.4.1-beta` | Media field and security updates |
| `v3.4.2-beta` | Security and privacy hardening | | `v3.4.2-beta` | Security and privacy hardening |
| `v3.5.0-beta` | Relation field |
| `v3.5.1-beta` | Relation picker fix |
| `v3.5.2-beta` | LLM documentation and MCP server |
| `v3.5.3-beta` | OpenAPI response schemas |
| `v3.6.0-beta` | Roles and permissions |
| `v3.7.0-beta` | Account self-service |
+40
View File
@@ -0,0 +1,40 @@
This release adds a Relation field to custom content types, so an entry can point at entries of another type.
## New
**Relation field**
- A content type can now have a Relation field that links to another content type. Three kinds are available: one to one (a target can be used by only one entry), one to many (one target per entry) and many to many (up to 50 targets per entry).
- Entries are linked through a searchable picker in the entry form. Chosen targets appear as chips labelled with the first field of the target entry, or "Entry N" when that field is empty.
- Entry lists and cards show the linked entries of each relation column.
- When a linked entry is deleted afterwards, the relation is shown as missing instead of breaking the page. A required relation with a missing target must be fixed before the entry can be saved.
- The schema builder previews the inverse side of a relation, and the schema review dialog explains the impact of adding or removing one.
## Improvements and fixes
- Deleting a content type that another type points at is refused, and the delete dialog tells you which types depend on it.
- Adding a required relation to a type that already has entries is refused with the usual schema incompatibility message.
- The right side panel of the article editor no longer overflows vertically on short screens.
- The web app is split into smaller chunks, so the first page load transfers less code.
## API
- Changed: every entry answer (`POST`, `PUT` and `GET` of one entry, and each item of a list) carries a top-level `relations` object keyed by relation field key. Each value holds `id`, `content_type` and `first_field` for an existing target, or `id` and `missing: true` for a deleted one. Many to many fields return an array.
- `GET /v1/content-type/{key}/entry` accepts `q`, a text search over the entries of the type, applied before pagination. `total` reflects the filtered count.
- `POST /v1/content-type` and `PUT /v1/content-type/{key}` accept `type: "relation"` fields with `relation: {"content_type", "kind"}`. A bad definition answers `422` `invalid_field`.
- Entry writes answer `422` `invalid_entry: <key>: required` or `invalid_entry: <key>: type`, and `409` `invalid_entry: <key>: unique` for a one to one target already taken.
- `DELETE /v1/content-type/{key}` answers `409` `content_type_referenced` while another type has a relation to it.
## Under the hood
- New feature specs and tests cover the backend and the web app, in `features/relation_field_type.feature` and `features/relation_field_spa.feature`.
- Vendor chunk splitting is configured in `web/vite/vendorChunks.ts` and covered by a spec.
## Upgrading
- Replace the binary, or pull the new image. Back up the database first as usual.
- No schema migration and no new environment variable. Existing content types and entries are unchanged.
## Known limitations
- Relation search filters entries in memory, so it is meant for types with a moderate number of entries.
- The inverse side of a relation is only shown in the builder preview. The target type's form, list and API do not list the entries pointing at it.
+9
View File
@@ -0,0 +1,9 @@
This release fixes the Relation field picker, which could not load any entries.
## Improvements and fixes
- Choosing a target for a Relation field no longer fails with "Couldn't load" and a `400` error. The picker asked for pages of 10 entries, a size the entry list refuses, and now asks for 25.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
+28
View File
@@ -0,0 +1,28 @@
This release lets language models read and use a RenewCMS instance: the server publishes its own documentation, an OpenAPI description, and a separate MCP server drives the API with a key.
## New
**Documentation for language models**
- Every instance serves `/llms.txt` (a short index), `/llms-full.txt` (all the documentation in one file), `/llms/<page>.md` (one page each) and `/openapi.json`. They are public, and carry the version and address of the instance that serves them.
- The pages include the API reference, the API key and article guides, ready-to-run recipes with real responses (publish an article, upload and attach an image, sites and sitemaps, pagination and formats), and a gotchas page on session-only routes, error texts, rate limits and draft visibility.
**MCP server**
- The new `renewcms-mcp` binary is a stdio MCP server for Claude Desktop, Claude Code, Cursor and similar clients. It calls the public API with an API key, so the key's rights limit what the model can do.
- Tools: list, get, create, update the body of and publish articles, check SEO, list and upload media, list sites and read a sitemap. Deletion tools are only offered with `--allow-destructive`.
- It exposes the instance's `llms-full.txt` as a resource and a prompt that drafts, checks and asks before publishing. Setup is in `docs/mcp.md`.
## API
- `GET /llms.txt`, `GET /llms-full.txt`, `GET /llms/{page}` and `GET /openapi.json` are public routes outside `/v1`. They answer `404` with a notice when the build did not generate them.
## Under the hood
- The API reference and `openapi.json` are generated from `web/src/data/apiCatalogue.ts` by `task build:llms`, which `task build`, `task release` and the Docker image run first.
- A new test fails when a route of the router is missing from the catalogue. Twenty-five existing routes (content types, entries, builtin types, image variants, media variants and the user lookup) are still undocumented and listed as exceptions in that test.
- The catalogue now records the success status of each endpoint.
- `task build:mcp` builds the MCP server. `task release` does not include it yet.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
- The MCP server reads `RENEWCMS_URL` and `RENEWCMS_API_KEY` in its own process; the server does not need them.
+13
View File
@@ -0,0 +1,13 @@
This release fixes the OpenAPI description served at `/openapi.json`, whose response schemas were empty.
## Improvements and fixes
- Every successful response in `/openapi.json` now has a schema (object properties, required keys, array items and scalar types) instead of an empty one, so client generators produce typed models. The schemas are inferred from the sample responses of the API reference.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
## Known limitations
- Because the schemas come from sample responses, a field that is `null` in a sample is typed `null`, a field missing from a sample is missing from its schema, and timestamps are plain strings.
+64
View File
@@ -0,0 +1,64 @@
This release replaces the fixed owner, admin and writer roles with roles and permissions you can build and assign from the web app.
## New
**Roles**
- A new Roles screen under Settings lists every role with its member count. You can create a role, edit its permissions in a grid, preview the result, reassign its members, delete it, and reset a built-in role to its defaults.
- Each permission is set to None, Own or All. Content permissions (read, create, update, delete, publish) can also be set per content type, and the per-type setting replaces the general one for that type.
- Three built-in roles ship with the app: Owner (locked), Admin and Writer. Only the Owner edits the Admin and Writer roles.
- Nobody can grant a permission they do not hold, edit their own role, or touch a role that holds more than they do.
**Users and ownership**
- Invitations carry a role (Writer by default), and pending invites show it.
- A Change role dialog on the Users screen moves a person to another role.
- The Owner can transfer ownership to a verified user by typing their email to confirm. The former Owner becomes an Admin, and the change takes effect at once.
- Users show who last assigned their role. Your Profile has a role card with a summary of what you can do.
**Content permissions**
- Articles and entries of custom content types follow the content permissions of your role. With Own, you only change what you created, and other people's items are shown as view only.
- Publish is its own permission: a role can write drafts without being able to put them online.
- The articles list and the editor hide or disable what your role cannot do, and say why.
- Settings pages are read only, with a banner, for roles that can view but not edit. Owner only actions are marked as such.
**Integration page**
- The Integration page has a search bar and a complete list of API routes, including the ones for API keys.
## Improvements and fixes
- API keys follow the current role of their creator, and the API keys screen says so.
- The Activity log records role changes, role edits, ownership transfers and the role of each invite, and has a Roles category with a diff of the grants in the detail panel.
- The "Jump to" links on the Integration page now scroll only the page's own scroll container.
- `/settings/permissions` is retired and redirects to `/settings/roles`.
## API
- `GET /v1/role`, `GET /v1/role/{id}`, `POST /v1/role`, `PUT /v1/role/{id}`, `DELETE /v1/role/{id}`, `POST /v1/role/{id}/reassign` and `POST /v1/role/{id}/reset` manage roles. Writes need a real session. Refusals are plain text codes such as `role_locked`, `own_role`, `escalation`, `role_name_taken`, `role_has_members` and `role_conflict` (stale `version`).
- `PUT /v1/user/{id}/role` changes a user's role with `{"role_id": N}`. It answers `403` `role_locked` for the Owner role and `403` `own_role` for your own account.
- `POST /v1/site/transfer-ownership` takes `{"user_id", "confirm_email"}`. It answers `400` `confirmation_mismatch` or `invalid_transfer`.
- `POST /v1/user/invite` accepts an optional `role_id`; the renew response and invite items carry the role.
- Changed: a refused call answers `403` `missing_permission` with an `X-Missing-Permission` header naming the permission, instead of "Only owners and admins can do this." or "Only the owner can do this.".
- Changed: user and invite items carry `role` as `{ id, name, built_in, locked }`, and the plain `role` string is gone. Accepted users also carry `role_assigned_by`.
- Changed: `GET /v1/auth/me` carries an `access` object with the role, the effective permissions and `role_assigned_by`.
- Changed: article and entry responses carry `created_by` for signed-in callers and API keys. Drafts are listed and read according to `content.read`.
- Changed: creating a content type whose key is `article`, `author`, `site`, `media` or `user` answers `409` `content_type_key_taken`.
- Changed: deleting a content type removes its per-type grants from every role.
- `GET /v1/activity` accepts the `roles` category and the new `user.change_role`, `user.transfer_ownership` and `role.*` events.
## Under the hood
- Roles, grants and a migration marker are stored in new tables, and the permission checks live behind a domain role package used by the HTTP middleware.
- New feature specs and tests cover the backend and the web app, in `features/roles_permissions_*.feature`, `features/roles_spa_*.feature`, `features/integration_search.feature` and `features/integration_api_key_routes.feature`.
- The design specs for roles are in `docs/design/screens/roles.md`.
## Upgrading
- Replace the binary, or pull the new image. Back up the database first as usual.
- The schema migrates by itself at startup. The three built-in roles are seeded, existing users and open invites are moved to the role matching their former owner, admin or writer value, and existing articles and entries get a creator when one can be found. The old `users.role` column stays in the database, unused.
- No new environment variable.
- Behaviour change for existing installs: Writers can no longer publish unless you grant it, and every restricted action now answers `missing_permission`.
- Clients that read the `role` string of users or invites must read `role.name` instead.
## Known limitations
- An unattributed article or entry (`created_by` 0) is owned by nobody, so a role at Own cannot change it.
- A user whose role no longer exists is denied everywhere and gets a `500` on `GET /v1/auth/me` until an Owner assigns a role.
+51
View File
@@ -0,0 +1,51 @@
This release lets people manage their own account: change their email address, download a copy of their data and delete their account.
## New
**Change your email address**
- Profile has a form to change your email. It needs your current password and sends a confirmation link to the new address.
- The old address is told about the request and keeps working until the link is opened. You can resend or cancel the pending change.
**Your data**
- A Your data card on Profile downloads a ZIP with your profile, the content you created, your uploaded media references and your account activity. It never holds your password hash, codes or API key hashes.
- One export is allowed every 15 minutes per user.
**Delete your account**
- A Danger zone card on Profile deletes your account with your password and the typed word `DELETE`.
- Your API keys and personal activity details are removed. Your author profile is deleted, or kept as "Deleted user" without personal details when articles credit it. Your articles, entries and media stay and show "Deleted user".
- The last Owner, and the last administrator, cannot delete their account until another verified user holds the role.
- A short confirmation mail is sent to the address the account had.
## Improvements and fixes
- Users can be removed by someone with the `users.remove` permission, and the removal is recorded in the Activity log.
- The media panel shows when an image is used by an author or a site, and the "used in" badge stays readable on a selected card.
- The role editor footer stays at the bottom of the screen, and other footers no longer float.
- Table cells no longer overflow each other.
- Date separators are hidden while a date picker field is idle, so labels no longer overlap.
- French button labels no longer overflow.
- The PostgreSQL backup bug and a stale PostgreSQL connection in the tests are fixed.
## API
- `POST /v1/auth/email/change`, `POST /v1/auth/email/resend`, `DELETE /v1/auth/email/change` and `POST /v1/auth/email/confirm` change the email of a verified, signed-in user. Refusals are plain text codes such as `same_email`, `wrong_password`, `no_pending_email_change`, `invalid_email_change_code` and `email_unavailable`. Change and resend share a limit of 5 per user per 15 minutes.
- `GET /v1/user/me/export` answers the ZIP of your data. It needs a real session and answers `429` with `Retry-After` when used twice within 15 minutes.
- `DELETE /v1/user/me` takes `{"current_password", "confirmation": "DELETE"}` and answers `204`. It answers `403` `wrong_password` or `409` `last_admin` (with `X-Last-Role`) when refused, and `429` after 5 attempts in 15 minutes.
- `DELETE /v1/user/{id}?expected_role_id=N` removes a user and needs `users.remove`. It answers `409` `user_role_changed` when the target's role changed.
- `POST /v1/role/{id}/reassign` answers `409` `target_role_not_found` when the target role no longer exists.
- Changed: `GET /v1/auth/me` carries `pending_email`, a string or `null`.
- `GET /v1/activity` accepts the new `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` and `user.remove` events.
## Under the hood
- Three new mail templates: `verify-new-email`, `email-change-notice` and `account-deleted`.
- The own-scope visibility filter moved into the repositories, and list calls no longer ignore their errors.
- New feature specs and tests in `features/account_change_email.feature`, `features/account_export_data.feature` and `features/account_delete.feature`.
- `docs/privacy.md` and `docs/api.md` describe the new self-service rights.
## Upgrading
- Replace the binary, or pull the new image. Back up the database first as usual.
- The schema migrates by itself at startup: `users` gets `pending_email`, `email_change_code` and `email_change_expiration`, and the activity log gets an index on its target.
- No new environment variable.
- Email change and account deleted mails use your existing SMTP settings.
+292
View File
@@ -0,0 +1,292 @@
Feature: Change my email address from Profile, verified by a link sent to the new address
As a verified RenewCMS user
I want to change my email address with my current password and a link sent to the new address
So that I keep control of my account address and nobody can take it over without my password
or without access to the new inbox
Background:
Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing",
docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states)
and the board "RenewCMS Screens Auth" group "Verify new email" (7 states)
And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier,
AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser,
so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.",
an API key gives 403 "This action needs a real session." and an unverified user gives
403 "You must verify your account to access this resource.":
| method | path | 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>"} |
And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes
(middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later."
with a Retry-After header; DELETE and confirm are not limited
And the request struct uses validate tags checked with validator.Validate.Struct:
new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator)
and current_password "required"; there is no hand-written validation in the handler
And error bodies are plain text from http.Error, snake_case codes are the whole body:
403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code",
409 "email_unavailable"; success bodies are JSON
And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset
code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and
PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange,
ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and
confirm a pending change; confirming is one atomic step that re-checks the address is free
And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns
every rule; the handler never touches a repository, GORM or the mailer
And password checking uses the domain hash.Hasher (never bcrypt in the handler)
And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the
old address keeps receiving password-reset links and the password and username keep working
And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload
And three mail templates are built from docs/design/source/emails and added to
internal/infrastructure/mailer/templates:
| template | to | data | subject |
| verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address |
| email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested |
And Host is PUBLIC_URL and Username is the account's username
And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and
user.confirm_email_change are added (category users, source web); request, resend and confirm are
security events; no email address is written into an event
And the layering rule holds and no comment is added anywhere
Rule: Requesting a change
Background:
Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026"
Scenario: A valid request stores a pending change and sends two mails
When marta sends POST /v1/auth/email/change with
{"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"}
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
And users.email is still "marta@renew.example"
And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw
code) and the expiration is within 5 seconds of now plus 2 hours
And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and
data Host, Username "marta" and the raw VerificationCode
And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host,
Username "marta" and NewEmail "marta.alves@atelier-renew.example"
And one audit event user.request_email_change is recorded: actor marta, target user 7, success,
security, with no address in its params
Scenario Outline: A refused request changes nothing and sends nothing
When marta sends POST /v1/auth/email/change with <body>
Then the response is <status> with the body "<text>"
And PendingEmail is empty and no mail is sent
Examples:
| body | status | text |
| {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email |
| {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email |
| {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 400 | the validator message for current_password |
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
| {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email |
| {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password |
Scenario: A wrong password records a failed security event
When marta sends the request with the password "nope"
Then one audit event user.request_email_change is recorded with outcome failed, security true and
failure reason "Wrong password."
Scenario: An address already in use answers like success and sends no verification mail
Given another user has the email "ines@renew.example"
When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example"
Then the status, the body and every header except Date are identical to the valid request
And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference
And no mail is sent to "ines@renew.example"
And the email-change-notice is still sent to "marta@renew.example"
Scenario: A new request replaces the previous link
Given marta requested a change to "a@renew.example" and received code 1
When she requests "b@renew.example"
Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example"
Scenario: A failed send is logged and the response is unchanged
Given the mail repository fails on Send with "smtp: connection refused"
When marta sends a valid request
Then the response is 200 with the same body, the pending change is stored, and the process log names
the failed send and the error but not the raw code
And a 500 would reveal whether the address is taken, which is why the failure is not surfaced
Scenario: The sixth request in 15 minutes is rate limited
Given marta made five requests or resends in the last 15 minutes
When she sends one more
Then the response is 429 "Too many requests. Please try again later." with a Retry-After header
And no mail is sent and nothing is stored
Rule: Resending and cancelling while pending
Scenario: Resend issues a new code and invalidates the old one
Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1
When she sends POST /v1/auth/email/resend
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours
And code 1 answers 403 "invalid_email_change_code"
And no email-change-notice is sent again
And one audit event user.resend_email_change is recorded
Scenario: Resend for a taken address sends nothing
Given marta's pending address belongs to another user
When she sends POST /v1/auth/email/resend
Then the response is 200 with the same body and no mail is sent
Scenario: Resend with nothing pending
When marta sends POST /v1/auth/email/resend
Then the response is 404 "no_pending_email_change"
Scenario: Cancel clears the pending change
Given marta has a pending change
When she sends DELETE /v1/auth/email/change
Then the response is 204 and PendingEmail, code and expiration are empty
And the old link answers 403 "invalid_email_change_code"
And one audit event user.cancel_email_change is recorded
Scenario: Cancel with nothing pending is harmless
When marta sends DELETE /v1/auth/email/change
Then the response is 204 and no audit event is recorded
Rule: Confirming from the link
Scenario: A valid code changes the email and is single use
Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code
When she sends POST /v1/auth/email/confirm with that code
Then the response is 200 with {"email": "marta.alves@atelier-renew.example"}
And users.email is "marta.alves@atelier-renew.example", the pending fields are empty
And her session keeps working (token_version is unchanged)
And one audit event user.confirm_email_change is recorded
When she sends the same code again
Then the response is 403 "invalid_email_change_code"
Scenario Outline: A code that does not work answers one 403
Given <setup>
When marta confirms with <code>
Then the response is 403 "invalid_email_change_code" and users.email is unchanged
Examples:
| setup | code |
| nothing is pending | any code |
| the code expired 1 second ago | the right code |
| a pending change exists | a wrong code |
| the code was issued to another user | the right code of ines |
| the change was cancelled | the old code |
Scenario: The address was taken in the meantime
Given marta has a live code for "ines@renew.example" and the address now belongs to another user
When she confirms
Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared
And the body is the same for an address that was always taken at request time
Scenario: An empty code is refused before any lookup
When marta sends {"code": ""}
Then the response is 400 with the validator message and nothing changes
Rule: Profile, the Account card (SPA)
Background:
Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email
field and a link "Change email"; the order of the cards is Display name, Account, Your role,
Author profile, Password, Your data, Danger zone
And the session carries pending_email from GET /v1/auth/me
And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the
caller; the form reads the body text and never the status alone
And every new SPA string (the Account card states, toasts and errors, and the verification landing
states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in
English and in French under the same keys, and the English wording is the design's verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the mail templates verify-new-email and email-change-notice are English only, because the existing
mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the
subjects are fixed English strings)
And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in
the board, using the sample data of the frame (marta, marta@renew.example,
marta.alves@atelier-renew.example)
Scenario Outline: Form states
Given the user is on /profile
When <trigger>
Then the state "<state>" is visible with <copy>
And <requests>
Examples:
| state | trigger | copy | requests |
| Default | the page loads, nothing pending | the Email field and the link "Change email" | none |
| Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none |
| Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none |
| Same as current | "marta@renew.example" is submitted | "That's already your email address." | none |
| Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none |
| Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST |
| Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST |
| Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST |
| Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST |
Scenario: Pending, from a successful request
When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example"
Then the form closes and the Email field still shows "marta@renew.example"
And the card shows "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" and "Cancel change"
And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears
And reloading /profile with pending_email set shows the same Pending card without a toast
Scenario Outline: Pending actions
Given the Pending card is shown
When <trigger>
Then <outcome>
Examples:
| trigger | outcome |
| "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled |
| the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." |
| the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock |
| "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
Rule: The verification landing /account/email/confirm?c=CODE (SPA)
Background:
Given the route is a session route inside AuthCard; without a session it shows the inline login as
/register/validate does, then continues the confirmation
And the AuthCard states are those of the board group "Verify new email"
Scenario Outline: Landing states
Given the user opens /account/email/confirm<query>
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| query | trigger | state | copy |
| ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" |
| ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | 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."; the session email is replaced |
| ?c=abc123 | the POST answers 403 | 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" |
| ?c=abc123 | the POST answers 409 "email_unavailable" | 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" |
| ?c=abc123 | no session (401 from /auth/me) | 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" |
| | no c parameter | 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"; no request is sent |
| ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again |
Scenario: Log in and confirm
Given the "No session (401)" state is shown
When the user logs in inline with valid credentials
Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows
And the code is not written to the URL history or logs by the SPA
Scenario: A confirmation is not repeated by reload noise
Given the Success state was reached
When the user reloads the page
Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use
Rule: Mail templates
Scenario Outline: Each template renders from its data
When the mailer renders "<template>" with <data>
Then the HTML contains <contains> and no unreplaced {{ }} action
And the subject is "<subject>"
Examples:
| template | data | contains | subject |
| verify-new-email | Host https://cms.example.com, Username marta, VerificationCode abc | the link https://cms.example.com/account/email/confirm?c=abc | Confirm your new RenewCMS email address |
| email-change-notice | Host https://cms.example.com, Username marta, NewEmail new@renew.example | new@renew.example and the link https://cms.example.com/profile | Your RenewCMS email address change was requested |
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents the four endpoints, their bodies, statuses and the pending_email field of /auth/me
And docs/privacy.md says users can change their email themselves (art. 16), lists the three new users
columns in the stored-data table, and removes "Changing an email address currently needs a database edit"
and the email half of "No self-service export or email change" from Gaps
+110
View File
@@ -0,0 +1,110 @@
# QA procedure: account_change_email
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and `test/mail_test.go` (`mockMailRepo`, `recordedMail`, `failSwitch`). Reuse the existing recording audit fake. Fake type names must be unique across the package.
## Test infrastructure additions needed first
1. `mockUserRepo`: add the new repository methods for setting, clearing and confirming a pending change.
- Each honours `fail.trigger` and returns `user.ErrUserNotFound` for an unknown id.
- Confirm checks "email not held by another user" and returns `user.ErrEmailUnavailable`.
- Add call counters, in the style of `clearVerificationCodeCalls`.
2. Seed fixtures with `stubHasher` hashes: `EmailChangeCode: "hashed:abc123"` stands for the raw code `abc123`. The password "limewash2026" is seeded as `"hashed:limewash2026"`.
3. A reusable helper builds the real `RequestEmailChangeUseCase`, `ResendEmailChangeUseCase`, `CancelEmailChangeUseCase` and `ConfirmEmailChangeUseCase` over `mockUserRepo`, `stubHasher`, `mockMailRepo`, the audit fake and `publicURL "https://cms.example.com"`.
## Setup shared by every case
4. Call `validator.InitValidator()` once.
5. Handler checks build the handler from the use cases via its `NewHandler`/named constructor. Mount the four routes on a fresh `chi.NewRouter()` that mirrors the `NewAuthRouter` session group: `middleware.RequireSession`, `authtoken.Verifier`, `AuditContext`, `RequireSignedIn`, `authtoken.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`. Add the shared `middleware.NewUserRateLimiter(5, 15*time.Minute)` in front of change and resend only.
6. Use a test `authtoken` instance and mint jwt cookies carrying `user_id` and `token_version`. Seed user 7 "marta" (`IsVerified: true`).
7. No live server, real SQLite file or SMTP.
## Use-case checks
8. Request, valid. Assert:
- nil error.
- `PendingEmail` set, and `users.email` unchanged.
- Stored code equals `"hashed:"+raw`, and the expiration is within 5 s of now+2h.
- `SentEmails` has exactly two entries.
- Entry 1 is `verify-new-email` to the new address; its data (type-assert `useCases.MailVerifyNewEmailData`) carries Host, Username and the raw code.
- Entry 2 is `email-change-notice` to the old address, with NewEmail in its data.
- One audit event `user.request_email_change`, success, `Security` true, with no `@` in its Params or Changes.
9. Request errors are table-driven, each asserted with `errors.Is`:
- Wrong password gives `ErrWrongPassword`, with no mail, nothing stored and one failed security event.
- Same address in different case gives `ErrSameEmail`.
10. Taken address, matched case-insensitively. Assert the same return value and the same stored `PendingEmail` as case 8, only the notice mail sent (none to the other user), and the same audit event shape.
11. Mail failure via `mailRepo.fail.failNext("Send")`. Capture the log (`log.SetOutput(&buf)` with a cleanup). Assert nil error, the pending change stored, a log line naming the failure, and neither the raw code nor its hash in the log.
12. Resend. Assert:
- A new code and expiry, and the old raw code now fails `stubHasher.Compare`.
- One `verify-new-email` mail and no notice.
- `ErrNoPendingEmailChange` when nothing is pending.
- Nothing sent for a taken address.
13. Cancel. Assert fields cleared, nil error when nothing is pending, and an audit event only when something was cancelled.
14. Confirm, success. Assert:
- `users.email` is the new address, the pending fields are cleared, and `TokenVersion` is unchanged.
- A second confirm returns `ErrInvalidEmailChangeCode`.
- The `user.confirm_email_change` event is recorded.
15. Confirm, failures. Assert:
- Expired, wrong, other user's, cancelled and none pending all return `ErrInvalidEmailChangeCode`.
- Taken returns `ErrEmailUnavailable` and clears the pending change.
## HTTP checks
16. Request, valid and refused. Using the examples table of the feature, assert the status and exact body and that `SentEmails` is empty on refusals. The text for the 400 cases is the validator message; assert it equals `validator.Validate.Struct` output.
17. Taken-address response parity. Compare status, body and headers except Date between case 8 and case 10 using `reflect.DeepEqual` on the header map minus Date.
18. Auth gates, for each of the four routes:
- No cookie gives 401 "You must be logged in to access this resource.".
- A bearer API key gives 403 "This action needs a real session.".
- An unverified user gives 403 "You must verify your account to access this resource.".
19. Rate limit. Send 5 valid-or-wrong-password requests mixed with resends, then assert the sixth is 429 with a numeric `Retry-After` and the body "Too many requests. Please try again later.". Assert DELETE and confirm still answer.
20. Resend, cancel and confirm statuses and bodies exactly as in the feature (200 JSON, 404 `no_pending_email_change`, 204 empty, 403 `invalid_email_change_code`, 409 `email_unavailable`).
21. `GET /auth/me` returns `pending_email` as null, then as the address after a request.
## Mailer checks (no SMTP)
22. Render `verify-new-email` and `email-change-notice` with `MailRepository.Render` and fake data.
- Assert the link strings, the subjects, and that no `{{` remains.
- Assert the HTML escapes a username such as `<b>`.
## Vitest (SPA), `web/src/**/__tests__/`
Mock `/v1` with the response in the second column. One spec per state, named with the design state name. Strings live in `messages.ts` in English and French under the same keys.
I18n checks (extend the existing `web/src/i18n/*.spec.ts` style, e.g. `spa.spec.ts`):
- Key parity: every new key exists in both `en` and `fr`, with no empty value and the same interpolation placeholders (new address, old address, countdown).
- French render: for each state with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the "Change email" link, the form labels and hint, the four field errors, "Sending…", "Waiting for verification" and its text, "Resend link", "Cancel change", "Resend in {time}", the three toast pairs (sent, resent, cancelled), the 429 and server notices, and all seven landing states (title, text, button).
| State | Mocked /v1 |
|---|---|
| Default, Form open | `GET /auth/me` with pending_email null |
| Invalid format, Same as current, Password missing | none (no request asserted) |
| Wrong password (403) | `POST /auth/email/change` 403 "wrong_password" |
| Sending | POST pending forever |
| Pending, then reload | POST 200; `GET /auth/me` with pending_email |
| Pending: resending / resent / resend rate limited | `POST /auth/email/resend` unresolved / 200 / 429 with Retry-After 270 |
| Cancelled | `DELETE /auth/email/change` 204 |
| Rate limited (429), Server or network error | POST 429; POST 500 and a rejected fetch |
| Landing: all seven states | `POST /auth/email/confirm` per the feature table; the 401 state uses `GET /auth/me` 401 and then an inline login |
Also assert:
- The 403 `wrong_password` is not treated as a global unverified redirect.
- Typed values are kept in the 429 and 500 states.
- The code is read from `?c=` only.
## Visual
Run `task visual:check -- account` and `task visual:check -- auth`. Every capture must be within 10 % at desktop and mobile, light and dark. No `exception` without the user's `approvedBy`.
- Board data to reuse in `web/visual/states.ts` setups: username marta, old email marta@renew.example, new email marta.alves@atelier-renew.example, resend countdown 4:30 (Retry-After 270).
- States: the 14 "Change email" states and the 7 "Verify new email" states of the Auth board.
- Toast states (Pending, Pending: resent, Cancelled) capture the toast.
- None of these is clock-sensitive.
## Manual verification
- Real SMTP delivery and rendering of both mails in a real client and in dark mode.
- Opening the link from a different browser to see the inline login, then the confirmation.
- Keyboard and focus order: the email field on open, password focus after 403, focus return after Cancel.
- Hover and motion of the toasts.
- Real embedded frontend: open `/account/email/confirm?c=...` on the built binary.
+235
View File
@@ -0,0 +1,235 @@
Feature: Delete my own account and my personal data
As a verified RenewCMS user
I want to delete my account with my password and a typed confirmation
So that I can exercise my right to erasure (GDPR art. 17) while the content I wrote stays on the site
without my name, and the workspace is never left without an Owner or an administrator
Background:
Given the design of record is docs/design/screens/account.md "Delete account", the board
"RenewCMS Screens Account" group "Delete account" (10 states) and the board "RenewCMS Screens Auth"
group "Account deleted" (1 state)
And RemoveUserUseCase and DELETE /v1/user/{id} are not reused, because they refuse the caller as the
target (role.ErrOwnRole), require users.remove and a covering role, never remove a Locked role (the
Owner), take no password and only delete the user row; a new DeleteOwnAccountUseCase is added and
RemoveUserUseCase is not changed
And DELETE /v1/user/me is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, with a per-user limiter of
5 attempts per 15 minutes (429 "Too many requests. Please try again later." with Retry-After); an API
key gets 403 "This action needs a real session."
And the body is {"current_password": "...", "confirmation": "DELETE"} validated with the tags
current_password "required" and confirmation "required,eq=DELETE" (exact, case-sensitive) through
validator.Validate.Struct; the board sends the password only, so confirmation is an addition
And password checking uses the domain hash.Hasher
And the check order is: body validation 400, wrong password 403 "wrong_password", last Owner or
administrator 409 "last_admin", then one atomic removal
And "last administrator" means: the user holds the built-in Owner role and no other verified user does
(Owner variant), or the user holds the built-in Admin role and no other verified user holds Owner or
Admin (Admin variant); other roles are never blocked; the 409 carries the header X-Last-Role with
"Owner" or "Admin"
And one atomic persistence operation (owned by persistence; the use case sees only a domain interface)
does, or does none of:
| effect | result |
| user | the users row is deleted; every jwt of the user stops working because RequireCurrentToken cannot find it |
| API keys | all the user's api_keys rows are deleted |
| author | the linked author is deleted if no article credits it; otherwise it is scrubbed in place: name "Deleted user", slug "deleted-user-<authorId>", empty bio, no avatar, website or social links, user_id NULL |
| content | articles (created_by), entries (created_by, updated_by) and images (uploaded_by_id) keep the old id and are not touched |
| audit events | the user's past events lose their name (actor and target names become "Deleted user") and their ip and user_agent become null; the events stay |
| re-check | the role holders are counted again inside the same operation; a change since the check answers 409 "account_not_deletable" and nothing is removed |
And after the commit the use case revokes the current session id, then sends the template
"account-deleted" once to the address the account had (data: Host only), and the handler clears the
jwt cookie (Max-Age 0, same attributes as logout)
And a failed send is logged and does not undo the deletion, and the response is still 204
And the audit event user.delete_account is recorded after the removal: actor and target id of the
deleted user, both named "Deleted user", category users, security true, success; its params hold no email
And a failed attempt records user.delete_account with outcome failed, security true and the reason
"Wrong password." or "Last Owner." / "Last administrator." on the user's own events
And the success response is 204 with no body and an expired jwt cookie
And the layering rule holds and no comment is added anywhere
Rule: The endpoint
Background:
Given the users "marta" (id 7, Writer), "jonas" (id 2, Admin) and "ines" (id 3, Owner)
Scenario: A Writer deletes their account
Given marta has the API keys 1 and 2, an author profile credited on 3 articles, 2 entries, 4 uploaded
images and 12 audit events
And her email was "marta@renew.example"
When marta sends DELETE /v1/user/me with {"current_password": "limewash2026", "confirmation": "DELETE"}
Then the response is 204 with no body and a Set-Cookie that expires jwt
And user 7 no longer exists, API keys 1 and 2 no longer exist
And her author row remains with name "Deleted user", slug "deleted-user-<id>", user_id null and no bio
And the 3 articles, 2 entries and 4 images are unchanged
And her 12 events show "Deleted user" and have a null ip and user_agent
And her old jwt on GET /v1/auth/me answers 401
And one mail "account-deleted" with Host only is sent to "marta@renew.example"
And one audit event user.delete_account is recorded
Scenario: An author profile that credits no article is deleted
Given marta's author profile credits nothing
When she deletes her account
Then the author row is gone
Scenario Outline: Refused requests change nothing
When marta sends DELETE /v1/user/me with <body>
Then the response is <status> with the body "<text>"
And user 7, her API keys, her author and her events are unchanged, and no mail is sent
Examples:
| body | status | text |
| {"current_password": "limewash2026", "confirmation": "delete"} | 400 | the validator message for confirmation |
| {"current_password": "limewash2026", "confirmation": ""} | 400 | the validator message for confirmation |
| {"current_password": "", "confirmation": "DELETE"} | 400 | the validator message for current_password |
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
| {"current_password": "nope", "confirmation": "DELETE"} | 403 | wrong_password |
Scenario Outline: The last Owner or administrator is blocked
Given the caller holds <role> and <others>
When the caller sends a valid DELETE /v1/user/me
Then the response is <result>
Examples:
| role | others | result |
| Owner | no other Owner, one Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | no other Owner, no Admin | 409 "last_admin" with X-Last-Role: Owner |
| Owner | another verified Owner | 204 |
| Admin | no Owner and no other Admin | 409 "last_admin" with X-Last-Role: Admin |
| Admin | one Owner | 204 |
| Admin | another verified Admin and no Owner | 204 |
| Admin | only an unverified Admin and no Owner | 409 "last_admin" with X-Last-Role: Admin |
| Writer | no Owner and no Admin | 204 |
| a custom role with every admin permission | no Owner and no Admin | 204 |
Scenario: A blocked attempt leaves everything and records a failed event
Given the caller is the last Owner
When the caller sends a valid DELETE /v1/user/me
Then the user, keys, author and events are unchanged and no mail is sent
And one audit event user.delete_account is recorded with outcome failed, security true and the reason "Last Owner."
Scenario: A concurrent role change stops the removal
Given the check passed and another user's role changed before the atomic removal
When the removal runs
Then the response is 409 "account_not_deletable" and nothing was removed
Scenario: A mail failure does not undo the deletion
Given the mail repository fails on Send
When marta deletes her account
Then the response is 204, the user is gone and the process log names the failed send
Scenario: A failure in the removal rolls everything back
Given deleting the API keys fails inside the operation
When marta deletes her account
Then the response is 500 "Internal server error" and user 7, her author and her events are unchanged, and no mail is sent
Scenario: Rate limited after five attempts
Given marta made five attempts in the last 15 minutes
When she sends one more
Then the response is 429 with a Retry-After header, and nothing is checked or changed
Scenario Outline: Only a real verified session may delete
When <caller> sends DELETE /v1/user/me
Then the response is <status>
Examples:
| caller | status |
| nobody | 401 |
| an API key bearer | 403 |
| an unverified user | 403 |
Scenario: DELETE /v1/user/{id} is unchanged
Given the scenarios of the users_row_actions feature
Then all still pass, and DELETE /v1/user/me is not routed to the {id} handler
Rule: Mail template
Scenario: account-deleted renders without personal data
When the mailer renders "account-deleted" with Host https://cms.example.com
Then the subject is "Your RenewCMS account has been deleted"
And the HTML has no button, no username and no address, and no unreplaced {{ }} action
Rule: Danger zone and the delete dialog (SPA)
Background:
Given the Danger zone card is the last card of /profile with the title "Danger zone", the text "Delete
your account and your personal data. This can't be undone." and the button "Delete my account"
And a dialog shows the design's content: "Delete your account?", "This permanently deletes your
account. It can't be undone.", the list "Your username, display name and email address", "Your author
profile and API keys", "Your sessions: you'll be logged out everywhere", the section "Your articles
and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from
them.", the link "Export my data first", the fields "Current password" and "Type DELETE to
confirm", and the actions "Keep my account" and "Delete my account"
And every new SPA string (the Danger zone card, the dialog and its blocked variants, errors, and the
Login notice "Your account has been deleted" with its text) is added to the i18n catalogue
(messages.ts, features/spa_internationalization.feature) in English and in French under the same
keys, with the design's English wording verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the account-deleted mail is English only, because the existing mailer does not localise
(templates hard-code lang="en" and the subjects are fixed English strings)
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
Scenario Outline: Dialog states
Given the user opened the dialog on /profile
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| state | trigger | copy |
| Danger zone | the page loads | the card only; no request |
| Confirm: empty | the dialog opens | "Delete my account" disabled |
| Confirm: typing | a password and "DEL" are entered | still disabled |
| Confirm: ready | a password and exactly "DELETE" are entered | enabled; "delete", "Delete" and "DELETE " (trailing space) keep it disabled |
| Wrong password (403) | DELETE answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused, the word kept |
| Deleting | DELETE unresolved | "Deleting…", fields and "Keep my account" disabled, no close |
| Rate limited (429) | DELETE answers 429 | "Too many requests. Try again in a few minutes.", Confirm stays enabled |
| Server error | DELETE answers 500 or fails | "Couldn't delete your account. Nothing was deleted. Try again in a moment.", values kept |
| Blocked: last administrator | DELETE answers 409 "last_admin" with X-Last-Role Admin | "You can't delete your account yet", "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: last Owner (Vitest only, no board frame) | the same with X-Last-Role Owner | "You can't delete your account yet", "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
| Blocked: cannot be deleted | DELETE answers 409 "account_not_deletable" | "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" (no "Go to Users") |
Scenario Outline: Blocked dialogs cannot be dismissed
Given a "Blocked" state is shown
When the user <action>
Then the dialog stays open and shows no close button
Examples:
| action |
| presses Escape |
| clicks the backdrop |
Scenario: A blocked dialog always offers a next step
Then "Back to profile" closes the dialog, and "Go to Users" navigates to /users
Scenario: Export my data first
Given the dialog is open
When the user activates "Export my data first"
Then the download of slice account_export_data starts without closing the dialog
Scenario: Success signs out and redirects with the one-time message
Given the user confirmed with a valid password and "DELETE"
When DELETE /v1/user/me answers 204
Then the session store is cleared without calling POST /v1/auth/logout and the router goes to /login?reason=account-deleted
And the Login screen shows a success notice (icon circle-check) "Your account has been deleted" /
"Your articles and media were kept without your name."
And the query is removed from the URL once consumed, so a reload shows the plain Login with no notice
And the leave-page confirmation of Profile does not open
Scenario: The one-time message needs the exact reason
When a visitor opens /login?reason=anything-else
Then no account-deleted notice is shown
Scenario: Deleted creators are named "Deleted user" in the SPA
Given an entry whose created_by is 7 and GET /v1/user/lookup does not return user 7
Then the screen that names its creator shows "Deleted user"
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents DELETE /v1/user/me: body, 204, 400, 403 wrong_password, 409 last_admin with
X-Last-Role, 409 account_not_deletable, 429
And docs/privacy.md:
- replaces the Erasure bullet and the "No way to delete a verified user" Gap with the self-service flow;
- states what is kept (articles, entries, media, anonymised author and activity events) and what is removed;
- adds the account-deleted mail to "What leaves the server";
- leaves the Gaps entry about soft-deleted rows and the failed-login username entry intact.
+101
View File
@@ -0,0 +1,101 @@
# QA procedure: account_delete
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`), `test/mail_test.go` (`mockMailRepo`, `failSwitch`) and the existing role, author, api-key and audit fakes. Reuse them and keep new type names unique.
## Test infrastructure additions needed first
1. A fake of the atomic domain operation behind `DeleteOwnAccountUseCase` that mutates the in-memory fakes together.
- It honours `fail.trigger` at each step ("DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser").
- On any failure it restores a snapshot, so rollback is observable.
2. The role fake: seed built-in Owner, Admin, Writer and a custom role with all admin permissions. Add a `CountHolders`-style method (or whichever method the coder adds to the domain interface) returning verified holders only.
3. The audit fake: record events and support the anonymise method; assert the rewritten events by id.
4. The session revocation: reuse the `authtoken` revocation fake; record revoked ids.
## Setup shared by every case
5. Call `validator.InitValidator()` once.
6. Build `useCases.NewDeleteOwnAccountUseCase(...)` over the fakes, `stubHasher`, `mockMailRepo`, a clock, `publicURL "https://cms.example.com"` and the audit fake.
7. Handler checks mount `DELETE /v1/user/me` on a fresh `chi.NewRouter()` with the `NewUserRouter` session chain and the 5 per 15 minutes limiter. Also mount `DELETE /{id}` behind its permission middleware, to prove the routing order. Mint jwt cookies with `user_id` and `token_version`.
8. Seed marta (id 7, Writer, "hashed:limewash2026"), jonas (2, Admin), ines (3, Owner). No live server, real SMTP or real SQLite.
## Use-case checks
9. Happy path for a Writer. Assert:
- The user is gone, keys 1 and 2 are gone.
- The author row is scrubbed (name, slug `deleted-user-<id>`, bio "", avatar/website nil, links empty, user_id nil) when it credits 3 articles.
- The author is deleted when it credits none.
- Articles, entries and images are byte-for-byte unchanged (`reflect.DeepEqual` before and after).
- Her 12 events are anonymised and the other users' events are untouched.
- The session id is revoked.
- Exactly one `account-deleted` mail goes to the old address, with data `useCases.MailAccountDeletedData{Host}` and no other field.
- Exactly one `user.delete_account` event, with actor and target name "Deleted user" and no `@` in the params.
10. Password. A wrong password returns `user.ErrWrongPassword` (`errors.Is`), changes nothing, sends nothing and records one failed security event.
11. Last-role matrix. Table-driven from the feature (9 rows):
- Owner/Admin blocked rows return `user.ErrLastAdmin` carrying the role label "Owner" or "Admin" (assert with `errors.As` on whatever the coder exports).
- Unverified holders do not count.
- A custom all-permission role does not count and is not blocked.
- Allowed rows delete.
12. Concurrent role change. The fake changes a holder between the check and the operation. Assert `ErrAccountNotDeletable` and an untouched state.
13. Rollback. For each step failure in case 1, assert that all fakes equal their pre-state, no mail is sent, no revocation is recorded and the error is returned.
14. Mail failure with the log captured. Assert nil error, the user gone, and a log line naming the failed send.
15. Order of checks. A wrong password plus a last-Owner caller returns `ErrWrongPassword`, not last_admin (no role probing without the password).
## HTTP checks
16. Success. Assert 204, an empty body, and a `Set-Cookie` for `jwt` with `Max-Age=0` or an expired date. A follow-up `GET` with the old cookie through the real middleware chain gives 401.
17. Refusals table of the feature: status and exact body for each row. For the 400 rows, compare to the `validator.Validate.Struct` message. Assert the fakes are unchanged.
18. Last role: 409, body `last_admin`, header `X-Last-Role` equal to `Owner` or `Admin`.
19. `account_not_deletable` gives 409 with that body.
20. Rate limit. Make 5 attempts (mix wrong-password and blocked), then assert the sixth is 429 with `Retry-After` and that no state changed.
21. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
22. Routing. `DELETE /v1/user/me` reaches the new handler, never `removeHandler`. `DELETE /v1/user/7?expected_role_id=3` still behaves as `features/users_row_actions.qa.md` says.
23. Mail rendering. Render `account-deleted` with the real `MailRepository.Render`. Assert the subject, no `@`, no username, no button, no leftover `{{`.
## Persistence (real in-memory SQLite, same models as `api/dependecyInjection.go`)
24. One test runs the real atomic operation. Assert the user, the keys, the scrub-or-delete of the author, the audit anonymisation, a forced mid-way failure leaving everything intact, and the portability of the SQL (no driver-specific syntax). If a real-DB test already exists for MySQL/PostgreSQL (`task test:mysql`/`test:postgres`), run the same test there.
## Vitest (SPA)
Strings live in `messages.ts` in English and French under the same keys.
I18n checks (extend the existing `web/src/i18n/*.spec.ts` style, e.g. `spa.spec.ts`):
- Key parity: every new key exists in both `en` and `fr`, with no empty value and the same placeholders.
- French render: for the Danger zone, the dialog, each Blocked variant and the Login notice with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
- The confirmation word stays the literal "DELETE" in both languages, and the French dialog label tells the user to type it.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the Danger zone title, text and button; the dialog title, text, the three list items, the "Your articles and media stay" heading and text, "Export my data first", the two field labels, "Keep my account", "Delete my account", "Deleting…"; the wrong-password, 429 and server errors; the three Blocked variants (Owner, administrator, cannot be deleted) with their buttons; and the Login notice title and text.
| State | Mocked /v1 |
|---|---|
| Danger zone, Confirm: empty/typing/ready | none |
| Wrong password (403) | `DELETE /user/me` 403 "wrong_password" |
| Deleting | DELETE pending |
| Rate limited (429), Server error | 429; 500 and a rejected fetch |
| Blocked: last administrator | 409 "last_admin" + `X-Last-Role: Admin` |
| Blocked: last Owner | 409 "last_admin" + `X-Last-Role: Owner` |
| Blocked: cannot be deleted | 409 "account_not_deletable" |
| Success | 204; assert no `POST /auth/logout`, `session.clear()`, router at `/login?reason=account-deleted` |
Also assert:
- Confirm enablement for "DELETE", "delete", "DELETE ", "".
- A blocked dialog ignores Escape and the backdrop and has no close button.
- The Login notice appears for `reason=account-deleted` only, and the query is gone after it is consumed.
- Typed values are kept on 429 and 500.
- The "Deleted user" fallback where a creator is missing from the lookup.
## Visual
`task visual:check -- account` and `-- auth`.
- Board data to reuse: password mask `••••••••••`, the typed word "DEL" in Confirm: typing and "DELETE" elsewhere, and the Auth board's "Account deleted" notice.
- States: all 10 delete states of the Account board and "Account deleted" on the Auth board. The Owner variant has no frame.
- None is clock-sensitive.
## Manual verification
- Real SMTP: the account-deleted mail arrives once at the old address, and the link-free rendering in a real client.
- Delete a real account in a browser against a real database file: log back in as another user and check an article byline reads "Deleted user", and Activity shows "Deleted user".
- On the embedded frontend, check that after deletion `/login?reason=account-deleted` shows the notice once.
- Keyboard and focus: the dialog focus trap, the focus on the cleared password after 403, Tab order in the blocked dialog.
- MySQL and PostgreSQL via `task test:mysql` / `task test:postgres`.
+135
View File
@@ -0,0 +1,135 @@
Feature: Download a copy of my personal data as a ZIP of JSON files
As a verified RenewCMS user
I want one click on Profile to download the personal data RenewCMS holds about me
So that I can exercise my right of access and portability (GDPR art. 15 and 20) without asking an admin
Background:
Given the design of record is docs/design/screens/account.md "Your data", direct flow only, and the
board "RenewCMS Screens Account" group "Your data"
And the emailed-link variant, export-ready.html and /account/export/ are out of scope
And GET /v1/user/me/export is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, so an API key gets
403 "This action needs a real session." and no cookie gets 401
And the route allows one successful export per user per 15 minutes; a second attempt answers 429
"Too many requests. Please try again later." with a Retry-After header in seconds; an export that
fails with a 500 does not consume the slot
And the success response is 200, Content-Type application/zip, Cache-Control no-store and
Content-Disposition attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip", with the
username reduced to [a-z0-9-] and the date in UTC from the use case's clock
And the ZIP holds exactly four UTF-8 JSON files, snake_case fields, pretty-printed:
| file | content |
| profile.json | id, username, display_name, email, pending_email, role {id, name}, is_verified, created_at, updated_at, author (the 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}] and entries [{id, content_type, data, created_at, updated_at}] created by the user or credited to the user's author |
| uploaded-media.json | images [{id, filename, mime, size, hash, uploaded_at}] uploaded by the user: references only, no bytes |
| account-activity.json | events recorded with the user as actor: id, created_at, action, category, target, outcome, security, source, ip, user_agent, params |
And the ZIP never contains the password hash, any verification, reset or email-change code, the token
version, an API-key hash, or other people's data
And soft-deleted articles and images are excluded
And a use case (ExportUserData) reads through domain repositories (user, role, author, apikey, article,
entry, image, audit) and writes the archive with the stdlib; the handler streams the result; the
archive is built in memory first so a failure never sends a half file
And one audit event user.export_data is recorded on success (actor and target the user, category users)
And the layering rule holds and no comment is added anywhere
Rule: The endpoint
Background:
Given the user "marta" (id 7, verified) with 2 articles, 1 entry, 3 uploaded images and 5 audit events as actor
Scenario: A first export returns the archive
When marta sends GET /v1/user/me/export on 2026-10-29
Then the response is 200 with Content-Disposition attachment; filename="renewcms-export-marta-2026-10-29.zip"
And the ZIP has exactly the four files above
And profile.json has username "marta", email, role name and no key named password, code or token_version
And authored-content.json has 2 articles and 1 entry
And uploaded-media.json has 3 references and no file bytes
And account-activity.json has the 5 events plus nothing recorded for anonymous failed logins
And one audit event user.export_data is recorded
Scenario: A second export within 15 minutes is refused
Given marta exported 3 minutes ago
When she sends GET /v1/user/me/export
Then the response is 429 "Too many requests. Please try again later." with a numeric Retry-After of about 720
And no archive is built and no audit event is recorded
Scenario: The slot is per user and expires
Given marta exported 16 minutes ago, and ines exported 1 minute ago
Then marta's request succeeds and ines's request answers 429
Scenario: A failed export does not consume the slot
Given a repository fails while marta's data is being read
When she sends GET /v1/user/me/export
Then the response is 500 with a plain text body, no partial archive is sent and no audit event is recorded
When the failure is gone and she sends the request again
Then the response is 200
Scenario Outline: Only a real verified session may export
When <caller> sends GET /v1/user/me/export
Then the response is <status> with the body "<text>"
Examples:
| caller | status | text |
| nobody (no cookie) | 401 | You must be logged in to access this resource. |
| an API key bearer | 403 | This action needs a real session. |
| an unverified user | 403 | You must verify your account to access this resource. |
Scenario: Someone else's data is never included
Given ines has articles, entries, images and events
When marta exports
Then no record of ines appears in any file, including events where ines is the target of marta's action
Scenario: A user with a linked author profile
Given marta has an author profile credited on article 4 which marta did not create
When she exports
Then profile.json has the author profile and authored-content.json includes article 4
Rule: Your data card (SPA)
Background:
Given the Your data card follows the Account and Password cards, with the title "Your data", the text
"Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP." and the
four content rows Profile, Authored content, Uploaded media, Account activity with the design's
one-line descriptions
And the download is a fetch of GET /v1/user/me/export read as a blob and saved with the filename
from Content-Disposition
And every new SPA string of the card (title, text, content rows, button, states, toasts and errors) is
added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in English and
in French under the same keys, with the design's English wording verbatim
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
flags it for the user's review
And the ZIP file names and JSON keys are not translated
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
Scenario Outline: States
Given the user is on /profile
When <trigger>
Then the state "<state>" shows <copy>
Examples:
| state | trigger | copy |
| Direct (recommended): default | the page loads | "Download my data", "Ready in a few seconds." |
| Direct: preparing | "Download my data" is activated, GET unresolved | "Preparing download…", button disabled with a spinner |
| Direct: started | the GET answers 200 with the attachment | the browser download starts, then a toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads."; the card returns to the default |
| Direct: rate limited (429) | the GET answers 429 with Retry-After | "You can export once every 15 minutes", "Your last export was at 09:41. Try again in 12 minutes.", "Available in 12:00" |
| Direct: error | the GET answers 500 or fails | "Couldn't prepare your data", "Nothing was changed. Try again in a moment.", button enabled to retry |
Scenario: The rate limited state is clock-sensitive and is skipped
Then web/visual/states.ts marks "Direct: rate limited (429)" with skip 'clock-sensitive', no visual
check exists for it, and no Vitest spec freezes the system date; the Vitest spec asserts only the
copy driven by a mocked Retry-After header
Scenario: The async states are out of scope and carry exception entries
Then the board states "Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress"
and "Alternative: error" are not built in the SPA
And web/visual/states.ts lists each of them with an exception entry so `task visual:check -- account`
does not fail on a missing setup
And each exception needs the user's explicit approval recorded as approvedBy
Rule: Documentation and privacy
Scenario: The docs describe the feature
Then docs/api.md documents GET /v1/user/me/export with the headers, the four files and the 429
And docs/privacy.md replaces "there is no self-service export yet" with the Profile download, lists
the four files, notes that anonymous failed-login events and soft-deleted rows are not included,
and removes the export half of the "No self-service export or email change" gap
Loaded 100 of 882 files, more files were not shown because too many files have changed in this diff. Show more