Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efecdee200 | ||
|
|
34dffe222f | ||
|
|
3db490a615 | ||
|
|
907949aa12 | ||
|
|
80f7037d3a | ||
|
|
094df63a7f | ||
|
|
a043f7f41a | ||
|
|
de142f6c5f | ||
|
|
8c6c6456da | ||
|
|
44cca46d5e | ||
|
|
7c0ee21906 | ||
|
|
25d9c30b8a | ||
|
|
a8fea8d900 | ||
|
|
a0fdf03be0 | ||
|
|
9ab98fd50c | ||
|
|
d2af39da61 | ||
|
|
4cff43fefb |
No files matched your search
@@ -29,3 +29,7 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
codex-workflow.md
|
||||
CODING_STANDARDS.md
|
||||
|
||||
# Generated LLM docs
|
||||
internal/infrastructure/llmdocs/generated/*
|
||||
!internal/infrastructure/llmdocs/generated/.gitkeep
|
||||
@@ -4,6 +4,10 @@ COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
RUN bun run build-only
|
||||
WORKDIR /src
|
||||
COPY docs/ ./docs/
|
||||
COPY scripts/generate-llms.ts ./scripts/generate-llms.ts
|
||||
RUN bun scripts/generate-llms.ts
|
||||
|
||||
FROM golang:1.27.3-alpine AS server
|
||||
ARG VERSION=dev
|
||||
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
|
||||
COPY --from=web /src/internal/infrastructure/llmdocs/generated ./internal/infrastructure/llmdocs/generated
|
||||
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@ version: '3'
|
||||
vars:
|
||||
BINARY: renewcms
|
||||
DIST_DIR: internal/infrastructure/assets/dist
|
||||
LLMS_DIR: internal/infrastructure/llmdocs/generated
|
||||
VERSION:
|
||||
sh: git describe --tags --always --dirty
|
||||
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
||||
@@ -32,6 +33,7 @@ tasks:
|
||||
|
||||
dev:api:
|
||||
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
||||
deps: [build:llms]
|
||||
cmds:
|
||||
- air
|
||||
|
||||
@@ -236,17 +238,31 @@ tasks:
|
||||
cmds:
|
||||
- bun run build
|
||||
|
||||
build:llms:
|
||||
desc: Generate llms.txt, llms-full.txt and openapi.json from the route catalogue and docs/ → {{.LLMS_DIR}}
|
||||
deps: [web:install]
|
||||
sources: [scripts/generate-llms.ts, web/src/data/apiCatalogue.ts, docs/api.md, docs/api/*.md, docs/llm/*.md, docs/configuration.md]
|
||||
generates: ['{{.LLMS_DIR}}/llms-full.txt']
|
||||
cmds:
|
||||
- bun scripts/generate-llms.ts
|
||||
|
||||
build:
|
||||
desc: Production binary for this machine → bin/{{.BINARY}}
|
||||
deps: [build:web]
|
||||
deps: [build:web, build:llms]
|
||||
cmds:
|
||||
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
|
||||
|
||||
build:mcp:
|
||||
desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp
|
||||
cmds:
|
||||
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp
|
||||
|
||||
release:
|
||||
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
||||
cmds:
|
||||
- task: release:check
|
||||
- task: build:web
|
||||
- task: build:llms
|
||||
- for: { var: RELEASE_TARGETS }
|
||||
task: build:target
|
||||
vars:
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainRole "RenewCMS/internal/domain/role"
|
||||
domainSession "RenewCMS/internal/domain/session"
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
domainTransaction "RenewCMS/internal/domain/transaction"
|
||||
@@ -62,6 +63,9 @@ type UseCases struct {
|
||||
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
|
||||
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
|
||||
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
|
||||
GetMyAccessUseCase *useCases.GetMyAccessUseCase
|
||||
CheckPermissionUseCase *useCases.CheckPermissionUseCase
|
||||
ListPermissionsUseCase *useCases.ListPermissionsUseCase
|
||||
ListSitesUseCase *useCases.ListSitesUseCase
|
||||
CreateSiteUseCase *useCases.CreateSiteUseCase
|
||||
UpdateSiteUseCase *useCases.UpdateSiteUseCase
|
||||
@@ -72,6 +76,12 @@ type UseCases struct {
|
||||
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
||||
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
||||
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
||||
ListRolesUseCase *useCases.ListRolesUseCase
|
||||
GetRoleUseCase *useCases.GetRoleUseCase
|
||||
CreateRoleUseCase *useCases.CreateRoleUseCase
|
||||
UpdateRoleUseCase *useCases.UpdateRoleUseCase
|
||||
DeleteRoleUseCase *useCases.DeleteRoleUseCase
|
||||
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
|
||||
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
||||
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
||||
@@ -93,6 +103,8 @@ type UseCases struct {
|
||||
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
SendMailUseCase *useCases.SendMailUseCase
|
||||
@@ -161,6 +173,7 @@ type Repositories struct {
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.Repository
|
||||
UserRepo domainUser.Repository
|
||||
RoleRepo domainRole.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
Transactor domainTransaction.Transactor
|
||||
VariantRepo domainImage.VariantRepository
|
||||
@@ -305,6 +318,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
RoleRepo: persistence.NewRoleRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
Transactor: persistence.NewTransactor(db),
|
||||
VariantRepo: persistence.NewVariantRepository(db),
|
||||
@@ -366,16 +380,25 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
||||
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo, repos.UserRepo),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
|
||||
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
|
||||
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
|
||||
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
||||
@@ -385,7 +408,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
|
||||
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||
@@ -393,12 +416,14 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
|
||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
|
||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
|
||||
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
|
||||
@@ -419,7 +444,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -134,9 +135,8 @@ func writeActivityError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, audit.ErrInvalidFilter):
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, audit.ErrActorNotAllowed):
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
access.WriteMissingPermission(w, role.KeyActivityView)
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
activityQuery := toActivityQuery(query.filterQuery)
|
||||
activityQuery.Page, activityQuery.Size = query.Page, query.Size
|
||||
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
|
||||
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery})
|
||||
if err != nil {
|
||||
writeActivityError(w, err)
|
||||
return
|
||||
@@ -220,7 +220,13 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
|
||||
viewerId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)})
|
||||
if err != nil {
|
||||
writeActivityError(w, err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.ListUseCase.ListAPIKeys(userId)
|
||||
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
|
||||
if err != nil {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
|
||||
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
|
||||
switch {
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
||||
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
@@ -31,7 +31,6 @@ type Handler struct {
|
||||
ListUseCase *useCases.ListArticlesUseCase
|
||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
type PostArticle struct {
|
||||
@@ -39,14 +38,13 @@ type PostArticle struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
GetUseCase: get,
|
||||
ListUseCase: list,
|
||||
DeleteUseCase: delete,
|
||||
UpdateUseCase: update,
|
||||
GetUserUseCase: getUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +55,7 @@ type baseArticleResponse struct {
|
||||
BodyFormat domain.BodyFormat `json:"body_format"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
CreatedBy *uint32 `json:"created_by,omitempty"`
|
||||
Authors []author.Byline `json:"authors"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -86,7 +85,15 @@ type listItemResponse struct {
|
||||
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)
|
||||
if format != domain.BodyFormatJSON {
|
||||
encoded, err := json.Marshal(article.Body)
|
||||
@@ -106,14 +113,15 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArtic
|
||||
BodyFormat: format,
|
||||
IsOnline: article.IsOnline,
|
||||
SiteID: article.SiteID,
|
||||
CreatedBy: creatorFor(article, viewer),
|
||||
Authors: authors,
|
||||
CreatedAt: article.CreatedAt,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
|
||||
base, err := toBaseResponse(article, format)
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
|
||||
base, err := toBaseResponse(article, format, viewer)
|
||||
if err != nil {
|
||||
return articleResponse{}, err
|
||||
}
|
||||
@@ -138,16 +146,16 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat) (listItemResponse, error) {
|
||||
base, err := toBaseResponse(article, format)
|
||||
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) {
|
||||
base, err := toBaseResponse(article, format, viewer)
|
||||
if err != nil {
|
||||
return listItemResponse{}, err
|
||||
}
|
||||
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
|
||||
}
|
||||
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format)
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -180,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var article domain.Article
|
||||
if h.callerIsVerified(r) {
|
||||
article, err = h.GetUseCase.GetArticle(id, format)
|
||||
} else {
|
||||
article, err = h.GetUseCase.GetOnlineArticle(id, format)
|
||||
}
|
||||
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
@@ -195,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
articleJson, err := encodeArticle(article, format)
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -223,12 +226,11 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
creatorId, _ := claims.UserId(r)
|
||||
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
Title: localArticle.Title,
|
||||
Body: localArticle.Body,
|
||||
Format: format,
|
||||
CreatedBy: creatorId,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||
@@ -241,7 +243,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
|
||||
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
|
||||
}
|
||||
|
||||
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 {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
@@ -303,8 +308,8 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
|
||||
articleJson, err := encodeArticle(article, format)
|
||||
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -330,13 +335,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
|
||||
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
}
|
||||
|
||||
type optionalImageID struct {
|
||||
@@ -457,7 +462,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
SiteID: request.SiteID,
|
||||
AuthorIDs: request.AuthorIDs,
|
||||
@@ -480,7 +485,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 (
|
||||
defaultListPage = 1
|
||||
defaultListSize = 25
|
||||
@@ -597,7 +579,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Verified: h.callerIsVerified(r),
|
||||
Caller: access.CallerOf(r),
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
Q: query.Q,
|
||||
@@ -613,7 +595,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
responses := make([]listItemResponse, 0, len(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 {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -639,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil {
|
||||
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -14,11 +15,10 @@ import (
|
||||
type SEOHandler struct {
|
||||
ResolveUseCase *useCases.ResolveArticleSEOUseCase
|
||||
CheckUseCase *useCases.CheckArticleSEOUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler {
|
||||
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser}
|
||||
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler {
|
||||
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check}
|
||||
}
|
||||
|
||||
type CheckArticleSEOBody struct {
|
||||
@@ -68,7 +68,7 @@ func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase))
|
||||
resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r))
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
@@ -93,7 +93,7 @@ func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{
|
||||
checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{
|
||||
Body: request.Body,
|
||||
Slug: request.Slug,
|
||||
Excerpt: request.Excerpt,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
@@ -15,12 +16,14 @@ import (
|
||||
type MeHandler struct {
|
||||
GetUseCase *useCases.GetUserUseCase
|
||||
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{
|
||||
GetUseCase: getUser,
|
||||
GetAuthorUseCase: getAuthor,
|
||||
GetAccessUseCase: getAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +32,24 @@ type meAuthor struct {
|
||||
ArticleCount int `json:"article_count"`
|
||||
}
|
||||
|
||||
type meAccess struct {
|
||||
Role useCases.RoleRef `json:"role"`
|
||||
Permissions []role.Grant `json:"permissions"`
|
||||
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
publicuser.PublicUser
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
}
|
||||
|
||||
func toMeAccess(access useCases.Access) meAccess {
|
||||
return meAccess{
|
||||
Role: useCases.NewRoleRef(access.Role),
|
||||
Permissions: access.Permissions,
|
||||
RoleAssignedBy: access.RoleAssignedBy,
|
||||
}
|
||||
}
|
||||
|
||||
func toMeAuthor(found *author.Author) *meAuthor {
|
||||
@@ -64,7 +82,13 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
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), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package author
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
@@ -33,11 +34,10 @@ type Handler struct {
|
||||
CreateMyUseCase *useCases.CreateMyAuthorUseCase
|
||||
UpdateUseCase *useCases.UpdateAuthorUseCase
|
||||
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 {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser}
|
||||
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type socialLinkRequest struct {
|
||||
@@ -180,6 +180,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
|
||||
func writeAuthorError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case access.WriteIfMissingPermission(w, err):
|
||||
case errors.Is(err, domain.ErrAuthorNotFound):
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrSlugTaken):
|
||||
@@ -226,18 +227,6 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
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 {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
@@ -277,8 +266,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, _ := h.currentUser(r)
|
||||
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
|
||||
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -351,12 +339,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, ok := h.currentUser(r)
|
||||
callerID, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: 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 {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/response"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
@@ -178,6 +178,9 @@ func parseEntryId(r *http.Request) (uint32, error) {
|
||||
}
|
||||
|
||||
func writeEntryError(w http.ResponseWriter, err error) {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
var fieldError domain.FieldError
|
||||
switch {
|
||||
case errors.Is(err, contenttype.ErrContentTypeNotFound):
|
||||
@@ -211,21 +214,13 @@ func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
|
||||
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) {
|
||||
query, err := readListQuery(r.URL.Query())
|
||||
if err != nil || validator.Validate.Struct(query) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q")})
|
||||
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q"), Caller: access.CallerOf(r)})
|
||||
if err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
@@ -243,7 +238,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
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 {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
@@ -252,15 +247,11 @@ func (h *Handler) Get(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)
|
||||
if !ok {
|
||||
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 {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
@@ -274,15 +265,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := currentUserId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, 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 {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
@@ -296,7 +283,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,68 @@
|
||||
package ownership
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
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(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)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
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.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, 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, 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, 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, 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, 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})
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -21,29 +22,23 @@ type Handler struct {
|
||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
type restartRequest struct {
|
||||
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
|
||||
}
|
||||
|
||||
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
|
||||
return &Handler{
|
||||
GetUpdateStatusUseCase: getStatus,
|
||||
CheckForUpdateUseCase: checkForUpdate,
|
||||
StartUpdateDownloadUseCase: startDownload,
|
||||
RequestRestartUseCase: requestRestart,
|
||||
GetUserUseCase: getUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
isOwner, ok := h.isOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||
}
|
||||
|
||||
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,11 +46,7 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
isOwner, ok := h.isOwner(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
|
||||
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
return *request.Kind, true
|
||||
}
|
||||
|
||||
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return false, false
|
||||
}
|
||||
currentUser, err := h.GetUserUseCase.GetUser(userId)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return false, false
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return false, false
|
||||
}
|
||||
return currentUser.Role == user.RoleOwner, true
|
||||
func canApply(r *http.Request) bool {
|
||||
return access.Grants(r).Holds(role.KeySystemApplyUpdates)
|
||||
}
|
||||
|
||||
func writeDownloadError(w http.ResponseWriter, err error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
@@ -25,6 +26,7 @@ type InviteHandler struct {
|
||||
|
||||
type CreateInviteRequest struct {
|
||||
Email string `json:"email" validate:"required,inviteemail"`
|
||||
RoleID uint32 `json:"role_id"`
|
||||
}
|
||||
|
||||
type inviteResponse struct {
|
||||
@@ -32,6 +34,7 @@ type inviteResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Role useCases.RoleRef `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
@@ -40,6 +43,7 @@ type inviteListItem struct {
|
||||
ID uint32 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
Role *useCases.RoleRef `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -50,7 +54,8 @@ type userListItem struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Role *useCases.RoleRef `json:"role"`
|
||||
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -83,8 +88,10 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
|
||||
Caller: access.CallerOf(r),
|
||||
Email: request.Email,
|
||||
InvitedBy: userId,
|
||||
RoleID: request.RoleID,
|
||||
})
|
||||
if err != nil {
|
||||
writeCreateInviteError(w, err)
|
||||
@@ -94,12 +101,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeInviteResponse(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) {
|
||||
func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) {
|
||||
body, err := json.Marshal(inviteResponse{
|
||||
ID: entry.ID,
|
||||
Kind: useCases.EntryKindInvite,
|
||||
Email: entry.Email,
|
||||
Status: string(entry.StatusAt(time.Now())),
|
||||
Role: entry.Role,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
})
|
||||
@@ -144,7 +152,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId})
|
||||
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId})
|
||||
if err != nil {
|
||||
writeManageInviteError(w, err)
|
||||
return
|
||||
@@ -153,6 +161,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func writeManageInviteError(w http.ResponseWriter, err error) {
|
||||
if writeRoleRefusal(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, invite.ErrInviteNotFound):
|
||||
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
|
||||
@@ -168,10 +179,15 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeCreateInviteError(w http.ResponseWriter, err error) {
|
||||
if writeRoleRefusal(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
|
||||
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, invite.ErrPendingInvite):
|
||||
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
|
||||
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, invite.ErrInviteEmailNotSent):
|
||||
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
|
||||
@@ -183,22 +199,27 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
|
||||
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
|
||||
|
||||
items := make([]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Kind == useCases.EntryKindInvite {
|
||||
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
|
||||
continue
|
||||
}
|
||||
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
|
||||
}
|
||||
|
||||
body, err := json.Marshal(items)
|
||||
entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
|
||||
items := make([]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Kind == useCases.EntryKindInvite {
|
||||
items = append(items, inviteListItem{
|
||||
ID: entry.ID,
|
||||
Kind: entry.Kind,
|
||||
Email: entry.Email,
|
||||
Role: entry.Role,
|
||||
Status: entry.Status,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
})
|
||||
continue
|
||||
}
|
||||
items = append(items, userListItemOf(entry))
|
||||
}
|
||||
|
||||
writeNoStoreJSON(w, http.StatusOK, items)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"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: entry.Role,
|
||||
RoleAssignedBy: 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = access.WithCurrentUser(r)
|
||||
origin := audit.Origin{
|
||||
Actor: auditActor(getUser, r),
|
||||
Source: auditSource(r),
|
||||
@@ -29,7 +31,7 @@ func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
|
||||
if !ok {
|
||||
return audit.AnonymousActor("")
|
||||
}
|
||||
current, err := getUser.GetUser(userId)
|
||||
current, err := access.LoadUser(getUser, r, userId)
|
||||
if err != nil {
|
||||
return audit.AnonymousActor("")
|
||||
}
|
||||
|
||||
+4
-37
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
|
||||
return user.User{}, false
|
||||
}
|
||||
|
||||
currentUser, err := getUser.GetUser(userId)
|
||||
currentUser, err := access.LoadUser(getUser, r, userId)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
@@ -39,6 +40,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
|
||||
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = access.WithCurrentUser(r)
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
|
||||
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = access.WithCurrentUser(r)
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -77,39 +80,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner {
|
||||
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}, ¤tUserHolder{}))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -19,12 +19,14 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
||||
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
|
||||
const EmailHasAccountErrorMessage = "This address already has an account."
|
||||
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
||||
const ErrorCodeHeader = "X-Error-Code"
|
||||
const EmailHasAccountErrorCode = "email_has_account"
|
||||
const InvitePendingErrorCode = "invite_pending"
|
||||
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||
const InviteNotFoundErrorMessage = "Invite not found"
|
||||
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
|
||||
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
|
||||
const MediaQueryErrorMessage = "The media query is invalid."
|
||||
@@ -39,8 +41,9 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
|
||||
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||
const MissingPermissionErrorMessage = "missing_permission"
|
||||
const MissingPermissionHeader = "X-Missing-Permission"
|
||||
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
const PurgeBusyErrorMessage = "A purge is already running."
|
||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||
@@ -100,3 +103,26 @@ const InvalidFieldErrorMessage = "invalid_field"
|
||||
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
||||
const EntryNotFoundErrorMessage = "entry_not_found"
|
||||
const InvalidEntryErrorMessage = "invalid_entry"
|
||||
|
||||
const RoleNotFoundErrorMessage = "role_not_found"
|
||||
const RoleLockedErrorMessage = "role_locked"
|
||||
const RoleBuiltInErrorMessage = "role_built_in"
|
||||
const BuiltInNameErrorMessage = "built_in_name"
|
||||
const NotBuiltInErrorMessage = "not_built_in"
|
||||
const OwnRoleErrorMessage = "own_role"
|
||||
const EscalationErrorMessage = "escalation"
|
||||
const EscalationHeader = "X-Escalation-Key"
|
||||
const RoleNameTakenErrorMessage = "role_name_taken"
|
||||
const RoleConflictErrorMessage = "role_conflict"
|
||||
const RoleHasMembersErrorMessage = "role_has_members"
|
||||
const InvalidRoleNameErrorMessage = "invalid_role_name"
|
||||
const UnknownPermissionErrorMessage = "unknown_permission"
|
||||
const InvalidScopeErrorMessage = "invalid_scope"
|
||||
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
|
||||
const DuplicatePermissionErrorMessage = "duplicate_permission"
|
||||
const UnknownContentTypeErrorMessage = "unknown_content_type"
|
||||
|
||||
const InvalidRoleErrorMessage = "invalid_role"
|
||||
const UserRoleChangedErrorMessage = "user_role_changed"
|
||||
const InvalidTransferErrorMessage = "invalid_transfer"
|
||||
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
|
||||
@@ -13,7 +13,6 @@ type PublicUser struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role user.Role `json:"role"`
|
||||
}
|
||||
|
||||
func New(u user.User) PublicUser {
|
||||
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
}
|
||||
}
|
||||
+162
-81
@@ -11,7 +11,11 @@ import (
|
||||
"RenewCMS/api/handlers/entry"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/handlers/imagevariant"
|
||||
"RenewCMS/api/handlers/llms"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/ownership"
|
||||
"RenewCMS/api/handlers/permission"
|
||||
rolehandler "RenewCMS/api/handlers/role"
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
@@ -19,7 +23,9 @@ import (
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/llmdocs"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -55,16 +61,37 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
return c.apiKeyAuthMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
|
||||
func (c *UseCases) requireTyped(key string, typeKey func(*http.Request) string) func(http.Handler) http.Handler {
|
||||
return middleware.RequireTypedPermission(c.GetUserUseCase, c.CheckPermissionUseCase, key, typeKey)
|
||||
}
|
||||
|
||||
func (c *UseCases) requireArticle(key string) func(http.Handler) http.Handler {
|
||||
return c.requireTyped(key, middleware.ArticleTypeKey)
|
||||
}
|
||||
|
||||
func (c *UseCases) requireEntry(key string) func(http.Handler) http.Handler {
|
||||
return c.requireTyped(key, middleware.PathTypeKey)
|
||||
}
|
||||
|
||||
func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
|
||||
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
|
||||
|
||||
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase, container.GetUserUseCase)
|
||||
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Get("/{id}/seo", seoHandler.Resolve)
|
||||
@@ -73,12 +100,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||
r.Post("/{id}/seo/check", seoHandler.Check)
|
||||
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
|
||||
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
|
||||
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||
r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -90,7 +117,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
|
||||
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, 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)
|
||||
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||
|
||||
@@ -138,7 +165,9 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
@@ -146,16 +175,28 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
|
||||
r.Get("/lookup", lookupHandler.Lookup)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyUsersInvite))
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Post("/invite", inviteHandler.CreateInvite)
|
||||
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
|
||||
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyUsersChangeRole))
|
||||
r.Put("/{id}/role", roleHandler.Change)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -170,6 +211,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeySettingsView))
|
||||
r.Get("/", settingsHandler.GetSettings)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -180,7 +222,7 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeySystemEditRetention))
|
||||
r.Patch("/", settingsHandler.UpdateSettings)
|
||||
})
|
||||
return r
|
||||
@@ -193,6 +235,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
|
||||
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
|
||||
|
||||
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
|
||||
|
||||
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -210,19 +254,19 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Post("/", siteHandler.Create)
|
||||
r.Patch("/{id}", siteHandler.Update)
|
||||
r.Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.Delete("/{id}", siteHandler.Delete)
|
||||
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
|
||||
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
|
||||
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
|
||||
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
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.Use(authtoken.Verifier(container.Token))
|
||||
@@ -230,6 +274,40 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyRolesView))
|
||||
r.Get("/", roleHandler.List)
|
||||
r.Get("/{id}", roleHandler.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyRolesManage))
|
||||
r.Post("/", roleHandler.Create)
|
||||
r.Put("/{id}", roleHandler.Update)
|
||||
r.Delete("/{id}", roleHandler.Delete)
|
||||
r.Post("/{id}/reassign", roleHandler.Reassign)
|
||||
r.Post("/{id}/reset", roleHandler.Reset)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyAuthorsView))
|
||||
r.Get("/", authorHandler.List)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -241,12 +319,9 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/me", authorHandler.CreateMine)
|
||||
r.Patch("/{id}", authorHandler.Update)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", authorHandler.Create)
|
||||
r.Delete("/{id}", authorHandler.Delete)
|
||||
})
|
||||
r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update)
|
||||
r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create)
|
||||
r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -265,19 +340,16 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", contentTypeHandler.List)
|
||||
r.Get("/{key}", contentTypeHandler.Get)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", contentTypeHandler.Create)
|
||||
r.Put("/{key}", contentTypeHandler.Update)
|
||||
r.Delete("/{key}", contentTypeHandler.Delete)
|
||||
})
|
||||
r.Get("/{key}/entry", entryHandler.List)
|
||||
r.Post("/{key}/entry", entryHandler.Create)
|
||||
r.Get("/{key}/entry/{id}", entryHandler.Get)
|
||||
r.Put("/{key}/entry/{id}", entryHandler.Update)
|
||||
r.Delete("/{key}/entry/{id}", entryHandler.Delete)
|
||||
r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List)
|
||||
r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get)
|
||||
r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create)
|
||||
r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update)
|
||||
r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete)
|
||||
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List)
|
||||
r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create)
|
||||
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get)
|
||||
r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update)
|
||||
r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -293,7 +365,7 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(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("/{key}", builtinTypeHandler.Get)
|
||||
return r
|
||||
@@ -311,12 +383,9 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", activityHandler.List)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/summary", activityHandler.Summary)
|
||||
r.Get("/export", activityHandler.Export)
|
||||
})
|
||||
r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
|
||||
r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
|
||||
r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -330,7 +399,7 @@ const (
|
||||
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -338,19 +407,19 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/update", updateHandler.GetStatus)
|
||||
r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeySystemApplyUpdates))
|
||||
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
||||
r.Post("/update/check", updateHandler.Check)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeySystemApplyUpdates))
|
||||
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
|
||||
r.Post("/update/download", updateHandler.Download)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeySystemApplyUpdates))
|
||||
r.Post("/update/restart", updateHandler.Restart)
|
||||
})
|
||||
return r
|
||||
@@ -367,9 +436,23 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", apiKeyHandler.Create)
|
||||
r.Get("/", apiKeyHandler.List)
|
||||
r.Delete("/{id}", apiKeyHandler.Revoke)
|
||||
r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
|
||||
r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
|
||||
r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewPermissionRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
|
||||
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", permissionHandler.List)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -385,28 +468,24 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.Post("/purge", mediaHandler.RunPurge)
|
||||
})
|
||||
r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RejectAPIKey)
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/{id}/variants", mediaHandler.ListVariants)
|
||||
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||
r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||
})
|
||||
r.Get("/", mediaHandler.List)
|
||||
r.Post("/", mediaHandler.Upload)
|
||||
r.Post("/merge", mediaHandler.Merge)
|
||||
r.Patch("/{id}", mediaHandler.Rename)
|
||||
r.Delete("/{id}", mediaHandler.Delete)
|
||||
r.Get("/{id}", mediaHandler.Get)
|
||||
r.Get("/{id}/usage", mediaHandler.Usage)
|
||||
r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
|
||||
r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
|
||||
r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
|
||||
r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
|
||||
r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
|
||||
r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
|
||||
r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -421,15 +500,14 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/", variantHandler.List)
|
||||
r.Post("/", variantHandler.Create)
|
||||
r.Get("/cache", variantHandler.CacheStats)
|
||||
r.Post("/cache/purge", variantHandler.PurgeCache)
|
||||
r.Put("/{name}", variantHandler.Update)
|
||||
r.Patch("/{name}", variantHandler.SetEnabled)
|
||||
r.Delete("/{name}", variantHandler.Delete)
|
||||
r.Post("/{name}/purge", variantHandler.Purge)
|
||||
r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
|
||||
r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
|
||||
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -448,6 +526,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
|
||||
@@ -455,6 +534,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/permission", NewPermissionRouter(container))
|
||||
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
|
||||
@@ -493,6 +573,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
|
||||
apiRouter.Use(chimiddleware.GetHead)
|
||||
apiRouter.Get("/healthz", GetHealth)
|
||||
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
|
||||
apiRouter.Mount("/v1", backend)
|
||||
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||
apiRouter.Mount("/", frontend)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+173
-14
@@ -11,8 +11,8 @@ for example `https://cms.example/v1/article/`.
|
||||
|
||||
## Authentication
|
||||
- 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
|
||||
object also answer drafts to a verified session or API key.
|
||||
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
|
||||
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
||||
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
||||
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
||||
creator, with that user's role, and shows in full once.
|
||||
@@ -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} |
|
||||
| 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, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
|
||||
| Settings | GET /v1/settings |
|
||||
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||
| Authors | GET /v1/author/ |
|
||||
@@ -45,12 +45,12 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
|
||||
|
||||
| Route | Who | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
|
||||
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
|
||||
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
|
||||
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
|
||||
|
||||
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
||||
`api_keys`, `auth`, `system`, `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
|
||||
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`).
|
||||
@@ -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`,
|
||||
`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
|
||||
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.
|
||||
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` |
|
||||
| `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.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
||||
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
@@ -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`
|
||||
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
|
||||
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
|
||||
session only and owner only (403 "Only the owner can do this.", or "This action needs a real
|
||||
session." with a key). The role is checked before the body.
|
||||
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
|
||||
session." with a key. The role is checked before the body.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
@@ -173,7 +173,7 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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
|
||||
`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author
|
||||
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
|
||||
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
|
||||
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
|
||||
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
|
||||
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
|
||||
@@ -284,8 +284,7 @@ repoint the avatars of the merged copies and report it as `authors_updated`.
|
||||
|
||||
## Image variants
|
||||
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
||||
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
|
||||
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
||||
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
@@ -354,10 +353,170 @@ 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
|
||||
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). |
|
||||
| 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, the Owner role, unknown). |
|
||||
| 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` 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.
|
||||
|
||||
## 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 `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. |
|
||||
| `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` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
||||
|
||||
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
|
||||
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
|
||||
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
|
||||
under two simultaneous transfers (one answers 200, the other 409).
|
||||
|
||||
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
|
||||
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
|
||||
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
|
||||
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
|
||||
`GET /v1/auth/me`; it is never promoted.
|
||||
|
||||
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
|
||||
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
|
||||
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
|
||||
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
|
||||
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
|
||||
|
||||
## Content permissions
|
||||
|
||||
Articles and the entries of custom content types are checked against the `content.*` permissions of the
|
||||
caller's role, which is read on every request (a key follows its creator's role).
|
||||
|
||||
| Route | Permission | Scope check after the lookup |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
|
||||
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
|
||||
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
|
||||
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
|
||||
| `DELETE /v1/article/{id}` | `content.delete` | same |
|
||||
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
|
||||
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
|
||||
| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller |
|
||||
| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller |
|
||||
|
||||
Each action reads its own permission: publish does not imply update, update does not imply read. `own`
|
||||
means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission
|
||||
can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that
|
||||
row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the
|
||||
wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`.
|
||||
|
||||
A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`,
|
||||
never `content.update:blog`). The permission is checked before the id and the body are read, then the scope
|
||||
after the lookup, and a refused call writes nothing and records no activity event. The verification and
|
||||
token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media`
|
||||
or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the
|
||||
same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it.
|
||||
|
||||
## Errors
|
||||
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
||||
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
||||
|
||||
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
|
||||
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
|
||||
every request, so an API key follows the current role of its creator.
|
||||
|
||||
## Notes
|
||||
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
||||
- Browsers can call the API only from origins in the server's CORS setting.
|
||||
@@ -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}/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
|
||||
|
||||
@@ -72,6 +72,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"body_format": "markdown",
|
||||
"is_online": true,
|
||||
"site_id": 1,
|
||||
"created_by": 1,
|
||||
"authors": [],
|
||||
"slug": "backyard-renovation-update",
|
||||
"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.
|
||||
|
||||
## SEO fields
|
||||
@@ -121,7 +124,7 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr
|
||||
|
||||
## 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`).
|
||||
|
||||
|
||||
+13
-2
@@ -15,7 +15,7 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
|
||||
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
|
||||
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
|
||||
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
|
||||
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
|
||||
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. |
|
||||
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
|
||||
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
|
||||
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
|
||||
@@ -27,7 +27,8 @@ Nothing under `internal/` imports `api/`.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
|
||||
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). |
|
||||
| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. |
|
||||
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
|
||||
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
|
||||
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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 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.
|
||||
@@ -138,3 +138,6 @@ 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.
|
||||
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
|
||||
- The sandbox cannot export; no files were bundled.
|
||||
|
||||
## Pass 6: roles and permissions
|
||||
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 11 groups, 61 states, all Available now. 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. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
|
||||
@@ -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 |
|
||||
| 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No |
|
||||
| 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No |
|
||||
|
||||
## Roles and permissions: screens that change
|
||||
|
||||
| Screen | Change |
|
||||
|---|---|
|
||||
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
|
||||
| Settings navigation (AppFrame, done) | New "Access" group with a "Roles" item (shield icon) under Settings in the sidebar and the mobile drawer, shown to everyone except the restricted `user` role. "Permissions" is removed. |
|
||||
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
|
||||
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
|
||||
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
|
||||
| Content entry lists and editor | Same Own rules. View only mark. |
|
||||
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
|
||||
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
|
||||
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
|
||||
| Profile | Your role card and See what I can do. |
|
||||
| Activity and filters | New event types, role diff in the detail panel. |
|
||||
| Error and 403 copy | Two codes and the page-level state added. |
|
||||
|
||||
Decisions to confirm:
|
||||
| # | Decision made | Alternative |
|
||||
|---|---|---|
|
||||
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
|
||||
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
|
||||
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
|
||||
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
|
||||
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
|
||||
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
|
||||
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
|
||||
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
|
||||
@@ -131,3 +131,29 @@ 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 |
|
||||
| FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px |
|
||||
| MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row |
|
||||
|
||||
## Roles and permissions (new)
|
||||
|
||||
| Component | Used for | Nuxt UI mapping |
|
||||
|---|---|---|
|
||||
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
|
||||
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
|
||||
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
|
||||
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
|
||||
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
|
||||
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
|
||||
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
|
||||
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
|
||||
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
|
||||
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
|
||||
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
|
||||
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
|
||||
|
||||
Accessibility of the tri-state cell:
|
||||
| Item | Pattern |
|
||||
|---|---|
|
||||
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
|
||||
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
|
||||
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
|
||||
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
|
||||
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
|
||||
+22
-1
@@ -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/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
|
||||
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
|
||||
| /settings/permissions | 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)
|
||||
`/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,24 @@ No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?
|
||||
|
||||
## Custom content types in the URL
|
||||
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
|
||||
|
||||
## Roles and permissions
|
||||
|
||||
| Route | Purpose | Access |
|
||||
|---|---|---|
|
||||
| /settings/roles | Roles list | Roles: view |
|
||||
| /settings/roles/new | New role | Roles: manage |
|
||||
| /settings/roles/:id | Role editor (read-only without manage) | Roles: view |
|
||||
| /settings/permissions | Retired. 301 to /settings/roles | n/a |
|
||||
|
||||
API: GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me · GET /v1/permission. Errors: 403 missing_permission, 403 role_locked, 409 role_has_members, 409 role_name_taken, 409 role_conflict.
|
||||
|
||||
Needs backend:
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
|
||||
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
|
||||
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
|
||||
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
|
||||
| Version field | role.version for the conflict state (409 role_conflict). |
|
||||
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||
@@ -15,7 +15,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- 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".
|
||||
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||
- Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
|
||||
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
|
||||
|
||||
@@ -114,3 +114,87 @@ 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/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
|
||||
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
|
||||
|
||||
|
||||
## Search (jump-to palette)
|
||||
Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working.
|
||||
|
||||
### Behaviour
|
||||
- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts.
|
||||
- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger.
|
||||
- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows.
|
||||
- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first.
|
||||
- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep-<id> so the result can be shared.
|
||||
- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row.
|
||||
- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count.
|
||||
|
||||
### Row anatomy
|
||||
- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted.
|
||||
- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description.
|
||||
- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated.
|
||||
- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2.
|
||||
|
||||
### Copy per state
|
||||
| # | State | Copy |
|
||||
|---|---|---|
|
||||
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
|
||||
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
|
||||
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "10 routes"); count and live "17 results, showing 7" |
|
||||
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "N more endpoints. Keep typing to narrow the list."; count "22 results"; live "22 results, showing 7" |
|
||||
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
|
||||
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "35 routes, not available to API keys"; "2 results" |
|
||||
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
|
||||
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
|
||||
|
||||
### Nuxt UI v4 mapping
|
||||
- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }).
|
||||
- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling.
|
||||
- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite".
|
||||
- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only.
|
||||
|
||||
### Assumptions to confirm
|
||||
- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples.
|
||||
- Matching is substring per term, not fuzzy ("mdia" does not match "media").
|
||||
- The palette shows the first sentence of each endpoint description, not the full text.
|
||||
- Endpoints are capped at 6 rows with a count line; sections are never capped.
|
||||
- Recent searches are left out. Open question: store the last 3 queries in localStorage?
|
||||
- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm).
|
||||
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
|
||||
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
|
||||
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,565 @@
|
||||
# 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: You can see roles but not change them · Ask an owner or admin if a role needs to change. · 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/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 · Her 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 he transfers 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/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: Five new events.
|
||||
|
||||
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 · 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.
|
||||
|
||||
## 9 Profile: your role
|
||||
|
||||
Route: /profile · API: GET /v1/me · Auth: Any
|
||||
|
||||
The person sees their own role and a read-only summary.
|
||||
|
||||
### Role card (Available now)
|
||||
|
||||
Trigger: Any signed-in user.
|
||||
|
||||
Copy: 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. 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.
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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). |
|
||||
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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. |
|
||||
|
||||
|
||||
State count: 61.
|
||||
@@ -18,7 +18,7 @@ All later. The model reads as two sentences, with a live preview of both sides.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -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="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">
|
||||
<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-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>
|
||||
@@ -369,9 +369,10 @@
|
||||
</sc-if>
|
||||
|
||||
{{ 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="{{ 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 }}
|
||||
<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 }}">
|
||||
@@ -390,7 +391,7 @@ class Component extends DCLogic {
|
||||
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 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 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 || {}
|
||||
@@ -546,7 +547,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,
|
||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||
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,
|
||||
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',
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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;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-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>
|
||||
@@ -19,12 +20,15 @@
|
||||
<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="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<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="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
|
||||
</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">
|
||||
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
||||
@@ -48,7 +52,7 @@
|
||||
</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;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>
|
||||
</section>
|
||||
@@ -57,10 +61,15 @@
|
||||
<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>
|
||||
<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)">
|
||||
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
|
||||
<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="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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</sc-if>
|
||||
</div>
|
||||
@@ -85,7 +94,7 @@
|
||||
</sc-for>
|
||||
|
||||
<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="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">
|
||||
@@ -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>
|
||||
<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>
|
||||
<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:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for>
|
||||
</div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
||||
</section>
|
||||
@@ -130,11 +139,16 @@
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
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()
|
||||
BASE = 'https://cms.renew.example'
|
||||
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() {
|
||||
const i = (this.props.s && this.props.s.int) || {}
|
||||
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) }
|
||||
onScroll = () => {
|
||||
const r = this.rootRef.current; if (!r) return
|
||||
let cur = this.IDX[0][0]
|
||||
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
|
||||
let cur = this.IDX[0][0], best = -1
|
||||
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 })
|
||||
}
|
||||
flash(k) {
|
||||
@@ -177,6 +191,16 @@ class Component extends DCLogic {
|
||||
const i404 = [404, 'Invite not found']
|
||||
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 eAd = [403, 'Only owners and admins can do this.'], 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 = {
|
||||
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' }]) }),
|
||||
@@ -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('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. An owner or admin creates 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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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. Any verified user can write entries.', 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.', 'Owner or admin', 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.', 'Owner or admin', 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: [
|
||||
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, 'Invalid page or size.'], 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-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-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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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.', 'Owner or admin', 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, 'Invalid page or size.'], 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: [
|
||||
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
|
||||
@@ -211,35 +261,35 @@ class Component extends DCLogic {
|
||||
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
|
||||
return Object.keys(G).map(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 => {
|
||||
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12')
|
||||
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('{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]
|
||||
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
||||
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] + '"')
|
||||
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 = []
|
||||
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 (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')
|
||||
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
||||
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||
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 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 })]
|
||||
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,
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Owner or admin' ? { 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: 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] } })),
|
||||
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 } })) } }),
|
||||
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 +305,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 sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
|
||||
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 {
|
||||
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,
|
||||
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)',
|
||||
@@ -265,17 +318,18 @@ class Component extends DCLogic {
|
||||
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',
|
||||
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',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
|
||||
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: 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: 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: 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.", "Only owners and admins can do this." or "You must verify your account to access this resource."' },
|
||||
{ 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`. 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`.' },
|
||||
{ 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: 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) })),
|
||||
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="{"$preview":{"width":1440,"height":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, 'Only owners and admins can do this.'], 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, 'Invalid page or size.'], [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.', 'Owner or admin', ['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.', 'Owner or admin', ['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.', 'Owner or admin', [], [[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.', 'Owner or admin', [], [[401, L], eAd]),
|
||||
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'Owner or admin', [], [[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, 'Invalid page or size.'], [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.', 'Owner or admin', [], [[401, L], eAd]),
|
||||
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Owner or admin', ['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.', 'Owner or admin', [], [[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.', 'Owner or admin', ['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, 'Invalid page or size.'], [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>
|
||||
@@ -364,7 +364,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: "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: "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: [
|
||||
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 }),
|
||||
|
||||
@@ -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="{"theme":{"editor":"enum","default":"light","options":["system","light","dark"],"tsType":"string"}}">
|
||||
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>
|
||||
@@ -0,0 +1,316 @@
|
||||
<!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="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||
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') },
|
||||
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 => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [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]); (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 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), ...TY.map(t => card(t, R.t[t], R.t[t] !== base)))
|
||||
}
|
||||
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))
|
||||
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative', hd, row('', allv, false, true), ...TY.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip)))
|
||||
}
|
||||
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) : 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 not change them', 'Ask an owner or admin if a role needs to change.'),
|
||||
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'))
|
||||
} 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 (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.') } }
|
||||
}
|
||||
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')]))
|
||||
} 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']]
|
||||
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 === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
|
||||
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) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? '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 && 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: 'Her 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: 'Her 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.')]))
|
||||
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 he transfers 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 === '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 E = [['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'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
|
||||
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: k === 'detail' && i === 1 }, I(e[0], 16, 'color:var(--ui-text-toned)'), Hi(e[1]), 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])), Mu(e[2] + ' · ' + e[3]))))
|
||||
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), m ? cards : tbl)
|
||||
if (k === 'detail') { const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi('Role Contributor edited'), Mu('Marta Alves · Oct 14, 10:52')), 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('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 = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
|
||||
const R = ROLES.contrib
|
||||
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account.'), Card('Account', null, null, F('Name', 'Pedro Lima', {}), F('Email', 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', RB('Contributor'), Mu('Assigned by Marta Alves')), Mu(R.desc), 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.')))
|
||||
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>
|
||||
@@ -0,0 +1,177 @@
|
||||
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' }, 'You can see roles but not change them · Ask an owner or admin if a role needs to change. · 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: '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.', { 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/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 · Her 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 he transfers 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/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, 'Five new events.', { 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 · 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.')
|
||||
] },
|
||||
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/me', auth: 'Any', note: 'The person sees their own role and a read-only summary.', states: [
|
||||
st('Role card', N, 'Any signed-in user.', { v: 'prof', k: 'card' }, '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. If your role changes, they change too.'),
|
||||
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 })
|
||||
] }
|
||||
]
|
||||
|
||||
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']]),
|
||||
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.'],
|
||||
['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).'],
|
||||
['Activity events', 'role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred.']]),
|
||||
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('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', '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.']])
|
||||
]
|
||||
@@ -107,3 +107,37 @@ Entry rules, 422 except unique (409): required, type, min, max, options, unique,
|
||||
|
||||
## 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).
|
||||
|
||||
## Roles and permissions
|
||||
|
||||
61 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 2, Profile 2, API keys 1, Create-a-role flow 6. 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. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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.
|
||||
@@ -45,3 +45,6 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
||||
| `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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: The back-office keeps an append-only activity log that owners can read and retention can prune
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want every important action recorded with who, what, when and from where
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: activity-log-backend
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box), new files `test/activity_log_domain_test.go`, `test/activity_log_persistence_test.go`, `test/activity_log_http_test.go` and `test/activity_cleanup_test.go`. Fake and helper type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. No env var is added. Never put a secret value in a test name or log assertion helper beyond the fixed test strings.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Every real action of the back-office is recorded in the activity log from inside its use case
|
||||
Superseded in part by roles_permissions_users_api: user.change_role and user.transfer_ownership (security) join the users category, and invite.create and invite.accept carry the params role_id and role (the role name).
|
||||
|
||||
As the owner of a RenewCMS site
|
||||
I want articles, media, users and invites, and system tasks to leave an activity event
|
||||
|
||||
@@ -51,6 +51,7 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
|
||||
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
|
||||
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
|
||||
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
|
||||
Superseded in part by roles_permissions_roles_api: the closed category list also holds `roles` (target `role`) and the actions are `role.create`, `role.edit`, `role.delete` and `role.reassign`, covered in test/roles_api_*_test.go.
|
||||
|
||||
## G. Mutation guard (gremlins stays clean)
|
||||
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
|
||||
@@ -65,3 +66,8 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
|
||||
42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container.
|
||||
43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent.
|
||||
44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure.
|
||||
|
||||
## Amendment from roles_permissions_users_api
|
||||
|
||||
- `user.change_role` and `user.transfer_ownership` (security true) are recorded in the `users` category by `ChangeUserRoleUseCase` and `TransferOwnershipUseCase`. A refusal and a no-op record nothing; a storage failure at the write records a failed event.
|
||||
- `invite.create` and `invite.accept` carry the params `role_id` and `role` (the role name). `invite.renew` is unchanged. A refused role (unknown, locked, above the caller) on create or renew records nothing.
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Activity screen, filter cards as toggles and the Export menu
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an owner or admin
|
||||
I want the Failed actions and Security events cards to toggle their filter, and Export to offer the current view or all activity as CSV
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Activity filter cards as toggles and the Export menu
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
## Implementation note for coder (informational)
|
||||
|
||||
SPA slice with one small backend change: accept `range=all` in the list, summary and export range validation (use case range handling and the handler `validate` tag). An omitted range stays 7d. The range table in features/activity-log-backend.feature is updated accordingly.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Recent activity card on Home and Retention card on Settings
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As a RenewCMS user
|
||||
I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Recent activity card on Home and Retention card on Settings
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
- Backend: add NextCleanupAt (nullable time) to the audit settings entity with a domain method computing the next 03:00 (audit.CleanupHour) strictly after a given time, in that time's location; nil when RetentionDays is audit.ForeverRetentionDays. GetActivityRetentionUseCase and UpdateActivityRetentionUseCase get an injected `now func() time.Time` (wired in api/dependecyInjection.go) and populate it. settingsResponse in api/handlers/settings gains `activity_next_cleanup_at` (`*time.Time`, RFC 3339, null when nil). No logic in the handler, none in the SPA.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As a developer integrating with RenewCMS
|
||||
I want to create named API keys and send them as a Bearer token
|
||||
@@ -115,7 +116,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the
|
||||
When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
|
||||
Then the response status is 201
|
||||
And the stored key belongs to user 2
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.MissingPermissionErrorMessage
|
||||
|
||||
Scenario Outline: Key management needs a verified real session
|
||||
Given <caller>
|
||||
@@ -186,7 +187,7 @@ Feature: Developers authenticate against /v1 with named API keys instead of the
|
||||
Given an active key created by admin 2
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 403
|
||||
And the response body is apperr.OwnerRequiredErrorMessage
|
||||
And the response body is apperr.MissingPermissionErrorMessage
|
||||
And no invite exists
|
||||
|
||||
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: API keys
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
@@ -38,7 +40,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
"yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
|
||||
Each: repository still empty.
|
||||
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite.
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.MissingPermissionErrorMessage`, no invite.
|
||||
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
|
||||
stale `token_version` -> `401`. Each: repository empty.
|
||||
|
||||
@@ -59,7 +61,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
`200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
|
||||
-> `401` `apperr.UnauthorizedErrorMessage`.
|
||||
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403`
|
||||
`apperr.OwnerRequiredErrorMessage`, no invite and no mail.
|
||||
`apperr.MissingPermissionErrorMessage`, no invite and no mail.
|
||||
19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the
|
||||
stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only):
|
||||
unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Article body content negotiation on read
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As a headless frontend developer
|
||||
I want article routes to return the body as markdown (default), html or a typed json block array
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: article_body_format
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Article bodies have no length limit and the body is optional
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want to create an article with only a title and write articles of any length
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: unlimited, optional article body
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
## Part A. HTTP (Go, test/article_body_unlimited_http_test.go)
|
||||
|
||||
Setup: in-memory fake article repository plus the real markdown converter as BodyRenderer (so the "json" format of an empty body is genuinely exercised). Build CreateArticle, GetArticle, UpdateArticle use cases and the article handler via NewHandler(...). Mount on a fresh chi.NewRouter() that mirrors api/router.go: POST /v1/article/ and PATCH /v1/article/{id} inside jwtauth.Verifier + Authenticator with a test jwtauth.New("HS256", ...) token. Call validator.InitValidator(). Use httptest. No live server, no real SQLite, no real SMTP.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: An authenticated user can publish and unpublish an article
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As an authenticated RenewCMS user managing the CMS
|
||||
I want to flip an article's is_online flag from an HTTP endpoint
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: article_publishing
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
Package: `test` (black-box, extending `article_test.go`).
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: The article title rule is the same in the request and the domain
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want PostArticle's title validation and article.CreateArticle's title validation to
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: article_title_rules
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_test.go` /
|
||||
`not_found_errors_test.go` / the hasher-domain-interface tests).
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Articles list screen
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want /article to list every article with its status, let me search and filter it, and let me
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Articles list screen
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Paginated article listing
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As an API consumer or the RenewCMS back-office
|
||||
I want GET /v1/article/ to always answer one page of articles with the total and the tab counts
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Paginated article listing (API)
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real `ListArticlesUseCase` and a fake `article.Repository` (extend `mockArticleRepo` in `test/article_test.go`, keeping type names unique across files). The fake implements the interface contract: paging (Page/Size 0 = unpaged), q/status filtering, ordering updated_at DESC then id DESC, total, and the visibility counts. Mount on a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only, test `jwtauth.New("HS256", ...)`). Call `validator.InitValidator()`. Use a user fake for the verified, unverified and missing cases, as in `test/public_article_listing_test.go`. No live server, no SQLite file.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Authors backend, public bylines that articles credit in order
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an owner or admin of a RenewCMS installation
|
||||
I want to manage authors and credit them on articles in a chosen order
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: Authors backend
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token or constant named in the spec.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Container-aware update guidance
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As the owner of a RenewCMS site running in Docker
|
||||
I want Settings to say "update by pulling a new image" instead of "Not prepared"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Container update guidance
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
## Automated (Go, test/, httptest)
|
||||
1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...).
|
||||
2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API
|
||||
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As a site owner or admin
|
||||
I want to define content types made of ordered scalar fields, and then create entries of those types
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# QA procedure: custom content types (backend core)
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
|
||||
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an owner, admin or verified user working in the SPA
|
||||
I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# QA procedure: custom content types support (icon, built-in read model, user lookup)
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Deleting an article never deletes images
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As an authenticated RenewCMS user
|
||||
I want deleting an article to leave the media library alone
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: delete_article_keeps_images
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first.
|
||||
|
||||
## HTTP checks
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: An authenticated user can edit an article's body
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As an authenticated RenewCMS user managing the CMS
|
||||
I want to change an existing article's body from an HTTP endpoint
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: edit_article_body
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
Package: `test` (black-box, extending `article_test.go`).
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: A logged-in user fetches their own public profile without triggering the verified-account gate
|
||||
Superseded in part by roles_permissions_users_api: GET /v1/auth/me has no top-level role key; the role is access.role only. The public user has seven fields: id, username, email, is_verified, created_at, updated_at and display_name.
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants are generated on first request, cached, and managed by admins
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an admin or owner of a RenewCMS site
|
||||
I want to define named variants of the original images and have them generated and cached on demand
|
||||
@@ -48,12 +49,12 @@ Feature: Image variants are generated on first request, cached, and managed by a
|
||||
| GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins are both allowed
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image-variants
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an owner or admin of a RenewCMS site
|
||||
I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image
|
||||
@@ -42,8 +43,8 @@ Feature: Image variants cache panel: cache statistics, purge by scope, request t
|
||||
| GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image_variants_cache
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Two parts: Go for the API and tracking, Vitest for the Cache screen. No live server, no real SQLite file, no real SMTP, no `time.Sleep` (use an injected clock and manual flushes).
|
||||
|
||||
## Go (package `test`, black-box)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Image variants per image: library fields, per-image routes and generate on upload
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As an owner or admin of a RenewCMS site
|
||||
I want to see, generate and purge the variants of one image, and to have variants generated when I upload
|
||||
@@ -36,10 +37,10 @@ Feature: Image variants per image: library fields, per-image routes and generate
|
||||
| GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.MissingPermissionErrorMessage |
|
||||
| GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA procedure: image_variants_per_image_api
|
||||
|
||||
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_per_image_http_test.go`, `test/image_variants_generate_on_upload_test.go`, `test/media_variants_list_http_test.go`, `test/image_variants_per_image_audit_test.go`. Extend `test/image_variants_support_test.go`; fake type names must stay unique in the package. No live server, no real SQLite file, no real SMTP, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Integration screen (API reference)
|
||||
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
|
||||
|
||||
As a developer building a frontend or an API-key client for a RenewCMS site
|
||||
I want /integration to be a complete, searchable API reference
|
||||
@@ -11,13 +12,13 @@ Feature: Integration screen (API reference)
|
||||
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
|
||||
"Everything a developer needs to build on RenewCMS."
|
||||
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
|
||||
And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order:
|
||||
And the catalogue holds 42 key-callable endpoints in 9 expandable groups (the Content types, Entries, Built-in types and Sites and authors groups and the extra Media routes are in features/integration_api_key_routes.feature), in this order:
|
||||
| group | routes | endpoints |
|
||||
| Articles | 7 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | 6 | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
|
||||
| Articles | 8 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}, POST /v1/article/{id}/seo/check |
|
||||
| Media | 11 | 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 and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge |
|
||||
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
|
||||
| Settings | 1 | GET /v1/settings |
|
||||
And the "Session only" group has 13 non-expandable rows in 3 sub-groups:
|
||||
And the "Session only" group has 37 non-expandable rows in 6 sub-groups (the full list is in features/integration_api_key_routes.feature); the first three are:
|
||||
| sub-group | rows |
|
||||
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
|
||||
| Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset |
|
||||
@@ -36,7 +37,7 @@ Feature: Integration screen (API reference)
|
||||
|
||||
Scenario: Default, every card collapsed
|
||||
When the user navigates to /integration
|
||||
Then the intro reads "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."
|
||||
Then the intro reads "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."
|
||||
And a "Base URL" label sits above a CodeSnippet with the single tab "Base URL" showing window.location.origin and a "Copy" button (icon copy)
|
||||
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
|
||||
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
|
||||
@@ -117,7 +118,7 @@ Feature: Integration screen (API reference)
|
||||
And its CodeSnippet has tabs curl and fetch for GET /v1/auth/me, using the current origin
|
||||
And it says "Keys start with `rcms_` and are shown in full once, when you create them."
|
||||
And a "Manage API keys" link navigates to /integration/api-keys
|
||||
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, 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."
|
||||
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, 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. Owner and admin routes answer 403 to a key whose creator is neither."
|
||||
And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
|
||||
Scenario: Session only group
|
||||
@@ -134,9 +135,10 @@ Feature: Integration screen (API reference)
|
||||
| status | message |
|
||||
| 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. |
|
||||
| 401 | You must be logged in to access this resource. |
|
||||
| 403 | This action needs a real session. / Only the owner can do this. / You must verify your account to access this resource. |
|
||||
| 404 | Article not found / Image not found / Invite not found / API key not found |
|
||||
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
|
||||
| 403 | This action needs a real session. / Only owners and admins can do this. / Only the owner can do this. / You must verify your account to access this resource. |
|
||||
| 404 | Article not found / Image not found / Invite not found / API key not found / content_type_not_found / entry_not_found / builtin_type_not_found |
|
||||
| 409 | There's already a pending invite for this address. / This invite has already been accepted. / Some of the images to merge changed. Refresh and try again. / This address already has an account. / content_type_key_taken / content_type_has_entries / content_type_referenced / schema_incompatible / invalid_entry: <reason> / slug_taken |
|
||||
| 422 | invalid_entry: <reason> |
|
||||
| 429 | Too many requests. Please try again later. |
|
||||
| 500 | Internal server error |
|
||||
And a CodeSnippet with the single tab "Error response · text/plain" shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
|
||||
|
||||
Loaded 100 of 633 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user