79 changed files with 10668 additions and 441 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
# DB
*.db
data/
/data/
# Build
bin
+18 -2
View File
@@ -1,6 +1,7 @@
package api
import (
domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
@@ -18,9 +19,11 @@ import (
"crypto/ed25519"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/glebarez/sqlite"
@@ -29,6 +32,8 @@ import (
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
@@ -63,6 +68,10 @@ type UseCases struct {
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
}
type Repositories struct {
@@ -70,6 +79,7 @@ type Repositories struct {
UserRepo domainUser.Repository
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
@@ -106,7 +116,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
panic("Failed to migrate database: " + err.Error())
}
@@ -136,6 +146,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
@@ -154,6 +165,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
@@ -168,7 +180,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
@@ -190,6 +202,10 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
}
}
+176
View File
@@ -0,0 +1,176 @@
package apikey
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateAPIKeyUseCase
ListUseCase *useCases.ListAPIKeysUseCase
RevokeUseCase *useCases.RevokeAPIKeyUseCase
}
type CreateAPIKeyRequest struct {
Name string `json:"name" validate:"required,max=60"`
ExpiresAt *time.Time `json:"expires_at"`
}
type createdKeyResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Key string `json:"key"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
type keyListItem struct {
ID uint32 `json:"id"`
UserID uint32 `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Status domain.Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedByName *string `json:"created_by_name,omitempty"`
}
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, RevokeUseCase: revoke}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request CreateAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{
UserID: userId,
Name: request.Name,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
if errors.Is(err, domain.ErrExpiryNotInFuture) {
http.Error(w, apperr.APIKeyExpiryErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(createdKeyResponse{
ID: created.Key.ID,
Name: created.Key.Name,
Prefix: created.Key.Prefix,
Key: created.RawKey,
CreatedAt: created.Key.CreatedAt,
ExpiresAt: created.Key.ExpiresAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(body)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
entries, err := h.ListUseCase.ListAPIKeys(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]keyListItem, 0, len(entries))
for _, entry := range entries {
key := entry.Key
item := keyListItem{
ID: key.ID,
UserID: key.UserID,
Name: key.Name,
Prefix: key.Prefix,
Status: entry.Status,
CreatedAt: key.CreatedAt,
LastUsedAt: key.LastUsedAt,
ExpiresAt: key.ExpiresAt,
RevokedAt: key.RevokedAt,
}
if entry.ListsCreator {
item.CreatedByName = &entry.CreatorName
}
items = append(items, item)
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil {
switch {
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
+100
View File
@@ -0,0 +1,100 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
const bearerScheme = "bearer "
type apiKeyIdContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
return "", false
}
credential := strings.TrimSpace(header[len(bearerScheme):])
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
}
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
return id, ok
}
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey {
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
return func(next http.Handler) http.Handler {
limited := limiter(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawKey, isKey := bearerAPIKey(r)
if !isKey {
next.ServeHTTP(w, r)
return
}
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
if err != nil {
writeAPIKeyError(w, err)
return
}
token, err := sessionTokenFor(authenticated)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
limited.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func writeAPIKeyError(w http.ResponseWriter, err error) {
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
token := jwt.New()
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
return nil, err
}
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
}
func apiKeyRateLimitKey(r *http.Request) string {
id, _ := apiKeyIdFrom(r)
return strconv.FormatUint(uint64(id), 10)
}
+3
View File
@@ -35,3 +35,6 @@ const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
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 SessionRequiredErrorMessage = "This action needs a real session."
+43 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend"
@@ -33,8 +34,21 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(msg)
}
const (
apiKeyMaxRequests = 120
apiKeyWindow = time.Minute
)
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
c.apiKeyAuthOnce.Do(func() {
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
})
return c.apiKeyAuthMiddleware
}
func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
r.Use(container.apiKeyAuth())
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
@@ -85,11 +99,18 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me)
})
return r
@@ -102,6 +123,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -124,6 +146,7 @@ func NewSettingsRouter(container *UseCases) http.Handler {
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -143,6 +166,7 @@ func NewSystemRouter(container *UseCases) http.Handler {
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
@@ -165,12 +189,29 @@ func NewSystemRouter(container *UseCases) http.Handler {
return r
}
func NewAPIKeyRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
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)
return r
}
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -198,6 +239,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/api-key", NewAPIKeyRouter(container))
r.Mount("/system", NewSystemRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
@@ -225,7 +267,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
+39
View File
@@ -0,0 +1,39 @@
# API reference
The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI.
## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
articles only.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once.
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
or revoked key answers 401, even on public routes.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
## Routes a key can call (20)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Settings | GET /v1/settings |
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
## 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.
## 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.
+23
View File
@@ -0,0 +1,23 @@
# API keys
Developers can authenticate against `/v1` with a named API key sent as `Authorization: Bearer rcms_...` instead of the `jwt` cookie. A key acts with exactly the rights of the user who created it.
## Managing keys
Key management needs a real session (the `jwt` cookie from a verified user).
- `POST /v1/api-key` with `{"name": "ci", "expires_at": "<RFC3339, optional>"}` answers `201` with `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`. The full `key` is shown once and never again.
- `GET /v1/api-key` lists the caller's keys (all keys for the owner), newest first, with a `status` of `active`, `expired` or `revoked`.
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`. Revoking twice keeps the first `revoked_at`.
Only the SHA-256 hash and a 12 character prefix are stored.
## Where a key is accepted
`/v1/article` (public and protected routes), `/v1/user`, `/v1/media`, `/v1/settings` and `GET /v1/auth/me`. An invalid, expired or revoked `rcms_` key answers `401` and is never treated as anonymous. Each key is limited to 120 requests per minute (`429` with `Retry-After`). `last_used_at` is updated at most once per minute.
## Session-only routes
`/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and every `/v1/system` route answer `403 This action needs a real session.` to any `rcms_` Bearer header. The public auth routes ignore the header.
A successful password reset revokes every key of that user.
+3 -1
View File
@@ -31,7 +31,8 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
@@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
## Other boards
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
+6 -1
View File
@@ -30,7 +30,12 @@
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
+1
View File
@@ -17,6 +17,7 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
| `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
@@ -0,0 +1,58 @@
# Integration: API keys
Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`.
- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
## API
`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session."
- `POST /v1/api-key` body: `name` (string, required, max 60), `expires_at` (RFC3339 string, optional, in the future). A past date answers 400 "The expiry must be a date in the future."
- `DELETE /v1/api-key/{id}`: unknown id answers 404 "API key not found".
## Placement
Under Integration, as a sub-page linked from the Authentication section ("Manage API keys") and the sidebar item. Keys exist to call the API and admins can create them; Settings is owner-only today. The mobile top bar has a back arrow that returns to Integration.
## Content
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only.
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
- Note: "Resetting a user's password revokes all of their keys."
## Mobile: pinned Create key
On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card).
## States
Names are matched exactly by the visual-check script.
### Owner: list
### Admin: own keys only
### Loading
the list request is in flight. Skeleton rows appear only after 150 ms.
### Empty
### Error
a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
### Create key
### Create key: validation
### Create key: name too long
### One-time reveal
### One-time reveal: copied
### Revoke: confirm
### Revoked
## Component inventory (Nuxt UI v4)
`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
+72 -12
View File
@@ -1,22 +1,82 @@
# Connect your website
# Integration (API reference)
- **Route:** `/integration`
- **API:** none (static)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
Written for a developer. The base URL is window.location.origin.
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
## Navigation decision
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
## Page copy
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced).
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
## EndpointCard states
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## States
Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script.
### Default
### 1. Default
**Trigger:** Opened from the sidebar or the Home card.
### 2. Group scrolled, one card expanded
- Breadcrumbs: Integration (mobile title "Integration")
### 3a. Copied: base URL
### Copied
### 3b. Copied: CodeBlock
**Trigger:** The base URL was copied. The button reads "Copied" for 2 s and this is announced.
### 4. Authentication
- Breadcrumbs: Integration (mobile title "Integration")
- copied: true
### 5. Session only
### 6. Errors
### 7. Navigation
## Error messages
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future."
- 401: "You must be logged in to access this resource."
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error"
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required).
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
- `PATCH /v1/user/me`: `display_name` (string, required, max 60 characters).
- `POST /v1/user/invite`: `email` (string, required, valid email).
- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`.
## Component inventory and Nuxt UI v4
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock.
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
## Assumptions to confirm
- Paths not in the brief: `/v1/settings`, `/v1/system/update/*`, `POST /v1/auth/register|verify|logout`.
- [to confirm] The multipart field name on `POST /v1/media/` is written as `file` as a placeholder.
- Response field names in the samples (for example `articlesUpdated`, `display_name` in the profile response) are illustrative.
- Code samples use a simple keyword and string colouring.
## Tokens
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
+94
View File
@@ -0,0 +1,94 @@
<!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="flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;padding:{{ pad }};position:relative;display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="color:var(--ui-text-muted)">Integration › API keys</span><span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API keys</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ sub }}</span></div>
<sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if>
</div>
<sc-if value="{{ err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="flex:1"><strong>Couldn't load your API keys</strong><br>Check your connection and try again.</span><span style="font-weight:500;color:var(--ui-text-highlighted)">Try again</span></div></sc-if>
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
<sc-if value="{{ showList }}">
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
</div></sc-for></div></sc-if>
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
</div></sc-for></div></sc-if>
</sc-if>
<div style="display:flex;flex-direction:column;gap:8px;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;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">Resetting a user's password revokes all of their keys.</span></div>
</div>
<sc-if value="{{ pinned }}"><div style="position:absolute;left:0;right:0;bottom:0;z-index:40;padding:12px 16px;background:var(--ui-bg);border-top:1px solid var(--ui-border)"><span style="height:44px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></div></sc-if>
<sc-if value="{{ toast }}"><div role="status" style="position:absolute;{{ toastPos }};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);color:var(--ui-success)"><span style="display:flex;flex-direction:column"><strong>Key revoked</strong><span style="color:var(--ui-text-toned)">"Nightly media import" can no longer be used.</span></span></div></sc-if>
</div>
<sc-if value="{{ modal }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ mAlign }};justify-content:center;padding:{{ mPad }}">
<div role="dialog" aria-modal="true" style="width:{{ mW }};max-width:100%;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-modal);padding:20px;display:flex;flex-direction:column;gap:14px;color:var(--ui-text)">
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ m.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ m.desc }}</span></div>
<sc-if value="{{ m.form }}">
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">Name</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ m.cntFg }}">{{ m.count }}</span></div><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.nameBd }};background:var(--ui-bg);color:var(--ui-text-highlighted)">{{ m.name }}</span><span style="color:{{ m.nameFg }}">{{ m.nameHelp }}</span></div>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Expiry date <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></span><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.expBd }};background:var(--ui-bg);color:{{ m.expTx }}">{{ m.exp }}</span><span style="color:{{ m.expFg }}">{{ m.expHelp }}</span></div>
</sc-if>
<sc-if value="{{ m.reveal }}">
<div style="display:flex;gap:10px;padding:10px 12px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span></span><span style="font-weight:500">You won't be able to see this key again.</span></div>
<div style="display:flex;flex-direction:{{ keyDir }};border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated)"><span style="flex:1;min-width:0;padding:10px 12px;font:400 12px/20px 'IBM Plex Mono',monospace;word-break:break-all;color:var(--ui-text-highlighted)">rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe</span><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ m.cpFg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat"></span>{{ m.cpT }}</span></div>
<span style="color:var(--ui-text-muted)">Closing this window discards the key from the screen. Copy it first.</span>
</sc-if>
<div style="display:flex;flex-direction:{{ btnDir }};justify-content:flex-end;gap:8px"><sc-if value="{{ m.cancel }}"><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ m.cancel }}</span></sc-if><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;font-weight:500;background:{{ m.cbg }};color:{{ m.cfg }};opacity:{{ m.cop }}">{{ m.confirm }}</span></div>
</div></div></sc-if>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
renderVals() {
const mob = !!this.props.mobile, k = (this.props.s && this.props.s.ak) || {}
const owner = k.view !== 'admin'
const R = (name, prefix, status, by, created, used, exp) => {
const rv = status === 'revoked', tone = { active: ['var(--rc-success-soft)', 'var(--ui-success)'], expired: ['var(--rc-warning-soft)', 'var(--ui-warning)'], revoked: ['var(--ui-bg-elevated)', 'var(--ui-text-muted)'] }[status]
return { name, prefix, status, by, created, used, exp, sbg: tone[0], sfg: tone[1], bg: rv ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', fg: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)', fg2: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', canRevoke: !rv }
}
let all = owner
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
const st = k.state || 'list'
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
const cp = { reveal: true }
const b = (o) => ({ cancel: 'Cancel', cbg: 'var(--ui-primary)', cfg: 'var(--ui-bg)', cop: '1', ...o })
const form = (o) => b({ form: true, title: 'Create API key', desc: 'The key acts as you, with your role. It is shown in full once.', confirm: 'Create key', name: 'Nightly media import', count: '20 / 60', cntFg: 'var(--ui-text-muted)', nameBd: 'var(--ui-border-accented)', nameFg: 'var(--ui-text-muted)', nameHelp: 'Required. Up to 60 characters.', exp: '31 Dec 2026', expTx: 'var(--ui-text-highlighted)', expBd: 'var(--ui-border-accented)', expFg: 'var(--ui-text-muted)', expHelp: 'Optional. Must be in the future. Leave it empty for a key that does not expire.', ...o })
const M = {
create: form({}),
invalid: form({ name: '', count: '0 / 60', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Enter a name.', exp: '12 Mar 2026', expBd: 'var(--ui-error)', expFg: 'var(--ui-error)', expHelp: 'Choose a date in the future.' }),
toolong: form({ name: 'Nightly import of the new media library from the Brussels office file server', count: '75 / 60', cntFg: 'var(--ui-error)', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Use 60 characters or fewer.' }),
reveal: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copy', cpIcon: 'copy', cpFg: 'var(--ui-text-highlighted)' }),
copied: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copied', cpIcon: 'check', cpFg: 'var(--ui-success)' }),
revoke: b({ title: 'Revoke this key?', desc: '"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.', cancel: 'Keep key', confirm: 'Revoke key', cbg: 'var(--ui-error)', cfg: 'var(--ui-bg)' })
}
const m = k.modal ? M[k.modal] : null
return {
mob, desk: !mob, owner, rows, pad: mob ? '20px 16px 84px' : '32px', pinned: mob && !m, h1: mob ? '20px/28px' : '28px/36px',
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
modal: !!m, m: m || {}, mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '16px' : '120px 16px 16px', mW: mob ? '100%' : '480px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
}
}
}
</script>
</body>
</html>
+3 -20
View File
@@ -177,25 +177,8 @@
</sc-if>
</sc-if>
<sc-if value="{{ isInteg }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px;max-width:860px;overflow:hidden">
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted)">A guide for the developer who builds your public site.</span></div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.</p>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span><div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span><span data-act="copy" style="height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ copyFg }}"><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/{{ copyIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat"></span>{{ copyText }}</span></div></div>
<sc-for list="{{ endpoints }}" as="e" hint-placeholder-count="2">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="padding:16px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:500 12px 'IBM Plex Mono',monospace">GET</span><span style="font:500 14px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ e.path }}</span></div><span style="color:var(--ui-text-toned)">{{ e.desc }}</span></div>
<div style="display:flex;gap:20px;padding:0 16px;border-bottom:1px solid var(--ui-border)"><span style="height:36px;display:flex;align-items:center;border-bottom:2px solid {{ e.l1 }};margin-bottom:-1px;font-weight:500;color:var(--ui-text-highlighted)">fetch</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">curl</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">Response</span></div>
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 8px 0 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">JavaScript</span><span style="display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><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/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span>Copy</span></div><pre style="margin:0;padding:14px 16px;overflow:hidden;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text);white-space:pre">{{ e.code }}</pre></div>
</div>
</sc-for>
<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">Anonymous callers only ever get online articles. Drafts stay private until you publish them.</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">Images in <code>body</code> use paths like <code>/static/uploadedImages/&lt;uuid&gt;.png</code>. Prefix them with the base URL before rendering.</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">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
</div>
</div>
</sc-if>
<sc-if value="{{ isInteg }}"><div style="position:relative;flex:1;min-height:0;overflow:hidden"><dc-import name="IntegrationPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
<sc-if value="{{ isKeys }}"><div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden"><dc-import name="ApiKeysPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
<sc-if value="{{ isUsers }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
@@ -442,7 +425,7 @@ class Component extends DCLogic {
desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', isUsers: k === 'users',
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 ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', 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',
+290
View File
@@ -0,0 +1,290 @@
<!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 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>
<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>
</div>
</sc-if>
<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;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>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
<div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span>
<button aria-label="Copy base URL" onClick="{{ copyBase }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ baseCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><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/{{ baseCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ baseCopy.t }}</span></button></div>
</div>
<section data-anchor="auth" aria-labelledby="h-auth" style="display:flex;flex-direction:column;gap:14px">
<h2 id="h-auth" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Authentication</h2>
<div style="display:grid;grid-template-columns:{{ authCols }};gap:12px;align-items:start">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Public</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px">No key</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">No key needed. Two read-only routes, and only online articles are returned.</span>
<div style="display:flex;flex-direction:column;gap:6px;font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)"><span>GET /v1/article/</span><span>GET /v1/article/{id}</span></div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
<div style="border:1px solid var(--ui-border);min-width:0">
<div role="tablist" aria-label="Example language" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ authTabs }}" as="t" hint-placeholder-count="2"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:36px;display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ authLang }}</span><button aria-label="Copy example" onClick="{{ copyAuth }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ authCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ authCopy.t }}</span></button></div>
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ authCode }}</pre></div>
</div>
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
</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">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>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
<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>
<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">
<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>
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-block;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>
</button>
<sc-if value="{{ ep.open }}">
<div style="padding:4px {{ detX }} 16px;display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--ui-border-muted)">
<div style="padding-top:10px;display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ ep.bodyTitle }}</span>
<sc-if value="{{ ep.hasFields }}"><div style="border:1px solid var(--ui-border)">
<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>
<div style="border:1px solid var(--ui-border);min-width:0">
<div role="tablist" aria-label="Example" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ ep.tabs }}" as="t" hint-placeholder-count="3"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:{{ tabH }};display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ ep.lang }}</span><button aria-label="{{ ep.copyLabel }}" onClick="{{ ep.copy }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ ep.cFg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ ep.cT }}</span></button></div>
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ ep.code }}</pre></div>
</div>
<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><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
</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="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">
<div style="border-top:1px dashed var(--ui-border-accented)">
<div style="padding:8px 14px 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ sg.t }}</div>
<sc-for list="{{ sg.rows }}" as="r" hint-placeholder-count="4"><div style="display:{{ rowDisplay }};grid-template-columns:{{ sessCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ sessGap }};padding:{{ sessPad }};min-height:36px"><span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ r.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 13px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ r.p }}</span></span><span style="color:var(--ui-text-muted)">{{ r.d }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;align-self:{{ chipSelf }};border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;white-space:nowrap"><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>Session only</span></div></sc-for>
</div>
</sc-for>
</div>
</section>
<section data-anchor="errors" aria-labelledby="h-err" style="display:flex;flex-direction:column;gap:10px">
<h2 id="h-err" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Errors</h2>
<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></div></sc-for>
</div>
<div style="border:1px solid var(--ui-border);min-width:0"><div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 12px;display:flex;align-items:center;border-bottom:1px solid var(--ui-border);font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">Error response · text/plain</div>
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div>
</section>
<section data-anchor="notes" aria-labelledby="h-notes" style="display:flex;flex-direction:column;gap:10px">
<h2 id="h-notes" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Notes</h2>
<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">Images in <code>body</code> use paths like <code>/static/uploadedImages/&lt;uuid&gt;.png</code>. Prefix them with the base URL before rendering.</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">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</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">Article routes accept <code>?format=markdown</code>, <code>?format=html</code> or <code>?format=json</code> to choose how <code>body</code> is returned. With <code>json</code>, <code>body</code> is the stored JSON structure. Articles have <code>body_format: markdown</code> by default.</span></div>
</div>
</section>
</div>
<sc-if value="{{ desk }}">
<nav aria-label="On this page" style="position:sticky;top:32px;width:168px;flex-shrink:0;display:flex;flex-direction:column;gap:2px;border-left:1px solid var(--ui-border)">
<span style="padding:0 12px 6px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">On this page</span>
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button aria-current="{{ i.cur }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 12px;margin-left:-1px;display:flex;align-items:center;border-left:2px solid {{ i.bd }};color:{{ i.fg }};font-weight:{{ i.fw }}" style-hover="color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}</button></sc-for>
</nav>
</sc-if>
</div>
</div>
</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' } })()
rootRef = React.createRef()
BASE = 'https://cms.renew.example'
KEY = 'rcms_Kf3x9QwL0aBc…'
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
componentDidMount() {
const i = (this.props.s && this.props.s.int) || {}
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
go(); setTimeout(go, 250); setTimeout(go, 900)
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go)
}
scrollTo(a, mob) {
const r = this.rootRef.current; if (!r) return
const el = r.querySelector('[data-anchor="' + a + '"]'); if (!el) return
const off = a.indexOf('ep-') === 0 ? 24 : 0
r.scrollTop = Math.max(0, this.yOf(el, r) - (mob ? 64 : 32) - off)
}
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 }
if (cur !== this.state.active) this.setState({ active: cur })
}
flash(k) {
this.setState({ copied: k }); clearTimeout(this._t); this._t = setTimeout(() => this.setState({ copied: null }), 2000)
}
hl(code) {
const re = /("[^"\n]*"|'[^'\n]*')|\b(const|await|fetch|curl|new)\b|\b(\d+)\b/g
const out = []; let last = 0, m, k = 0
while ((m = re.exec(code))) {
if (m.index > last) out.push(code.slice(last, m.index))
const c = m[1] ? 'var(--ui-success)' : m[2] ? 'var(--ui-info)' : 'var(--ui-warning)'
out.push(React.createElement('span', { key: k++, style: { color: c } }, m[0])); last = m.index + m[0].length
}
out.push(code.slice(last)); return out
}
eps() {
const A = (id, m, p, d, acc, key, errs, fields, x = {}) => ({ id, m, p, d, acc, key, errs: acc === 'Verified user' ? errs.flatMap(e => e[0] === 401 ? [e, e403] : [e]) : errs, fields, ...x })
const S = (n, t, req, rule) => ({ n, t, req, rule })
const e401 = [401, 'You must be logged in to access this resource.']
const e403 = [403, 'You must verify your account to access this resource.']
const e404 = [404, 'Article not found']
const m404 = [404, 'Image not found']
const i404 = [404, 'Invite not found']
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
const j = o => JSON.stringify(o, null, 2)
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. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.' }, r: j({ id: 13, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
] },
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-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' }]) })
] },
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
A('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', 1, [[400, mm], e401], [S('display_name', 'string', 'Yes', 'Up to 60 characters.')], { body: { display_name: 'Marta Alves' }, r: j({ display_name: 'Marta Alves' }) }),
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
] },
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
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 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')
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.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.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,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: 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 } })) } }),
lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)',
copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) },
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
}) }
})
}
renderVals() {
const mob = !!this.props.mobile, st = this.state
const code = {
curl: 'curl "' + this.BASE + '/v1/auth/me" \\\n -H "Authorization: Bearer ' + this.KEY + '"',
fetch: "const res = await fetch('" + this.BASE + "/v1/auth/me', {\n headers: { Authorization: 'Bearer " + this.KEY + "' }\n})\nconst me = await res.json()"
}[st.authTab]
const cp = k => ({ t: st.copied === k ? 'Copied' : 'Copy', icon: st.copied === k ? 'check' : 'copy', fg: st.copied === k ? 'var(--ui-success)' : 'var(--ui-text-toned)' })
const bc = cp('base'); if (st.copied !== 'base') bc.fg = 'var(--ui-text-highlighted)'
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'
return {
rootRef: this.rootRef, onScroll: this.onScroll, 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 ? '20px 16px 96px' : '32px', secGap: mob ? '28px' : '32px', h1: mob ? '20px/28px' : '28px/36px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
baseCopy: bc, copyBase: () => { try { navigator.clipboard && navigator.clipboard.writeText(this.BASE) } catch (x) { } this.flash('base') },
authTabs: ['curl', 'fetch'].map(t => { const on = st.authTab === t; return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState({ authTab: t }) } }),
authLang: st.authTab === 'curl' ? 'Shell' : 'JavaScript', authCode: this.hl(code), authCopy: cp('auth'), copyAuth: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash('auth') },
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',
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."' },
{ 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', 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: 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"' }
],
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
}
}
}
</script>
</body>
</html>
@@ -0,0 +1,106 @@
<!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&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: API keys</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">The screen the Authentication section of <a href="RenewCMS Screens Back-office.dc.html">the Integration screen</a> links to. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
</div>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
</div>
</sc-for>
</div>
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() {
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
return this.state.mode
}
apply() {
const el = this.rootRef.current
if (!el) return
const vars = this.resolved() === 'dark' ? this.dark : this.light
for (const k in vars) el.style.setProperty(k, vars[k])
document.body.style.background = vars['--rc-canvas']
}
componentDidMount() { this.apply() }
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() {
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
return this.finish([
{ label: 'API keys', title: 'API keys', route: '/integration/api-keys', api: 'GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} (session only)', note: 'Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.', states: [
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Owner: list", trigger: "Owners see every user's keys and a \"Created by\" column. Expired keys keep their Revoke action.", ak: {"view":"owner"} }, { ak: {"view":"owner"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Admin: own keys only", trigger: "Admins see only the keys they created, with no \"Created by\" column.", ak: {"view":"admin"} }, { ak: {"view":"admin"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Loading", trigger: "The list request is in flight. Skeleton rows appear after 150 ms.", ak: {"state":"loading"} }, { ak: {"state":"loading"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Empty", trigger: "No keys yet.", ak: {"state":"empty"} }, { ak: {"state":"empty"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Error", trigger: "A 500 or network failure on the list request.", ak: {"state":"error"} }, { ak: {"state":"error"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key", trigger: "\"Create key\" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional.", ak: {"modal":"create"} }, { ak: {"modal":"create"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: validation", trigger: "On submit or blur. Nothing is sent. The name is empty and the expiry is in the past.", ak: {"modal":"invalid"} }, { ak: {"modal":"invalid"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: name too long", trigger: "Over 60 characters. Checked as you type.", ak: {"modal":"toolong"} }, { ak: {"modal":"toolong"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal", trigger: "201: the full key is shown once. Closing the dialog discards it from the screen.", ak: {"modal":"reveal"} }, { ak: {"modal":"reveal"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal: copied", trigger: "Copy was pressed. The button reads \"Copied\" for 2 s and this is announced.", ak: {"modal":"copied"} }, { ak: {"modal":"copied"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoke: confirm", trigger: "Revoke on an active or expired row.", ak: {"modal":"revoke"} }, { ak: {"modal":"revoke"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoked", trigger: "204: the key stays in the list, greyed, with no Revoke action. A toast confirms.", ak: {"revoked":1,"toast":1} }, { ak: {"revoked":1,"toast":1} })
] }
])
}
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
const on = this.state.mode === k
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
})
return { rootRef: this.rootRef, modes, groups: this.groups() }
}
}
</script>
</body>
</html>
@@ -265,9 +265,15 @@ class Component extends DCLogic {
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
] },
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
{ label: 'Integration', title: 'Integration: API reference', route: '/integration', api: 'none (static). Replaces "Connect your website".', note: 'Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.', states: [
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "1. Default", trigger: "Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the \"Jump to\" select above the content.", int: {} }, { int: {} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "2. Group scrolled, one card expanded", trigger: "POST /v1/media/ opened in the Media group: multipart body with 1 file field, curl tab shown. Only one example block is visible per card. Other cards stay collapsed.", int: {"scroll":"media","open":"med-up","tab":"curl"} }, { int: {"scroll":"ep-med-up","open":"med-up","tab":"curl"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3a. Copied: base URL", trigger: "The base URL was copied. The button reads \"Copied\" for 2 s and this is announced.", int: {"copied":"base"} }, { int: {"copied":"base"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "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.", 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} })
] },
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
@@ -288,7 +294,7 @@ class Component extends DCLogic {
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
] },
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
+20 -2
View File
@@ -134,8 +134,26 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) |
| Integration | `/integration` | Group scrolled, one card expanded | A card opened, one example visible | [integration](screens/integration.md) |
| Integration | `/integration` | Copied: base URL | Copy clicked (2 s) | [integration](screens/integration.md) |
| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) |
| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) |
| Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) |
| Integration | `/integration` | Errors | Status table and plain-text error shape | [integration](screens/integration.md) |
| Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) |
| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Loading | First load or retry | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Empty | No keys yet | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Error | 500 or network | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key | Modal opened | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration-api-keys](screens/integration-api-keys.md) |
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
+1
View File
@@ -30,3 +30,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart |
| `v2.6.2-beta` | v2.6.2-beta - English and French interface |
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
| `v2.8.0-beta` | v2.8.0-beta - API keys and API reference |
+41
View File
@@ -0,0 +1,41 @@
This release adds API keys, so a script or service can use the whole API without the web interface, and turns the Integration page into a complete API reference.
## New
**API keys**
- Create named API keys from the new API keys screen under Integration, with an optional expiry date. The full key is shown once, when you create it, and only its prefix is visible afterwards.
- Send a key as `Authorization: Bearer rcms_...` to manage articles, media, users and invites, and to read settings. A key acts with exactly the rights of the user who created it, so an admin's key cannot do owner-only things.
- Revoke a key at any time. Active, expired and revoked keys all stay listed with their status, creation date and last use.
- The owner sees every key and who created it. Admins see only their own.
- Each key is limited to 120 requests per minute.
- Resetting a user's password revokes all of that user's keys.
**API reference**
- The Integration page now documents every route a developer can call, grouped by area: articles, media, users and invites, and settings. Each endpoint lists who can call it, its request body, ready-to-copy fetch and curl examples, a sample response and its error statuses.
- A new Authentication section explains public access and API keys, and a section index lets you jump between groups.
- Routes that need a signed-in browser session are listed separately, and the page shows the real error messages the API returns.
- The Integration page and the API keys screen are available in English and French.
## API
- `POST /v1/api-key` creates a key. It answers `201` with the key, shown once.
- `GET /v1/api-key` lists keys, newest first, each with a `status` of `active`, `expired` or `revoked`. For the owner, each entry also carries `created_by_name`.
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`, also when the key is already revoked.
- An invalid, unknown, expired or revoked key answers `401` on every route, including public ones, and is never treated as anonymous.
- A key calling a session-only route answers `403` with "This action needs a real session." These routes are `/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and everything under `/v1/system`.
- `GET /v1/auth/me` accepts an API key, so a client can check who a key belongs to.
- A key beyond 120 requests per minute gets `429` with a `Retry-After` header.
- Cross-origin requests now allow the `PATCH` method.
## Under the hood
- Add the API key domain, use cases, SQLite repository and an authentication middleware that runs after each token verifier. Keys are generated by a new domain interface and stored as SHA-256 hashes, never in clear.
- Add a typed API catalogue that drives the Integration page, with a test that every route has a description.
- Add the API keys and Integration specifications and QA procedures to `features/`, and the API key reference to `docs/api/`.
## Upgrading
- Replace the binary. The new API keys table is created automatically at startup. No new environment variable is required.
- Browsers calling the API from another origin can now use `PATCH`. Check your CORS configuration only if you restricted methods in a reverse proxy.
+457
View File
@@ -0,0 +1,457 @@
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
As a developer integrating with RenewCMS
I want to create named API keys and send them as a Bearer token
So that I can manage articles, media and users without the UI, with exactly the rights of the
user who created the key and never more
Background:
Given internal/domain/apikey defines the entity APIKey with ID uint32, UserID uint32, Name,
Prefix, HashedKey, ExpiresAt *time.Time, RevokedAt *time.Time, LastUsedAt *time.Time and
CreatedAt, with snake_case json tags
And internal/domain/apikey defines the sentinels apikey.ErrAPIKeyNotFound,
apikey.ErrAPIKeyInvalid and apikey.ErrExpiryNotInFuture
And apikey.ErrAPIKeyInvalid covers an unknown, malformed, expired or revoked key and a key
whose user is deleted or unverified
And internal/domain/apikey defines the interface Repository with Create, Get(id),
GetByHashedKey(hashedKey), ListByUser(userId), ListAll(), Revoke(id, at),
RevokeAllByUser(userId, at) and TouchLastUsed(id, at)
And RevokeAllByUser sets RevokedAt to at on every key of that user that is not yet revoked and
leaves the RevokedAt of already revoked keys and every other user's keys untouched
And internal/domain/apikey defines the interface KeyGenerator with Generate() returning the
raw key, its display prefix and its SHA-256 hash, and Hash(rawKey) returning the SHA-256 hash
And internal/infrastructure/hashing implements KeyGenerator, asserted with
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
And a generated raw key is "rcms_" followed by the base64url encoding, without padding, of 32
random bytes, so it matches ^rcms_[A-Za-z0-9_-]{43}$
And the display prefix is the first 12 characters of the raw key
And only the SHA-256 hash and the prefix are persisted, never the raw key
And the use cases CreateAPIKey, ListAPIKeys, RevokeAPIKey and AuthenticateAPIKey depend only
on apikey.Repository, apikey.KeyGenerator, user.Repository and an injected clock
And ResetPasswordUseCase additionally receives apikey.Repository and an injected clock, and
after a successful password reset calls RevokeAllByUser(userId, now); it never reaches
persistence directly
And persistence has a GORM model, a mapper and a repository for the entity, the model is
added to the AutoMigrate list in api/dependecyInjection.go, and wiring happens only in
api/dependecyInjection.go
And a new middleware resolves "Authorization: Bearer rcms_<...>" to a user and places a jwt in
the same jwtauth request context the cookie path fills, with the claims user_id and
token_version taken from the user's current record
And that middleware runs after jwtauth.Verifier and ahead of jwtauth.Authenticator on the
/article routes (public and protected), /user, /media, /settings and GET /auth/me
And jwtauth.Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireOwner and every
handler stay unchanged
And a new middleware RequireSession, mounted ahead of jwtauth.Verifier, answers 403 with
apperr.SessionRequiredErrorMessage whenever the Authorization header is a Bearer token that
starts with "rcms_", whether that key is valid, expired, revoked, unknown or garbage
And RequireSession is mounted on the key management routes POST /v1/api-key, GET /v1/api-key
and DELETE /v1/api-key/{id}, on the session-only auth routes POST /v1/auth/verify and
DELETE /v1/auth/register, and on every /v1/system route (all under /v1/system/update)
And a request on those routes without a "Bearer rcms_" header passes through RequireSession
unchanged, so cookie-only and jwt-Bearer behaviour there does not change
And the public auth routes login, register, registration, logout, password-reset,
password-reset/confirm, invite and invite/accept do not use the key middleware or
RequireSession and behave exactly as they do without a key header
And the routes are mounted under /v1 inside the jwtauth.Verifier, jwtauth.Authenticator,
RequireCurrentToken and RequireVerifiedUser group:
POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id}
And a Bearer key on a key-accepting route that cannot authenticate gets 401 with
apperr.UnauthorizedErrorMessage, and a request without an rcms_ key keeps the existing
jwtauth.Authenticator response, status 401, unchanged by this feature
And new apperr strings: APIKeyNotFoundErrorMessage = "API key not found",
APIKeyExpiryErrorMessage = "The expiry must be a date in the future." and
SessionRequiredErrorMessage = "This action needs a real session."
And the per-key rate limit is 120 requests per minute, as constants in api/router.go
And enableCorsIfConfigured allows the methods GET, POST, PUT, PATCH, DELETE and OPTIONS
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), admin id 3 "pat"
(verified), unverified id 4 "quinn"
Scenario: Creating a key returns the full key exactly once
Given a valid jwt cookie for admin 2
When the client sends POST /v1/api-key with body {"name": "ci-deploy"}
Then the response status is 201
And the header Cache-Control is "no-store"
And the body has exactly the keys id, name, prefix, key, created_at, expires_at
And name is "ci-deploy", expires_at is null, and key matches ^rcms_[A-Za-z0-9_-]{43}$
And prefix equals the first 12 characters of key
And the stored record has user_id 2 and HashedKey equal to the SHA-256 of key
And neither the raw key nor the stored hash appears in any later response
Scenario: The key can't be retrieved afterwards
Given admin 2 created the key "ci-deploy" and holds its raw key
When the same jwt cookie sends GET /v1/api-key
Then the response status is 200
And the body is a JSON array with one entry whose keys are exactly id, user_id, name, prefix,
status, created_at, last_used_at, expires_at, revoked_at
And the entry has no "key" and no "hashed_key" field
And the raw key and its hash appear nowhere in the body
And the entry's prefix is the first 12 characters of the raw key
Scenario: Creating a key with an expiry
Given a valid jwt cookie for admin 2
When the client sends POST /v1/api-key with body
{"name": "temp", "expires_at": "<now + 30 days, RFC3339>"}
Then the response status is 201
And expires_at in the body equals the requested instant
Scenario Outline: Creating a key with an invalid body is refused and creates nothing
Given a valid jwt cookie for admin 2
When the client sends POST /v1/api-key with <body>
Then the response status is 400
And the response body is <message>
And no key exists in the repository
Examples:
| body | message |
| body not-json | apperr.BodyErrorMessage |
| body {} | text containing 'Name' and 'required' |
| body {"name": ""} | text containing 'Name' and 'required' |
| body {"name": "<61 characters>"} | text containing 'Name' and 'max' |
| body {"name": "x", "expires_at": "yesterday"} | apperr.BodyErrorMessage |
| body {"name": "x", "expires_at": "<now - 1h>"} | apperr.APIKeyExpiryErrorMessage |
Scenario: A request body can't choose the owner or the role of a new key
Given a valid jwt cookie for admin 2
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
Scenario Outline: Key management needs a verified real session
Given <caller>
When the client sends POST /v1/api-key with body {"name": "x"}
Then the response status is <status>
And the response body is <message>
And no key exists in the repository
Examples:
| caller | status | message |
| no credentials | 401 | the existing Authenticator response |
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
| a stale token_version cookie | 401 | the existing Authenticator response |
Scenario Outline: A valid key acts as its creator on every key-accepting route
Given an active key created by admin 2
When the client sends <method> <path> with "Authorization: Bearer <that key>" and no cookie <body>
Then the response status is <status>
And the effect is attributed to user 2
Examples:
| method | path | body | status |
| GET | /v1/settings | | 200 |
| GET | /v1/user | | 200 |
| PATCH | /v1/user/me | and body {"display_name": "Dev"} | 200 |
| POST | /v1/article | and body {"title": "A valid title", "body": "x"} | 201 |
| PATCH | /v1/article/1/publish | | 200 |
| DELETE | /v1/article/1 | | 204 |
| GET | /v1/media | | 200 |
| DELETE | /v1/media/1 | | 204 |
Scenario: A key can read its creator through GET /v1/auth/me
Given an active key created by admin 2
When the client sends GET /v1/auth/me with that key and no cookie
Then the response status is 200
And the body is the same JSON the jwt cookie of admin 2 gets on GET /v1/auth/me
And the body describes user 2 "dev"
Scenario: A key that can't authenticate gets 401 on GET /v1/auth/me
When the client sends GET /v1/auth/me with "Authorization: Bearer rcms_unknown"
Then the response status is 401
And the response body is apperr.UnauthorizedErrorMessage
Scenario: A key sees offline articles on the public article routes, an anonymous caller does not
Given an offline article 5 and an online article 6
And an active key created by admin 2
When the client sends GET /v1/article with that key
Then the body lists articles 5 and 6
When the client sends GET /v1/article with no credentials
Then the body lists only article 6
When the client sends GET /v1/article/5 with that key
Then the response status is 200
When the client sends GET /v1/article/5 with no credentials
Then the response status is 404
Scenario: An invalid key on a public article route is refused, not treated as anonymous
When the client sends GET /v1/article with "Authorization: Bearer rcms_unknown"
Then the response status is 401
And the response body is apperr.UnauthorizedErrorMessage
Scenario: An owner's key can do owner-only things
Given an active key created by owner 1
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
Then the response status is 201
And the invite's invited_by is 1
Scenario: An admin's key can not do owner-only things
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 no invite exists
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
Given <state>
When the client sends GET /v1/settings with "Authorization: Bearer <credential>"
Then the response status is 401
And the response body is apperr.UnauthorizedErrorMessage
And no handler or use case behind the route runs
Examples:
| state | credential |
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 |
| a key whose expires_at is one second ago | that key |
| a key that was revoked | that key |
| a key of user 1 whose user record was deleted | that key |
| a key of an unverified user | that key |
| nothing after the prefix | rcms_ |
| a key with one character changed | that key, last character changed |
| a key's hash passed instead of the key | the stored HashedKey, status 401 only |
Scenario: Key behaviour does not change the jwt cookie path
Given a valid jwt cookie for admin 2
When the client sends GET /v1/settings with only that cookie
Then the response status is 200
When the client sends GET /v1/settings with an Authorization header "Bearer <a valid jwt>" and no cookie
Then the response status is 200
When the client sends GET /v1/settings with a stale token_version cookie
Then the response status is 401
Scenario: A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie
Given an active key created by admin 3 and a valid jwt cookie for owner 1
When the client sends POST /v1/user/invite with both the key and the cookie
Then the response status is 403
When the client sends GET /v1/settings with "Authorization: Bearer rcms_unknown" and the owner cookie
Then the response status is 401
Scenario Outline: Session-only routes refuse any rcms_ credential with 403
Given <state>
When the client sends <method> <path> with "Authorization: Bearer <credential>" <body>
Then the response status is 403
And the response body is apperr.SessionRequiredErrorMessage
And no use case behind the route runs
And no key was created, listed or revoked
Examples:
| state | credential | method | path | body |
| an active key of owner 1 | that key | POST | /v1/api-key | and body {"name": "child"} |
| an active key of owner 1 | that key | GET | /v1/api-key | |
| an active key of owner 1 | that key | DELETE | /v1/api-key/1 | |
| an active key of owner 1 | that key | POST | /v1/auth/verify | and body {"code": "x"} |
| an active key of owner 1 | that key | DELETE | /v1/auth/register | |
| an active key of owner 1 | that key | GET | /v1/system/update | |
| an active key of owner 1 | that key | POST | /v1/system/update/check | |
| an active key of owner 1 | that key | POST | /v1/system/update/download | |
| an active key of owner 1 | that key | POST | /v1/system/update/restart | |
| a revoked key | that key | GET | /v1/api-key | |
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | POST | /v1/system/update/check | |
| nothing | rcms_ | DELETE | /v1/auth/register | |
Scenario Outline: The session-only routes still work as before with a session
Given a valid jwt cookie for owner 1
When the client sends <method> <path> with only that cookie <body>
Then the response is the same as before this feature
And a request with no credentials still gets status 401 from the existing Authenticator
Examples:
| method | path | body |
| POST | /v1/api-key | and body {"name": "x"} |
| GET | /v1/api-key | |
| GET | /v1/system/update | |
| POST | /v1/system/update/check | |
Scenario Outline: A key never obtains a session through the public auth routes
Given an active key created by owner 1
When the client sends <method> <path> with that key and body <body>
Then the response is the same as without the key
And no jwt cookie is set unless the credentials in the body are valid
And the stored user and password are unchanged
Examples:
| method | path | body |
| POST | /v1/auth/login | {"username": "marta", "password": "wrong-pass-1"} |
| POST | /v1/auth/password-reset/confirm | {"email": "marta@example.com", "code": "nope", "password": "newpassword1"} |
Scenario: A successful password reset revokes every key of that user
Given user 2 has active keys "a" and "b", and each works on GET /v1/settings
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with an unexpired expiration
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"}
Then the response status is 200
And GET /v1/settings with key "a" returns 401 with apperr.UnauthorizedErrorMessage
And GET /v1/settings with key "b" returns 401 with apperr.UnauthorizedErrorMessage
Scenario: A password reset leaves other users' keys untouched
Given user 2 has an active key "a" and user 3 has an active key "c"
And user 2 has a valid password reset code "reset-code-1"
When user 2's password reset is confirmed with "reset-code-1"
Then GET /v1/settings with key "c" returns 200
And key "c" has no revoked_at
Scenario: Keys revoked by a password reset show as revoked
Given user 2 has active keys "a" and "b", and a clock at T
And user 2 has a valid password reset code "reset-code-1"
When user 2's password reset is confirmed with "reset-code-1"
And admin 2 logs in again with the new password and sends GET /v1/api-key with the new cookie
Then the entries "a" and "b" have status "revoked" and revoked_at T
Scenario: A password reset does not move the revoked_at of a key that was already revoked
Given user 2 has a key "old" revoked at T0 and an active key "a"
And user 2 has a valid password reset code "reset-code-1"
When user 2's password reset is confirmed with "reset-code-1" at a later time T
Then key "old" still has revoked_at T0
And key "a" has revoked_at T
Scenario Outline: A failed password reset confirmation revokes nothing
Given user 2 has an active key "a"
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with <expiration>
When the client sends POST /v1/auth/password-reset/confirm with <body>
Then the response status is not 200
And key "a" has no revoked_at
And GET /v1/settings with key "a" returns 200
Examples:
| expiration | body |
| an unexpired expiration | {"email": "dev@example.com", "code": "wrong", "password": "newpassword1"} |
| an expiration in the past | {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} |
| an unexpired expiration | {"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"} |
Scenario: Listing as an admin shows only that admin's keys
Given keys "a" and "b" created by admin 2 and key "c" created by admin 3
When admin 2 sends GET /v1/api-key with a jwt cookie
Then the body lists exactly "a" and "b"
Scenario: Listing as the owner shows every user's keys, with the creator's id
Given the same three keys and key "d" created by owner 1
When owner 1 sends GET /v1/api-key with a jwt cookie
Then the body lists "a", "b", "c" and "d" newest first
And each entry has the user_id of its creator
Scenario Outline: The list reports each key's state
Given a key that is <state>
When its creator sends GET /v1/api-key with a jwt cookie
Then the entry's status is "<status>", revoked_at is <revoked_at>, and last_used_at is <last_used>
Examples:
| state | status | revoked_at | last_used |
| active, never used | active | null | null |
| active, used 5 minutes ago | active | null | that time |
| expired one hour ago | expired | null | unchanged |
| revoked | revoked | a time | unchanged |
| both expired and revoked | revoked | a time | unchanged |
Scenario: Revoking a key ends it immediately
Given an active key "ci-deploy" created by admin 2
And the key works on GET /v1/settings
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
Then the response status is 204 and the body is empty
And GET /v1/settings with that key returns 401 with apperr.UnauthorizedErrorMessage
And GET /v1/api-key lists it with status "revoked"
Scenario: Revoking twice is idempotent
Given a key revoked at time T
When its creator sends DELETE /v1/api-key/<its id> again
Then the response status is 204
And revoked_at is still T
Scenario: An admin can't revoke someone else's key, and learns nothing about it
Given a key created by admin 3
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
Then the response status is 404
And the response body is apperr.APIKeyNotFoundErrorMessage
And the key still works
Scenario: The owner can revoke any key
Given a key created by admin 3
When owner 1 sends DELETE /v1/api-key/<its id> with a jwt cookie
Then the response status is 204
And the key gets 401 on GET /v1/settings
Scenario Outline: Revoking with a bad id
Given a valid jwt cookie for admin 2
When the client sends DELETE /v1/api-key/<id>
Then the response status is <status>
And the response body is <message>
Examples:
| id | status | message |
| abc | 400 | apperr.IdUint32ErrorMessage |
| -1 | 400 | apperr.IdUint32ErrorMessage |
| 4294967296 | 400 | apperr.IdUint32ErrorMessage |
| 999 | 404 | apperr.APIKeyNotFoundErrorMessage |
Scenario: Using a key records last_used_at, at most once per minute
Given an active key never used, and a fake clock at T
When the key authenticates a request at T
Then last_used_at is T
When it authenticates again at T + 30s
Then no write happened and last_used_at is still T
When it authenticates again at T + 61s
Then last_used_at is T + 61s
Scenario: A failure to record last_used_at doesn't fail the request
Given an active key and a repository whose TouchLastUsed returns an error
When the client sends GET /v1/settings with that key
Then the response status is 200
Scenario: A rejected key never records last_used_at
Given an expired key and a revoked key
When each is used on GET /v1/settings
Then each gets 401 and no last_used_at is written
Scenario: Each key has its own rate limit
Given two active keys "a" and "b" and a limit of N requests per minute per key
When key "a" sends N+1 requests to GET /v1/settings
Then request N+1 gets 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header
And key "b" still gets 200
And a jwt cookie request still gets 200
Scenario: The rate limit does not apply to unauthenticated requests
When the client sends N+1 requests with "Authorization: Bearer rcms_unknown"
Then every response is 401, never 429
Scenario: The CORS preflight allows PATCH
Given settings with CorsAllowedOrigins ["https://app.example.com"]
When the client sends OPTIONS /v1/article/1 with Origin "https://app.example.com" and
Access-Control-Request-Method "PATCH"
Then the response is a successful preflight
And Access-Control-Allow-Methods includes PATCH
And a preflight for method "TRACE" is not allowed
Scenario: Use case: creating a key hashes it and stores no plaintext
Given a fake KeyGenerator returning raw "rcms_fake", prefix "rcms_fake", hash "hash:rcms_fake"
When CreateAPIKey is called with user 2 and name "ci"
Then the repository's Create receives HashedKey "hash:rcms_fake", Prefix "rcms_fake", UserID 2
And the result carries the raw key, which is not stored
Scenario: Use case: authenticating returns the user and rejects every invalid case with one sentinel
Given a fake KeyGenerator and keys that are unknown, expired, revoked, and owned by a missing or
unverified user
When AuthenticateAPIKey is called with each raw key
Then every call returns apikey.ErrAPIKeyInvalid, asserted with errors.Is
And a key expiring exactly at the clock's now is expired
And a valid key returns the user from user.Repository
Scenario: Use case: a failing repository is not reported as an invalid key
Given a repository whose GetByHashedKey returns an unexpected error
When AuthenticateAPIKey is called
Then the error is returned and errors.Is(err, apikey.ErrAPIKeyInvalid) is false
And the middleware answers 500 "Internal server error"
Scenario: Use case: ResetPassword revokes the user's keys through the domain repository
Given a fake apikey.Repository holding active keys of users 2 and 3, a fake clock at T, and a
user.Repository fake with a valid reset code for user 2
When ResetPasswordUseCase.ResetPassword succeeds for user 2
Then the fake apikey.Repository received exactly one RevokeAllByUser(2, T)
And user 3's keys are untouched
Scenario: Use case: ResetPassword does not call RevokeAllByUser when the reset fails
Given a fake apikey.Repository and a user.Repository fake with a wrong or expired reset code
When ResetPasswordUseCase.ResetPassword is called
Then it returns the existing invalid or expired sentinel
And RevokeAllByUser is never called
Scenario: Use case: a failure to revoke keys after a reset is reported
Given a valid reset code and an apikey.Repository whose RevokeAllByUser returns an error
When ResetPasswordUseCase.ResetPassword is called
Then the use case returns that error
And the password was already changed
+171
View File
@@ -0,0 +1,171 @@
# QA procedure: API keys
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Add in-memory fakes with unique type names across the package: `mockAPIKeyRepo`
(implements `apikey.Repository` including `RevokeAllByUser`, with a `fail` switch per method such as
`failNext("TouchLastUsed")`, a `touchCalls` counter and a `revokeAllCalls` log of `(userId, at)`) and
`stubKeyGenerator` (deterministic raw/prefix/hash). Reuse `mockUserRepo`, `stubHasher` and the
article, media and invite fakes.
3. HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests
use `stubKeyGenerator`.
4. Build the handler stack from a `*api.UseCases` container holding fakes and mount
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). It is the
most faithful way to test the middleware order and the exclusions. If a nil use case breaks a
handler constructor, fall back to a fresh `chi.NewRouter()` that mirrors `api/router.go`:
`jwtauth.Verifier`, the key middleware, `jwtauth.Authenticator`, `RequireCurrentToken`,
`RequireVerifiedUser` (and `RequireOwner` on the invite group) for the key-accepting routes
(including `GET /auth/me`); `RequireSession` ahead of `jwtauth.Verifier` on `/api-key`,
`POST /auth/verify`, `DELETE /auth/register` and `/system`; and the public auth routes with neither.
The test token is `jwtauth.New("HS256", []byte("test-secret"), nil)`.
5. Inject a fake clock (`now func() time.Time`) into the key use cases and `ResetPasswordUseCase`.
6. Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn".
7. Helpers: `cookieFor(userId, tokenVersion)` mints a jwt cookie with
`token.Encode(map[string]any{"user_id": id, "token_version": v})`; `bearer(raw)` sets the header.
Create real keys through `POST /v1/api-key` with the cookie so the shown-once value is genuine.
## POST /v1/api-key
8. Admin 2 cookie, `{"name":"ci-deploy"}` -> `201`, `Cache-Control: no-store`, key set is exactly `id`,
`name`, `prefix`, `key`, `created_at`, `expires_at`; `key` matches `^rcms_[A-Za-z0-9_-]{43}$`; prefix
is `key[:12]`; stored `HashedKey` equals the generator's `Hash(key)`; `UserID` 2; the raw key is
stored nowhere in the fake.
9. Two creations give two different keys and ids.
10. With `expires_at` 30 days ahead -> `201`, echoed instant.
11. Invalid bodies (table): not-json -> `400` `apperr.BodyErrorMessage`; `{}`, `{"name":""}` -> `400`,
body contains `'Name'` and `required`; 61-char name -> `400`, contains `max`; `expires_at`
"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.
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
stale `token_version` -> `401`. Each: repository empty.
## Shown once
14. After case 8, `GET /v1/api-key` -> entry keys exactly `id`, `user_id`, `name`, `prefix`, `status`,
`created_at`, `last_used_at`, `expires_at`, `revoked_at`; assert the response bytes contain neither
the raw key nor the stored hash. Also assert the raw key and hash are absent from the `DELETE`
response and from every error body in this file.
15. There is no route that returns a key again: `GET /v1/api-key/{id}` -> `404` or `405` (whatever the
router yields; assert not `200`).
## Authentication
16. Table over `GET /v1/settings`, `GET /v1/user`, `PATCH /v1/user/me`, `POST /v1/article`,
`PATCH /v1/article/{id}/publish`, `DELETE /v1/article/{id}`, `GET /v1/media`, `DELETE /v1/media/{id}`
with only the Bearer key of admin 2 -> `200` each; the stored side effects (author, display name)
belong to user 2.
17. Public article routes: seed offline article 5, online article 6. Key -> list has both, `GET /5` is
`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.
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
the user fake; creator unverified (set `IsVerified=false` in the fake); `Bearer rcms_`; one
character changed; the stored hash sent as the key. Assert each also leaves `touchCalls == 0`.
Without an `rcms_` key the existing `jwtauth.Authenticator` response is unchanged: assert status `401`.
20. A key whose `ExpiresAt` equals the clock's now is rejected; one second later than now is accepted.
21. Cookie path unchanged: valid cookie -> `200`; valid jwt in `Authorization: Bearer` with no cookie ->
`200`; stale `token_version` -> `401`; no credentials on a protected route -> `401`.
22. Precedence: valid key of admin 3 plus an owner cookie on `POST /v1/user/invite` -> `403`
(acts as admin 3); `Bearer rcms_unknown` plus an owner cookie on `GET /v1/settings` -> `401`.
23. Role is read live: change the stored role of user 2 from admin to owner in the fake (test-only) and
assert the key is now owner-capable, then back, and assert it is not. This pins "never more
rights than the creator's current rights".
24. `GET /v1/auth/me` with only admin 2's key -> `200`, body byte-equal to the body admin 2's cookie gets
on the same route, describing user 2. `Bearer rcms_unknown` on `GET /v1/auth/me` -> `401`
`apperr.UnauthorizedErrorMessage`.
## Session-only routes (RequireSession)
25. Table, each with body `apperr.SessionRequiredErrorMessage`, status `403`, assert the underlying use
cases (key repository, verify, cancel registration, update status/check/download/restart) were
not invoked and the key repository is unchanged:
- owner 1's active key on `POST /v1/api-key`, `GET /v1/api-key`, `DELETE /v1/api-key/1`,
`POST /v1/auth/verify`, `DELETE /v1/auth/register`, `GET /v1/system/update`,
`POST /v1/system/update/check`, `POST /v1/system/update/download`, `POST /v1/system/update/restart`;
- a revoked key on `GET /v1/api-key`;
- an unknown well-formed `rcms_...` key on `POST /v1/system/update/check`;
- the bare string `rcms_` on `DELETE /v1/auth/register`.
The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key.
26. Session routes unchanged for sessions: owner cookie on `POST /v1/api-key`, `GET /v1/api-key`,
`GET /v1/system/update`, `POST /v1/system/update/check` -> the same status and body as before this
feature; no credentials -> status `401` (existing Authenticator); a jwt in `Authorization: Bearer`
(not starting with `rcms_`) -> the same result as the cookie.
27. Public auth routes with an owner key attached: `POST /v1/auth/login` with wrong credentials -> `403`
`apperr.LogsErrorMessage`, no `jwt` Set-Cookie; `POST /v1/auth/password-reset/confirm` with a wrong
code -> the same response as without the key, and the key is not rejected with
`SessionRequiredErrorMessage`. The user's stored password hash and `TokenVersion` are unchanged.
## Password reset revokes keys
28. Seed user 2 with active keys "a" and "b" (each `200` on `GET /v1/settings`), user 3 with key "c", and a
reset code for user 2 (hash of "reset-code-1", unexpired). `POST /v1/auth/password-reset/confirm`
`{"email":"dev@example.com","code":"reset-code-1","password":"newpassword1"}` -> `200`; then keys "a"
and "b" get `401` `apperr.UnauthorizedErrorMessage` on `GET /v1/settings`.
29. Same seed: key "c" still gets `200` and has no `revoked_at`.
30. Same seed with the fake clock at T: log in as user 2 with the new password, `GET /v1/api-key` with the
new cookie; "a" and "b" have status `revoked` and `revoked_at` equal to T.
31. A key of user 2 already revoked at T0 keeps `revoked_at` T0 after a reset at a later T; the active
key gets T.
32. Failed confirms (table: wrong code; expired code; unknown email) -> status not `200`; key "a" has no
`revoked_at`, still `200` on `GET /v1/settings`, and `revokeAllCalls` is empty.
## Listing
33. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its `user_id`.
34. Status table: active never used (`active`, nulls), active used (timestamp kept), expired
(`expired`), revoked (`revoked`, `revoked_at` set), expired-and-revoked (`revoked`).
## Revoke
35. Own key -> `204`, empty body; the key then gets `401` on `GET /v1/settings`; the list shows `revoked`.
36. Second `DELETE` -> `204`, `revoked_at` unchanged.
37. Admin 2 revoking admin 3's key -> `404` `apperr.APIKeyNotFoundErrorMessage`; the key still works.
Owner revoking it -> `204`; it then gets `401`.
38. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`; unknown id -> `404`.
39. Repository failure on `Revoke` -> `500` "Internal server error"; the key still works.
## last_used_at
40. Use-case level with the fake clock: first auth at T writes T; at T+30s no write
(`touchCalls` unchanged); at T+61s writes T+61s.
41. `TouchLastUsed` failing -> the request still returns `200`.
42. Rejected keys (case 19) never write.
## Rate limit
43. Mirror a group using the new per-key limiter with a limit of 2 (an exported constructor taking max
attempts and window): third request with key A -> `429`, body `apperr.TooManyRequestsErrorMessage`,
`Retry-After` header; key B and a cookie request still `200`; invalid keys stay `401`, never `429`.
44. Through `api.InitBackendRoutes`, send 121 requests with one key to `GET /v1/settings` -> the 121st is
`429`, the first 120 are `200`.
## CORS
45. Through `api.InitRoutes` with `CorsAllowedOrigins` `["https://app.example.com"]`: `OPTIONS /v1/article/1`
with `Origin` and `Access-Control-Request-Method: PATCH` -> success, `Access-Control-Allow-Methods`
includes `PATCH`; with `TRACE` it is not allowed.
## Use case level (no HTTP)
46. `CreateAPIKey` with `stubKeyGenerator`: repository receives the hash and the prefix and the user id;
the raw key appears only in the return value; a generator error -> returned, nothing stored; past
expiry -> `errors.Is(err, apikey.ErrExpiryNotInFuture)`.
47. `AuthenticateAPIKey`: each invalid case in 19 and 20 -> `errors.Is(err, apikey.ErrAPIKeyInvalid)`; a
valid key returns the user; an unexpected repository error is returned and is not `ErrAPIKeyInvalid`.
48. `ListAPIKeys` for admin and owner; `RevokeAPIKey` own, other's (`ErrAPIKeyNotFound` for admin),
owner any, unknown (`ErrAPIKeyNotFound`).
49. `ResetPasswordUseCase` built over `mockUserRepo`, `stubHasher`, `mockAPIKeyRepo` and a fake clock at T:
a successful reset for user 2 -> exactly one `RevokeAllByUser(2, T)` and user 3's keys untouched; a
wrong or expired code -> the existing sentinel and `revokeAllCalls` empty; `RevokeAllByUser`
failing -> that error is returned (the password was already changed).
50. `mockAPIKeyRepo.RevokeAllByUser` semantics: sets `RevokedAt` only on that user's not-yet-revoked
keys.
51. SHA-256 generator: raw matches the regex, `Hash(raw)` equals the generated hash, two generated keys
differ, prefix is the first 12 characters.
## Manual verification (not exercised by the Go tests)
- Real SQLite: start `task dev:api`, confirm `AutoMigrate` creates the api_keys table with a unique index
on the hashed key, create a key, restart, and check it still authenticates.
- With `curl`, run `Authorization: Bearer <key>` against a running server for an article create, a media
upload, an owner invite and `GET /v1/auth/me`, and confirm `PATCH` works from a browser origin listed
in `CORS_ALLOWED_ORIGINS`.
- Confirm the full key is shown only in the creation response and appears in no server log
(`ENVIRONMENT=development` enables httplog).
- A real reverse proxy: confirm `Authorization` is forwarded and `Retry-After` reaches the client.
- A real password reset by email: confirm the keys stop working after the link is used.
+352
View File
@@ -0,0 +1,352 @@
Feature: API keys screen
As a verified RenewCMS owner or admin
I want /integration/api-keys to list my API keys, create new ones and revoke them
So that a script or service can call the API with my rights, and I can cut it off at any time
Background:
Given /integration/api-keys is a verified route (meta.auth "verified") rendered as ApiKeysPage
with CreateApiKeyModal, ApiKeyReveal and a revoke confirmation dialog, inside
DashboardShell, per docs/design/routes.md
And the screen is a sub-page of Integration, per docs/design/screens/integration.md
And the screen calls, with relative URLs through useApi only, GET /v1/api-key,
POST /v1/api-key and DELETE /v1/api-key/{id}
And a GET /v1/api-key entry has the fields id, user_id, name, prefix (12 characters), status
("active", "expired" or "revoked"), created_at, last_used_at, expires_at and revoked_at, and
never a key or a hash, per features/api_keys.feature
And an owner's entries also carry created_by_name, the creator's display name or username (see
the API scenarios below)
And the list arrives newest first; an owner receives every user's keys, an admin only their own
And the PageHeader shows breadcrumb "Integration › API keys" (Integration links to
/integration), title "API keys" and a primary "Create key" button on the right at desktop
And the description reads "Keys act as the user who created them. As an owner you see
everyone's keys." for an owner, and "Keys act as you, with your role. You only see your own
keys." for an admin; a session without a role gets the admin rendering
And the table, on desktop, has the columns Name, Key, Status, Created by (owner only), Created,
Last used, Expires and an action column, in a UTable with a 36 px header and 48 px rows
And below md, each key is a card: name and status badge, prefix in monospace, then
"Created {date}[ by {name}] · Last used {date} · Expires {date}", then a 44 px "Revoke" button
And the Key column and the card show the prefix cut to its first 9 characters followed by "…",
for example "rcms_Kf3x…", and the full key is never shown in the list
And the revoke text uses the same 9-character prefix plus "…"
And dates are absolute and formatted for the current locale, for example "12 Mar 2026" in
English; a null expires_at reads "None" and a null last_used_at reads "Never"
And a Created by cell with an empty created_by_name shows "—"
And the Status badge is a UBadge: active success soft, expired warning soft, revoked neutral
with a muted row
And "Revoke" is offered on active and expired keys, never on revoked keys
And a note with an info icon always closes the page: "Resetting a user's password revokes all of
their keys."
And the Integration page (/integration, features/integration.feature) gains a "Manage API
keys" link to /integration/api-keys (see the Integration link scenario)
And on /integration/api-keys the sidebar and drawer item "Integration" carries
aria-current="page"
And every visible string goes through t(...), with the English text as the key and its French
entry in web/src/i18n/messages.ts
And the screen uses Nuxt UI components and design tokens only, with no raw colors
And the owner fixture is, newest first as the board draws it:
| name | prefix | status | created by | created | last used | expires |
| Website build | rcms_Kf3x… | active | Marta Alves | 12 Mar 2026 | 3 Oct 2026 | None |
| Nightly media import | rcms_Mq7b… | active | Jonas | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 |
| Staging preview | rcms_Zt2d… | expired | Marta Alves | 14 Jun 2026 | 20 Aug 2026 | 30 Aug 2026 |
| Old migration script | rcms_Hn5w… | revoked | Inès Duarte | 3 Apr 2026 | 9 May 2026 | None |
And the admin fixture (user "Jonas") is:
| name | prefix | status | created | last used | expires |
| Nightly media import | rcms_Mq7b… | active | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 |
| Old CI token | rcms_Lp9c… | revoked | 18 Jan 2026 | 2 Feb 2026 | None |
Scenario: Owner: list
Given the session role is "owner"
And GET /v1/api-key answers 200 with the owner fixture
When the user navigates to /integration/api-keys
Then the four rows show in fixture order with the columns Name, Key, Status, Created by,
Created, Last used, Expires
And "Staging preview" shows an "expired" badge and "Revoke"
And "Old migration script" shows a "revoked" badge, a muted row and no "Revoke"
And the description is the owner description
Scenario: Admin: own keys only
Given the session role is "admin"
And GET /v1/api-key answers 200 with the admin fixture
When the user navigates to /integration/api-keys
Then the two rows show with no "Created by" column
And on mobile the cards read "Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026"
without " by {name}"
And the description is the admin description
Scenario: Loading
Given GET /v1/api-key is pending
When the user navigates to /integration/api-keys
Then four skeleton rows are drawn in the table (two-bar cards below md) and the table has
aria-busy "true"
And the header "Create key" button is shown
Scenario: Empty
Given GET /v1/api-key answers 200 with []
When the user navigates to /integration/api-keys
Then a panel shows title "No API keys yet", text "Create a key to call the API from a script or
service." and a primary "Create key" button
And no table is drawn
Scenario Outline: Error on load
Given GET /v1/api-key fails with <failure>
When the user navigates to /integration/api-keys
Then an alert (role "alert") shows "Couldn't load your API keys" and "Check your connection and
try again." with a "Try again" action
And no table, no cards and no empty panel are drawn
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Try again after an error
Given the load failed and the error alert is shown
When the user activates "Try again"
Then GET /v1/api-key is requested again and, on 200, the list replaces the alert
Scenario Outline: Session problems use the app-wide handling
Given GET /v1/api-key answers <status>
When the user navigates to /integration/api-keys
Then <outcome>
Examples:
| status | outcome |
| 401 | the user lands on /login?redirect=/integration/api-keys&reason=expired |
| 403 | the user lands on /register/pending |
Scenario: Create key: modal opened
Given the list is shown
When the user activates "Create key"
Then a modal titled "Create API key" opens with the text "The key acts as you, with your role. It
is shown in full once."
And it has a Name field with a "0 / 60" counter and help "Required. Up to 60 characters."
And it has an "Expiry date (optional)" date input with help "Optional. Must be in the future.
Leave it empty for a key that does not expire."
And it has the buttons "Cancel" and "Create key"
And Name has focus
Scenario Outline: Create key: validation
Given the Create API key modal is open
When the user submits with name "<name>" and expiry "<expiry>"
Then no request is sent
And <name_error>
And <expiry_error>
Examples:
| name | expiry | name_error | expiry_error |
| | 12 Mar 2026 | Name shows the error "Enter a name." in red | Expiry shows the error "Choose a date in the future." in red |
| | | Name shows the error "Enter a name." in red | Expiry shows no error |
| Deploy | 12 Mar 2026 | Name shows no error | Expiry shows the error "Choose a date in the future." in red |
Scenario: Create key: name too long
Given the Create API key modal is open
When the user types a name of 75 characters
Then the counter reads "75 / 60" in the error color
And Name shows the error "Use 60 characters or fewer." in red
And submitting sends no request
Scenario: Create key: success reveals the key once
Given the Create API key modal is open
When the user submits name "Nightly media import" and an empty expiry
Then exactly one POST /v1/api-key is sent with body {"name": "Nightly media import"} and no
expires_at
And on 201 the form modal is replaced by a modal titled "Your new API key" with the text
"\"Nightly media import\" is ready. Copy the key and store it somewhere safe."
And a warning alert (UAlert, color warning, triangle-alert icon) reads "You won't be able to
see this key again."
And the full key from the response is shown in a monospace box with a "Copy" button (icon copy)
And the note "Closing this window discards the key from the screen. Copy it first." is shown
And the only button is a primary "Done"
Scenario: Create key: with an expiry
Given the Create API key modal is open
And the local date is D
When the user submits name "temp" and the expiry date D + 30 days
Then the POST body carries name "temp" and expires_at equal to 23:59:59 local time on D + 30
days, as an RFC3339 instant with the local offset
Scenario Outline: The expiry date is checked against the same instant the API checks
Given the Create API key modal is open and the current time is T
When the user submits a name and the expiry date <date>
Then <outcome>
Examples:
| date | outcome |
| today | the request is sent, because 23:59:59 today is after T |
| yesterday | no request is sent and Expiry shows "Choose a date in the future." |
| tomorrow | the request is sent |
Scenario: One-time reveal: copied
Given the key reveal is open
When the user activates "Copy"
Then the full key is written to the clipboard
And the button shows icon check and text "Copied" in the success color, announced to assistive
technology
When 2 seconds elapse
Then the button reverts to icon copy and text "Copy"
Scenario: One-time reveal: Done discards the key
Given the key reveal is open
When the user activates "Done"
Then the modal closes
And the full key is no longer in the DOM, the component state, the URL or any storage
And the new key is listed first with its 9-character prefix and "…" only, status "active",
created by the current user (owner view), last used "Never", expires per the request, and no
second GET /v1/api-key is made
And reopening "Create key" shows an empty form
Scenario Outline: Dismissing the modals
Given <modal> is open
When the user <action>
Then <outcome>
Examples:
| modal | action | outcome |
| the key reveal | presses Escape | the modal closes and the key is discarded, as for Done |
| the key reveal | clicks the backdrop | the modal stays open |
| the Create key form | presses Escape | the modal closes with no request |
| the Create key form | clicks the backdrop | the modal stays open |
Scenario: Create key: Cancel discards the form
Given the Create API key modal is open with typed values
When the user activates "Cancel"
Then the modal closes without a request, focus returns to the opening button, and reopening it
shows an empty form
Scenario: Revoke: confirm
Given the owner list is shown
When the user activates "Revoke" on "Nightly media import"
Then a dialog titled "Revoke this key?" opens with the text "\"Nightly media import\"
(rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be
undone."
And its buttons are "Keep key" and a destructive "Revoke key"
And "Keep key" closes the dialog with no request and the row unchanged
Scenario: Revoked
Given the revoke dialog is open for "Nightly media import" (id 2)
When the user activates "Revoke key"
Then exactly one DELETE /v1/api-key/2 is sent
And on 204 the dialog closes and the row stays listed with status "revoked", a muted row and no
"Revoke" button
And a success toast shows title "Key revoked" and text "\"Nightly media import\" can no longer
be used."
And no second GET /v1/api-key is made
Scenario: Mobile Create key button, not drawn on board
Given the viewport is below md
When the list, the loading skeleton or the error alert is shown
Then a primary full-width 48 px "Create key" button is pinned to the bottom of the screen
And it is hidden while any modal or dialog is open, and in the Empty state where the panel has
its own "Create key" button
And the header has no "Create key" button below md
Scenario: Integration page links to the keys screen
Given the user is on /integration
Then a "Manage API keys" link (UButton variant link, arrow-right trailing icon) is shown below
the three info notes
When the user activates it
Then the user lands on /integration/api-keys and the sidebar "Integration" item keeps
aria-current="page"
Scenario: Language: every string has a French entry
Given the language is "fr"
When the user opens each state of the screen
Then every visible string is the French message and none falls back to English
And dates are formatted for "fr"
Scenario: Create key: sending, not drawn on board, no visual state
Given the Create API key modal is open with a valid name
When the user submits and POST /v1/api-key is pending
Then the primary button reads "Creating…", Name, Expiry and "Cancel" are disabled
And Escape does not close the modal
Scenario Outline: Create key: failed, not drawn on board, no visual state
Given the Create API key modal is open with name "Nightly media import"
When POST /v1/api-key fails with <failure>
Then an alert in the modal reads "<copy>"
And the typed values are kept, the form is enabled again, no row is added, and no field error
is shown
Examples:
| failure | copy |
| status 500 | Couldn't create the key. Try again in a moment. |
| a network error | Couldn't reach RenewCMS. Check your connection and try again. |
| status 429 | Too many requests. Wait a moment, then try again. |
| status 400 | Something in the form isn't valid. Check the highlighted fields. |
Scenario: Create key: retry after failure, not drawn on board, no visual state
Given a failed create left the modal open
When the user submits again and POST answers 201
Then the reveal opens and the row is added
Scenario: Revoke: sending, not drawn on board, no visual state
Given the revoke dialog is open
When the user activates "Revoke key" and DELETE is pending
Then "Revoke key" reads "Revoking…", both buttons are disabled, and Escape does not close the
dialog
Scenario Outline: Revoke: failed, not drawn on board, no visual state
Given the revoke dialog is open for "Nightly media import"
When DELETE fails with <failure>
Then a notice in the dialog reads "<copy>"
And the dialog stays open, the buttons are enabled again, and the row is unchanged
Examples:
| failure | copy |
| status 500 | Couldn't revoke the key. Try again in a moment. |
| a network error | Couldn't reach RenewCMS. Check your connection and try again. |
Scenario: Revoke: key already gone, 404, not drawn on board, no visual state
Given the revoke dialog is open for "Nightly media import" (id 2)
When DELETE /v1/api-key/2 answers 404
Then the dialog closes, the row is removed from the list, and an error toast reads "That key no
longer exists."
Scenario: Last used, never, not drawn on board, no visual state
Given an entry whose last_used_at is null
Then its Last used cell and its mobile card text read "Never"
Scenario: API: an owner's list carries the creator's name
Given owner 1 "marta" with display name "Marta Alves", admin 2 "dev" with display name "Dev Two"
and admin 3 "pat" with an empty display name
And keys "a" by user 1, "b" by user 2 and "c" by user 3
When owner 1 sends GET /v1/api-key with a jwt cookie
Then each entry has the key created_by_name
And entry "a" has "Marta Alves", "b" has "Dev Two" and "c" has "pat"
And the other nine fields are unchanged from features/api_keys.feature
Scenario: API: a whitespace-only display name falls back to the username
Given admin 3 "pat" with display name " " and a key "c"
When owner 1 sends GET /v1/api-key
Then entry "c" has created_by_name "pat"
Scenario: API: an admin's list is unchanged
Given admin 2 with keys "a" and "b"
When admin 2 sends GET /v1/api-key with a jwt cookie
Then every entry has exactly the keys id, user_id, name, prefix, status, created_at,
last_used_at, expires_at, revoked_at
And no entry has created_by_name
Scenario: API: a key whose creator no longer exists
Given a key whose user record was deleted
When owner 1 sends GET /v1/api-key
Then its entry has created_by_name ""
And the response status is 200 and the other entries are unaffected
Scenario: API: a failing user lookup fails the list
Given the user repository fake returns an unexpected error on Get for a creator
When owner 1 sends GET /v1/api-key
Then the response status is 500 and the body is "Internal server error"
Scenario: API: the creator name is resolved once per user
Given owner 1 has 3 keys and admin 2 has 2 keys
When owner 1 sends GET /v1/api-key
Then the user repository received one Get per distinct creator, not one per key
Scenario: Use case: ListAPIKeys returns the creator name only to an owner
Given a fake apikey.Repository and a fake user.Repository
When ListAPIKeys is called for an owner
Then every APIKeyEntry carries the creator's name
When ListAPIKeys is called for an admin
Then every APIKeyEntry has an empty creator name and the handler omits the field
+235
View File
@@ -0,0 +1,235 @@
# QA: API keys screen
Two parts: the Go API change (HTTP-level, in `test/`) and the SPA (Vitest and visual).
## What changes in the backend
Only `GET /v1/api-key` called by an owner changes: each entry gains `created_by_name`, the creator's
display name (trimmed, non-empty) or else username, and `""` when the creator no longer exists.
Admin entries keep exactly the nine existing fields and never carry `created_by_name`.
`features/api_keys.feature` is not edited. Its exact-fields scenario is made as an admin and stays
true. No domain or persistence change: `ListAPIKeysUseCase` already holds `user.Repository` and
resolves each distinct creator once through `Get`.
## Go: created_by_name on GET /v1/api-key
Extend `test/api_keys_http_test.go`. Build the handler from `NewHandler(...)` over use cases wired
to the existing in-memory fakes (apikey.Repository, user.Repository, KeyGenerator, fake clock).
Mount it on a fresh `chi.NewRouter()` that mirrors `api/router.go`, wrapped in `jwtauth.Verifier`
and `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`; call
`validator.InitValidator()`; exercise it with `httptest`. No live server, no real SQLite, no real
SMTP. Users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" ("Dev Two"), admin 3
"pat" (empty display name), all verified.
1. **Owner sees names** - keys a (user 1), b (user 2), c (user 3); owner cookie GET /v1/api-key;
assert 200, every entry has `created_by_name`, a "Marta Alves", b "Dev Two", c "pat", and the
other nine fields as before (newest first).
2. **Whitespace display name** - user 3 display name " "; assert "pat".
3. **Admin unchanged** - admin 2 cookie; assert every entry has exactly the nine existing fields
(use `apiKeyAssertExactFields`) and no `created_by_name`. Check that the existing exact-fields
test at line 341 is made as an admin; if it is made as an owner, update it and report it.
4. **Deleted creator** - key whose user was removed from the fake; owner GET; assert 200 and
`created_by_name` "" for it.
5. **User lookup fails** - the fake user repository returns an error on Get for a creator; assert
500 and body "Internal server error".
6. **One lookup per creator** - the fake counts Get calls; owner with 3 + 2 keys over 2 creators;
assert the creator lookups equal the distinct creators (plus the requester's own Get).
7. **Use case level** - ListAPIKeys for an owner returns entries with the creator name; for an
admin returns empty creator names; the other existing ListAPIKeys tests pass unchanged.
8. **Regression** - the existing api_keys tests still pass; list order, statuses, 401 and 403
behaviour are unchanged.
Run with `task test`; one test with `task test -- -run 'TestName'`.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for GET /v1/api-key, POST /v1/api-key and DELETE /v1/api-key/{id} per
case. Mount ApiKeysPage inside DashboardShell with a resolved verified session (id 1, "marta",
display name "Marta Alves", role "owner") and a test router at /integration/api-keys; admin cases
use role "admin", username "jonas". Mocked owner entries carry `created_by_name`. Stub
`navigator.clipboard.writeText` per case. No test freezes the system date; specs assert on the
fixtures' absolute dates only, and the expiry cases build their dates from the real clock. Run
layout cases at desktop and below-md (mock `matchMedia`).
1. **Owner: list** - breadcrumb (with the Integration link), title, owner description, header
labels, rows in fixture order with every cell (prefix cut to 9 characters plus "…", Created by
from `created_by_name`), badge words, "Revoke" on rows 1 to 3 and not row 4, row 4 muted, the
note; the below-md cards. An entry with an empty `created_by_name` shows "—".
2. **Admin: own keys only** - no "Created by" header or cell, admin description, two rows, cards
without " by {name}"; a session with no role gets the same rendering.
3. **Loading** - unresolved GET; 4 skeleton rows, aria-busy "true", header "Create key"; resolve
and assert rows.
4. **Empty** - `[]`; the panel copy and its "Create key", no table.
5. **Error, Examples-driven** - ApiError(500), then NetworkError; alert copy as drawn, "Try again",
no rows; click "Try again" and assert the second request and the rows.
6. **401 and 403** - the app-wide redirects, no alert on the page.
7. **Create key: opened** - title, text, "0 / 60", helps, buttons, focus on Name.
8. **Create key: validation, Examples-driven** - the three Outline rows; error text, no request,
errors clear when fixed.
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
"Done".
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
dates from `new Date()` in the test.
12. **Reveal: copied** - fake timers; click "Copy"; writeText with the full key, icon check,
"Copied", aria-live announcement; advance 2 s; "Copy" again.
13. **Reveal: Done and Escape discard** - assert the modal is gone, the key string appears nowhere
in `wrapper.html()`, the router URL, localStorage or sessionStorage; the new row shows the
prefix only, status "active", "Never", created by the session user; no second GET; a reopened
form is empty. A backdrop click leaves the reveal open.
14. **Create key: Cancel and Escape** - no request, empty on reopen, focus back on the opener;
a backdrop click does not close.
15. **Revoke: confirm and Keep key** - dialog copy with the 9-character prefix, both buttons;
"Keep key" closes with no request.
16. **Revoked** - confirm; one DELETE with the key's id; row "revoked", muted, no "Revoke"; toast
title and text; no second GET.
17. **Not drawn: Create sending** - pending POST; "Creating…", disabled fields and Cancel, Escape
ignored.
18. **Not drawn: Create failed, Examples-driven** - ApiError(500) "Couldn't create the key. Try
again in a moment.", NetworkError "Couldn't reach RenewCMS. Check your connection and try
again.", ApiError(429) "Too many requests. Wait a moment, then try again.", ApiError(400)
"Something in the form isn't valid. Check the highlighted fields."; values kept, form
re-enabled, no row added; submit again, resolve 201, assert the reveal.
19. **Not drawn: Revoke sending** - "Revoking…", both buttons disabled, Escape ignored.
20. **Not drawn: Revoke failed, Examples-driven** - ApiError(500) "Couldn't revoke the key. Try
again in a moment." and NetworkError "Couldn't reach RenewCMS. Check your connection and try
again."; the notice in the open dialog, row unchanged; retry succeeds.
21. **Not drawn: Revoke 404** - the dialog closes, the row is removed, the error toast "That key no
longer exists.".
22. **Not drawn: Last used "Never"** - null `last_used_at`, desktop cell and mobile card.
23. **Mobile pinned button** - below md the pinned 48 px "Create key" exists in list, loading and
error; hidden while a modal is open and in Empty; no header button below md.
24. **Integration link and navigation** - on /integration assert "Manage API keys" is a link to
/integration/api-keys below the three info notes; navigate; assert the sidebar and drawer
"Integration" item has aria-current="page" on /integration/api-keys. Update the existing
integration specs (features/integration.qa.md case 1) for the extra link.
25. **French** - `setLocale('fr')`; walk cases 1, 4, 7, 10, 15, 16, 17, 21; no visible English
source string remains; dates use the French format.
26. **Messages** - every new `t()` key has a French entry (extend the i18n spec pattern).
Un-run web tests are reported as NOT RUN, never as passing.
## Visual (`task visual:check -- integration-api-keys`, desktop and mobile, light and dark)
Reference: docs/design/screens/integration.md, board `docs/design/source/RenewCMS Screens API
keys.dc.html`, exact values in `docs/design/source/ApiKeysPage.dc.html`. Add each state's setup to
`web/visual/states.ts` with the board's own sample data (mocked GET /v1/api-key responses include
`created_by_name` and 12-character prefixes whose first 9 characters match the board). Hard gate:
every capture of every state within 10 %.
Sample data: the owner and admin fixtures from the feature's Background; Create modal name
"Nightly media import" (20 / 60) and expiry 31 Dec 2026; validation: empty name (0 / 60) and
expiry 12 Mar 2026; too long: "Nightly import of the new media library from the Brussels office
file server" (75 / 60); reveal key rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe for
"Nightly media import"; revoke and toast for "Nightly media import" (rcms_Mq7b…).
No state is clock-sensitive, since every date is fixed text. The Create sample expiry 31 Dec 2026
becomes a past date after that day, but the state only displays it, so it gets no skip.
States:
- [ ] Owner: list
- [ ] Admin: own keys only
- [ ] Loading (the setup holds the request)
- [ ] Empty
- [ ] Error
- [ ] Create key
- [ ] Create key: validation
- [ ] Create key: name too long
- [ ] One-time reveal
- [ ] One-time reveal: copied (timer-driven, so it is checked)
- [ ] Revoke: confirm
- [ ] Revoked (list with the revoked row and the toast)
- [ ] Layout from the page component, not components.md: 36 px header, 48 px rows, 20 px status
badge; desktop modal 480 px wide 120 px from the top; mobile modal full width, bottom-aligned,
buttons stacked with Cancel under the primary; mobile card Revoke 44 px; toast 320 px wide
bottom-right on desktop and 16 px inset on mobile.
- [ ] Not drawn on the board, no visual state, Vitest only: Create sending, Create failed (500,
network, 429, 400), Revoke sending, Revoke failed, Revoke 404, "Never", the dismissal rules,
and the Integration "Manage API keys" link.
Risk: the mobile pinned "Create key" button is not on the board, and the Integration link is not
on the board's frames for the current Integration page. Either may push a mobile or Integration
capture over 10 %. Re-run `task visual:check -- integration` and the API keys screen. If either
fails, the user must decide: an approved per-state exception needs `approvedBy`, or a design
update.
Manual checks screenshots cannot cover:
- Keyboard and focus order: Tab reaches "Create key", then each "Revoke" in row order; modal focus
starts on Name, is trapped, and returns to the opener on close; the reveal's initial focus lands
on "Copy".
- Hover and motion: outline and primary button hover; modal motion; skeleton pulse stopped under
reduced motion.
## Manual verification
- Real round trip against `task dev`: create a key, copy it, run
`curl -H "Authorization: Bearer <key>" <origin>/v1/auth/me` and get 200; revoke it from the
screen and get 401; confirm the key gets 403 "This action needs a real session." on
/v1/api-key.
- Owner and admin: as owner confirm the Created by names (display name, else username); as admin
confirm only your own keys and no Created by column.
- Reset-password revocation: reset a user's password and confirm their keys show "revoked" after
reload.
- Clipboard on a real HTTPS or localhost origin.
## French strings for web/src/i18n/messages.ts
Keyed by the English text. `{0}` and `{1}` are the `t()` placeholders.
- API keys = Clés d'API
- Integration › API keys = Intégration › Clés d'API
- Create key = Créer une clé
- Name = Nom
- Key = Clé
- Status = Statut
- Created by = Créée par
- Created = Créée le
- Last used = Dernière utilisation
- Expires = Expire le
- active = active
- expired = expirée
- revoked = révoquée
- Revoke = Révoquer
- None = Aucune
- Never = Jamais
- Keys act as the user who created them. As an owner you see everyone's keys. = Les clés agissent comme l'utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.
- Keys act as you, with your role. You only see your own keys. = Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.
- Resetting a user's password revokes all of their keys. = Réinitialiser le mot de passe d'un utilisateur révoque toutes ses clés.
- No API keys yet = Aucune clé d'API pour l'instant
- Create a key to call the API from a script or service. = Créez une clé pour appeler l'API depuis un script ou un service.
- Couldn't load your API keys = Impossible de charger vos clés d'API
- Check your connection and try again. = Vérifiez votre connexion et réessayez.
- Try again = Réessayer
- Create API key = Créer une clé d'API
- The key acts as you, with your role. It is shown in full once. = La clé agit en votre nom, avec votre rôle. Elle n'est affichée en entier qu'une seule fois.
- Required. Up to 60 characters. = Obligatoire. 60 caractères maximum.
- Expiry date (optional) = Date d'expiration (facultative)
- Optional. Must be in the future. Leave it empty for a key that does not expire. = Facultative. Doit être dans le futur. Laissez vide pour une clé qui n'expire pas.
- Enter a name. = Saisissez un nom.
- Use 60 characters or fewer. = Utilisez 60 caractères ou moins.
- Choose a date in the future. = Choisissez une date dans le futur.
- Cancel = Annuler
- Your new API key = Votre nouvelle clé d'API
- "{0}" is ready. Copy the key and store it somewhere safe. = « {0} » est prête. Copiez la clé et conservez-la en lieu sûr.
- You won't be able to see this key again. = Vous ne pourrez plus revoir cette clé.
- Copy = Copier
- Copied = Copié
- Closing this window discards the key from the screen. Copy it first. = Fermer cette fenêtre supprime la clé de l'écran. Copiez-la d'abord.
- Done = Terminé
- Revoke this key? = Révoquer cette clé ?
- "{0}" ({1}) will stop working right away. Requests that use it answer 401. This can't be undone. = « {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l'utilisent recevront une réponse 401. Cette action est irréversible.
- Keep key = Garder la clé
- Revoke key = Révoquer la clé
- Key revoked = Clé révoquée
- "{0}" can no longer be used. = « {0} » ne peut plus être utilisée.
- Creating… = Création…
- Revoking… = Révocation…
- Couldn't create the key. Try again in a moment. = Impossible de créer la clé. Réessayez dans un instant.
- Couldn't reach RenewCMS. Check your connection and try again. = Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.
- Too many requests. Wait a moment, then try again. = Trop de requêtes. Patientez un instant, puis réessayez.
- Something in the form isn't valid. Check the highlighted fields. = Un élément du formulaire n'est pas valide. Vérifiez les champs signalés.
- Couldn't revoke the key. Try again in a moment. = Impossible de révoquer la clé. Réessayez dans un instant.
- That key no longer exists. = Cette clé n'existe plus.
- Manage API keys = Gérer les clés d'API
+182 -52
View File
@@ -1,63 +1,193 @@
Feature: Integration screen
Feature: Integration screen (API reference)
As a verified RenewCMS owner
I want /integration to show my site's base API URL and ready-to-use endpoint examples
So that I, or the developer who builds my public site, can wire it up without reading the
source
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
So that I can call every route a key may call without reading the source
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the PageHeader shows title "Connect your website", description "A guide for the developer
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
actions
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
articles but doesn't show them to visitors. Your website fetches published articles from the
two addresses below and displays them in its own design."
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
(icon copy) beside it
And two EndpointCard entries follow, in this order:
| method | path | description |
| GET | /v1/article/ | Lists every online article. |
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
And each EndpointCard's fetch example uses the current origin as the base of its URL:
| path | fetch example |
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
And the curl tab's content is a plain curl command built from the current origin (e.g.
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
content is a JSON sample built from the real domain Article fields; neither tab is active by
default, and neither is visually checked, since the fetch tab is the one shown in the board
And three info notes follow the endpoint cards, in this order:
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
them."
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
base URL before rendering."
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
whoever runs the server to add your site's address."
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:
| 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 |
| 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:
| 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 |
| API key management | GET /v1/api-key, POST /v1/api-key, DELETE /v1/api-key/{id} |
And a "Public auth flow" sub-list follows with 6 non-expandable rows and the chip "Public":
| method | path | description |
| GET | /v1/ | Hello world. |
| GET | /v1/auth/registration | Whether registration is open. |
| POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. |
| GET | /v1/auth/invite | Check an invite code. |
| POST | /v1/auth/invite/accept | Accept an invite. |
| GET | /healthz | Plain text "ok". Outside /v1. |
And every catalogue endpoint has a non-empty description, method, path, access chip, key chip,
query-parameter table (or "No query parameters."), request-body table (or "No request body."),
fetch, curl and Response examples, and error captions
Scenario: Default — the page loads with the app's own origin
Given the app is served from origin "{origin}"
Scenario: Default, every card collapsed
When the user navigates to /integration
Then the Base URL row shows exactly "{origin}"
And the Base URL "Copy" button reads "Copy", not "Copied"
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
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."
And a "Base URL" row shows window.location.origin in a monospace box with 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
Scenario: Copied — the Base URL copy button
Given the user is on /integration, served from origin "{origin}"
When the user activates the Base URL row's "Copy" button
Scenario Outline: Method badge and chips per endpoint
Then <method> <path> shows the <method> badge, access chip "<access>" and key chip "API key OK"
Examples:
| method | path | access |
| GET | /v1/article/ | Public |
| GET | /v1/article/{id} | Public |
| POST | /v1/article/ | Verified user |
| DELETE | /v1/article/{id} | Verified user |
| GET | /v1/media/ | Verified user |
| PATCH | /v1/user/me | Verified user |
| GET | /v1/auth/me | Verified user |
| POST | /v1/user/invite | Owner only |
| DELETE | /v1/user/invite/{id} | Owner only |
| POST | /v1/user/invite/{id}/renew | Owner only |
| GET | /v1/settings | Verified user |
Scenario: Owner-only invite cards, user-approved divergence from the board
Then the three invite cards show "Owner only", where the board shows "Verified user"
And each lists a 403 error "Only the owner can do this."
And each says it is rate limited and that too many requests answer 429, with no number
Scenario: Group scrolled, one card expanded
When the user activates the "POST /v1/article/" row
Then the row's button reports aria-expanded "true"
And a "Query parameters" table lists "format", string, optional, "markdown, html or json. Default markdown."
And a "Request body" table lists:
| Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
| body | string | Yes | Up to 10 000 characters. |
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
And an "Errors" list shows a status chip with a one-line caption for 400, 401
Scenario: Query parameters table, new UI beyond the board, user-approved
Then the "Query parameters" table sits above the "Request body" table in each card
And it lists:
| route | name | type | required | rule |
| every /v1/article route | format | string | no | markdown, html or json. Default markdown. |
| GET /v1/media/ | q | string | no | Search text. |
| GET /v1/media/ | hash | string | no | SHA-256 of the content, lowercased. |
| GET /v1/media/ | cursor | string | no | The next_cursor of the previous page. |
| GET /v1/media/ | filter | string | no | all, unused or duplicates. Default all. |
| GET /v1/media/ | sort | string | no | newest, oldest, name or largest. Default newest. |
| GET /v1/media/ | limit | number | no | 1 to 100. Default 24. |
And a card without query parameters shows "No query parameters."
Scenario Outline: Request body fields come from the handler validate tags
Then <method> <path> lists these fields: <fields>
Examples:
| method | path | fields |
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
| PATCH | /v1/article/{id} | body string required max 10000 |
| PATCH | /v1/media/{id} | filename string required |
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
| PATCH | /v1/user/me | display_name string required max 60 |
| POST | /v1/user/invite | email string required valid email |
| GET | /v1/article/ | "No request body." |
Scenario: Response samples use the real domain JSON field names
Then the GET /v1/article/{id} Response sample has exactly the keys id, title, body, body_format, is_online, created_at, updated_at
And the sample has no "images" key and no "status" key
And POST /v1/media/merge has the keys articles_updated and merged
And GET /v1/media/ has the keys items, next_cursor and counts
And GET /v1/media/{id}/usage items have the keys id, title, published
And PATCH /v1/article/{id}/publish and /unpublish samples are articles with is_online true and false
Scenario: Article body format
Then a note reads "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."
And every article card's Errors list includes 400 "The format must be one of: markdown, html, json."
Scenario: Authentication section
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
And card "API key" reads "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."
And its CodeBlock 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 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
Then the banner reads "These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session.""
And each row has a dashed border and muted fill, uses no method colour and cannot be expanded
And rows that answer 403 to a key show the lock icon and the chip "Session only"
And Log in, Log out, Register and Reset password show the caption "Made for the browser. A key is ignored here, with no 403." and no 403 claim
And no /v1/system route appears in any expandable group
And the "Public auth flow" rows carry the chip "Public" and no lock icon
Scenario: Errors section
Then the intro reads "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."
And the table lists:
| 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. |
| 429 | Too many requests. Please try again later. |
| 500 | Internal server error |
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
And the table does not use the SPA copy of docs/design/states.md
Scenario Outline: Per-endpoint error captions use the real strings
Then <method> <path> lists <status> "<caption>"
Examples:
| method | path | status | caption |
| POST | /v1/media/ | 400 | the image could not be read |
| POST | /v1/media/ | 413 | the file exceeds the maximum upload size |
| POST | /v1/media/ | 415 | the file must be a PNG, JPEG, WEBP, or SVG image |
| GET | /v1/media/ | 400 | The media query is invalid. |
| POST | /v1/media/merge | 400 | Choose an image to keep and at least one other image to merge into it. |
| POST | /v1/media/merge | 409 | Some of the images to merge changed. Refresh and try again. |
| PATCH | /v1/media/{id} | 400 | Enter a filename. |
| POST | /v1/article/ | 400 | Article title must be between 3 and 200 characters. |
| GET | /v1/article/{id} | 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
| PATCH | /v1/user/me | 400 | Display name must be 60 characters or fewer. |
| POST | /v1/user/invite | 409 | This address already has an account. |
| POST | /v1/user/invite | 409 | There's already a pending invite for this address. |
| POST | /v1/user/invite/{id}/renew | 409 | This invite has already been accepted. |
| POST | /v1/user/invite | 403 | Only the owner can do this. |
Scenario: Notes
Then the image-prefix note reads "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering."
And the CORS note reads "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address."
Scenario: Copied, Base URL
Given the app is served from origin "{origin}"
When the user activates the Base URL "Copy" button
Then "{origin}" is written to the clipboard
And the button's icon changes to check and its text changes to "Copied"
And the change is announced to assistive technology
When 2 seconds elapse without a further click
Then the button reverts to icon copy and text "Copy"
And the button shows icon check and "Copied", announced to assistive technology
When 2 seconds elapse
Then the button reverts to icon copy and "Copy"
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
Given the user is on /integration
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
button
Then that card's own code is written to the clipboard, and only that button reads "Copied"
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
Scenario: Copied, CodeBlock is independent of Base URL
Given a card is expanded
When the user activates that CodeBlock's "Copy JavaScript example" button
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
And the Base URL button and other CodeBlocks are unaffected
Scenario: Examples use the current origin and the API key header
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
And examples for non-public routes carry "Authorization: Bearer rcms_…"
And examples for public routes carry no Authorization header
Scenario: Navigation
When the user activates a section in the index (desktop)
Then the page scrolls to that anchor and the item reports aria-current "true"
And on mobile the "Jump to" select opens and lists the same sections
Scenario: The page stays static
Then no /v1 request is made while the page renders or while any card is expanded
+86 -54
View File
@@ -1,66 +1,98 @@
# QA: Integration screen
# QA: Integration screen (API reference)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
`/v1` mocks are needed; the screen makes no calls.
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
is made.
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
"Connect your website" and description "A guide for the developer who builds your public
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
the three info notes render in order.
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
button reverts to icon copy and text "Copy".
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
Vitest-only; it has no visual-check state of its own (see Visual below).
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
content is a JSON sample built from the real domain Article fields. Neither tab's content is
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
1. The catalogue holds exactly the 20 expected key-callable routes from an explicit hand-maintained
list in the spec (Articles 7, Media 6, Users and invites 6, Settings 1), each once, with no other
expandable route. Session only has the 13 rows and Public auth flow the 6 rows listed in the
feature, from the same kind of explicit list.
2. Every catalogue route has a non-empty `description`. An empty or missing one fails the spec and
names the route.
3. Every route has method, path, access chip, key chip, a query-parameter list and a body-field list
(possibly empty), and fetch, curl and Response examples.
4. The Response sample of each route parses as JSON and uses only keys from a fixture of the real
fields:
- article: id, title, body, body_format, is_online, created_at, updated_at;
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate;
- media list: items, next_cursor, counts (all, unused, duplicates);
- merge: articles_updated, merged;
- usage: id, title, published;
- user list item: id, kind, username, display_name, email, role, status, created_at;
- invite: id, kind, email, status, created_at, expires_at;
- settings: version.
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
rate-limit sentence. No /v1/system route is in an expandable group. Login, Log out, Register and
Reset password rows carry no 403 claim. Verify, DELETE /auth/register, /api-key/* and
/system/* are the only rows that claim 403.
7. Error captions equal fixtures copied from api/pkg/apperr/messages.go and the image domain
errors (413, 415, 400 on upload).
Component specs, one per design state:
1. **Default**: breadcrumb, title "API reference", description, intro, Base URL equals the stubbed
origin, all cards collapsed (aria-expanded "false"), section index entries present, the three
invite rows show "Owner only".
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
announced; reverts after 2 s.
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
only that button reads "Copied", and it reverts after 2 s.
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
href /integration/api-keys, Note 1, Note 2.
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
the "Public" chip.
7. **Errors**: table rows 400, 401, 403, 404, 409, 429, 500 with the real strings and the 403
sample block.
8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile
"Jump to" lists the same sections.
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
Authorization header only on non-public routes. Not visually checked.
10. **Notes**: format note, image-prefix note, CORS note.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Reference: `docs/design/screens/integration.md` and
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
Reference: docs/design/screens/integration.md; board "Integration" drawn by
docs/design/source/IntegrationPage.dc.html. Serve the SPA under `https://cms.renew.example`. Mocks
reuse the board's script data: BASE, KEY, group and endpoint descriptions, the request bodies
(for example the "Choosing tiles for a small bathroom" article). Response samples are not compared;
the Response tab is never active in a board frame.
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
Playwright request routing, so the real `window.location.origin` the running app reads matches the
board's sample text exactly; no production test hook or origin stub is needed in app code.
Pre-approved exceptions: the user pre-approved visual exceptions for Session only, Group scrolled
one card expanded, Errors and Default. For each of these four states the coder measures the real
diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and records
`approvedBy` "Florian Sylvain". The other states must still pass 10 %.
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
`https://cms.renew.example`, served as above.
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
color, matching the board's `copied: true` frame. This state is timer-driven, not
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
elsewhere in the app (UserMenu, ArticleTable row menu).
- [ ] Default (exception: owner-only chips and rate-limit line)
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
- [ ] Copied: base URL (must pass 10 %)
- [ ] Copied: CodeBlock (must pass 10 %)
- [ ] Authentication (must pass 10 %; Note 1 text changed)
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
- [ ] Errors (exception: 409 row)
- [ ] Navigation (must pass 10 %)
## Manual verification
None of these states is clock-sensitive, so none is skipped.
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
## Manual
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
- Hover and motion on ghost Copy buttons and rows.
- Find-in-page and a deep link to an `#ep-...` anchor.
- Real origin in a production build and under `task dev`.
- Run each documented example against a running server with a real `rcms_` key, including the
403 on a session-only route and the owner-only 403 for an admin key on the invite routes. This
needs a real server and is not automated.
- docs/api.md matches the catalogue (hand check).
+42
View File
@@ -0,0 +1,42 @@
package apikey
import (
"errors"
"time"
)
var ErrAPIKeyNotFound = errors.New("api key not found")
var ErrAPIKeyInvalid = errors.New("api key invalid")
var ErrExpiryNotInFuture = errors.New("api key expiry not in the future")
const RawKeyPrefix = "rcms_"
type Status string
const (
StatusActive Status = "active"
StatusExpired Status = "expired"
StatusRevoked Status = "revoked"
)
type APIKey struct {
ID uint32 `json:"id"`
UserID uint32 `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
HashedKey string `json:"hashed_key"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
func (k APIKey) StatusAt(now time.Time) Status {
if k.RevokedAt != nil {
return StatusRevoked
}
if k.ExpiresAt != nil && !now.Before(*k.ExpiresAt) {
return StatusExpired
}
return StatusActive
}
@@ -0,0 +1,6 @@
package apikey
type KeyGenerator interface {
Generate() (rawKey string, prefix string, hashedKey string)
Hash(rawKey string) string
}
@@ -0,0 +1,14 @@
package apikey
import "time"
type Repository interface {
Create(key APIKey) (APIKey, error)
Get(id uint32) (APIKey, error)
GetByHashedKey(hashedKey string) (APIKey, error)
ListByUser(userId uint32) ([]APIKey, error)
ListAll() ([]APIKey, error)
Revoke(id uint32, at time.Time) error
RevokeAllByUser(userId uint32, at time.Time) error
TouchLastUsed(id uint32, at time.Time) error
}
@@ -0,0 +1,34 @@
package hashing
import (
"RenewCMS/internal/domain/apikey"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
const (
apiKeyRandomBytes = 32
apiKeyPrefixLen = 12
)
type SHA256KeyGenerator struct{}
func NewSHA256KeyGenerator() *SHA256KeyGenerator {
return &SHA256KeyGenerator{}
}
func (g *SHA256KeyGenerator) Generate() (string, string, string) {
random := make([]byte, apiKeyRandomBytes)
_, _ = rand.Read(random)
rawKey := apikey.RawKeyPrefix + base64.RawURLEncoding.EncodeToString(random)
return rawKey, rawKey[:apiKeyPrefixLen], g.Hash(rawKey)
}
func (g *SHA256KeyGenerator) Hash(rawKey string) string {
sum := sha256.Sum256([]byte(rawKey))
return hex.EncodeToString(sum[:])
}
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
@@ -0,0 +1,34 @@
package mappers
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/persistence/models"
)
func APIKeyToDomain(m models.APIKey) apikey.APIKey {
return apikey.APIKey{
ID: m.ID,
UserID: m.UserID,
Name: m.Name,
Prefix: m.Prefix,
HashedKey: m.HashedKey,
ExpiresAt: m.ExpiresAt,
RevokedAt: m.RevokedAt,
LastUsedAt: m.LastUsedAt,
CreatedAt: m.CreatedAt,
}
}
func APIKeyToModel(k apikey.APIKey) models.APIKey {
return models.APIKey{
ID: k.ID,
UserID: k.UserID,
Name: k.Name,
Prefix: k.Prefix,
HashedKey: k.HashedKey,
ExpiresAt: k.ExpiresAt,
RevokedAt: k.RevokedAt,
LastUsedAt: k.LastUsedAt,
CreatedAt: k.CreatedAt,
}
}
@@ -0,0 +1,15 @@
package models
import "time"
type APIKey struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
UserID uint32 `gorm:"index;not null"`
Name string `gorm:"not null"`
Prefix string `gorm:"not null"`
HashedKey string `gorm:"uniqueIndex;not null"`
ExpiresAt *time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -0,0 +1,85 @@
package persistence
import (
domainAPIKey "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
type APIKeyRepository struct {
db *gorm.DB
}
func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository {
return &APIKeyRepository{db}
}
func (a *APIKeyRepository) Create(key domainAPIKey.APIKey) (domainAPIKey.APIKey, error) {
model := mappers.APIKeyToModel(key)
model.ID = 0
if err := a.db.Create(&model).Error; err != nil {
return domainAPIKey.APIKey{}, err
}
return mappers.APIKeyToDomain(model), nil
}
func (a *APIKeyRepository) Get(id uint32) (domainAPIKey.APIKey, error) {
var model entity.APIKey
if err := a.db.First(&model, id).Error; err != nil {
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
}
return mappers.APIKeyToDomain(model), nil
}
func (a *APIKeyRepository) GetByHashedKey(hashedKey string) (domainAPIKey.APIKey, error) {
var model entity.APIKey
if err := a.db.Where("hashed_key = ?", hashedKey).First(&model).Error; err != nil {
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
}
return mappers.APIKeyToDomain(model), nil
}
func (a *APIKeyRepository) ListByUser(userId uint32) ([]domainAPIKey.APIKey, error) {
return a.list(a.db.Where("user_id = ?", userId))
}
func (a *APIKeyRepository) ListAll() ([]domainAPIKey.APIKey, error) {
return a.list(a.db)
}
func (a *APIKeyRepository) list(query *gorm.DB) ([]domainAPIKey.APIKey, error) {
var found []entity.APIKey
if err := query.Order("created_at DESC, id DESC").Find(&found).Error; err != nil {
return nil, err
}
keys := make([]domainAPIKey.APIKey, 0, len(found))
for _, model := range found {
keys = append(keys, mappers.APIKeyToDomain(model))
}
return keys, nil
}
func (a *APIKeyRepository) Revoke(id uint32, at time.Time) error {
result := a.db.Model(&entity.APIKey{}).
Where("id = ? AND revoked_at IS NULL", id).
Update("revoked_at", at)
return result.Error
}
func (a *APIKeyRepository) RevokeAllByUser(userId uint32, at time.Time) error {
return a.db.Model(&entity.APIKey{}).
Where("user_id = ? AND revoked_at IS NULL", userId).
Update("revoked_at", at).Error
}
func (a *APIKeyRepository) TouchLastUsed(id uint32, at time.Time) error {
return a.db.Model(&entity.APIKey{}).
Where("id = ?", id).
Update("last_used_at", at).Error
}
var _ domainAPIKey.Repository = &APIKeyRepository{}
@@ -0,0 +1,67 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"errors"
"strings"
"time"
)
const lastUsedWriteInterval = time.Minute
type AuthenticateAPIKeyUseCase struct {
apiKeyRepository apikey.Repository
keyGenerator apikey.KeyGenerator
userRepository user.Repository
now func() time.Time
}
type AuthenticatedAPIKey struct {
Key apikey.APIKey
User user.User
}
func NewAuthenticateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, userRepository user.Repository, now func() time.Time) *AuthenticateAPIKeyUseCase {
return &AuthenticateAPIKeyUseCase{apiKeyRepository, keyGenerator, userRepository, now}
}
func (a *AuthenticateAPIKeyUseCase) AuthenticateAPIKey(rawKey string) (AuthenticatedAPIKey, error) {
if !strings.HasPrefix(rawKey, apikey.RawKeyPrefix) {
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
}
key, err := a.apiKeyRepository.GetByHashedKey(a.keyGenerator.Hash(rawKey))
if err != nil {
if errors.Is(err, apikey.ErrAPIKeyNotFound) {
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
}
return AuthenticatedAPIKey{}, err
}
now := a.now()
if key.StatusAt(now) != apikey.StatusActive {
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
}
owner, err := a.userRepository.Get(key.UserID)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
}
return AuthenticatedAPIKey{}, err
}
if !owner.IsVerified {
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
}
a.recordUse(key, now)
return AuthenticatedAPIKey{Key: key, User: owner}, nil
}
func (a *AuthenticateAPIKeyUseCase) recordUse(key apikey.APIKey, now time.Time) {
if key.LastUsedAt != nil && now.Sub(*key.LastUsedAt) < lastUsedWriteInterval {
return
}
_ = a.apiKeyRepository.TouchLastUsed(key.ID, now)
}
@@ -0,0 +1,49 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"time"
)
type CreateAPIKeyUseCase struct {
apiKeyRepository apikey.Repository
keyGenerator apikey.KeyGenerator
now func() time.Time
}
type CreateAPIKeyCommand struct {
UserID uint32
Name string
ExpiresAt *time.Time
}
type CreatedAPIKey struct {
Key apikey.APIKey
RawKey string
}
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
}
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
now := c.now()
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
}
rawKey, prefix, hashedKey := c.keyGenerator.Generate()
stored, err := c.apiKeyRepository.Create(apikey.APIKey{
UserID: command.UserID,
Name: command.Name,
Prefix: prefix,
HashedKey: hashedKey,
ExpiresAt: command.ExpiresAt,
CreatedAt: now,
})
if err != nil {
return CreatedAPIKey{}, err
}
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
}
@@ -0,0 +1,95 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"errors"
"sort"
"strings"
"time"
)
type ListAPIKeysUseCase struct {
apiKeyRepository apikey.Repository
userRepository user.Repository
now func() time.Time
}
type APIKeyEntry struct {
Key apikey.APIKey
Status apikey.Status
CreatorName string
ListsCreator bool
}
func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase {
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now}
}
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) {
requester, err := l.userRepository.Get(requesterId)
if err != nil {
return nil, err
}
keys, err := l.keysVisibleTo(requester)
if err != nil {
return nil, err
}
sort.SliceStable(keys, func(i, j int) bool {
if keys[i].CreatedAt.Equal(keys[j].CreatedAt) {
return keys[i].ID > keys[j].ID
}
return keys[i].CreatedAt.After(keys[j].CreatedAt)
})
creatorNames, err := l.creatorNamesFor(requester, keys)
if err != nil {
return nil, err
}
now := l.now()
entries := make([]APIKeyEntry, 0, len(keys))
for _, key := range keys {
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: requester.Role == user.RoleOwner})
}
return entries, nil
}
func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.APIKey) (map[uint32]string, error) {
names := map[uint32]string{}
if requester.Role != user.RoleOwner {
return names, nil
}
names[requester.ID] = displayNameOrUsername(requester)
for _, key := range keys {
if _, resolved := names[key.UserID]; resolved {
continue
}
creator, err := l.userRepository.Get(key.UserID)
if errors.Is(err, user.ErrUserNotFound) {
names[key.UserID] = ""
continue
}
if err != nil {
return nil, err
}
names[key.UserID] = displayNameOrUsername(creator)
}
return names, nil
}
func displayNameOrUsername(account user.User) string {
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
return trimmed
}
return account.Username
}
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
if requester.Role == user.RoleOwner {
return l.apiKeyRepository.ListAll()
}
return l.apiKeyRepository.ListByUser(requester.ID)
}
@@ -1,6 +1,7 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"time"
@@ -9,10 +10,12 @@ import (
type ResetPasswordUseCase struct {
userRepository user.Repository
hasher hash.Hasher
apiKeys apikey.Repository
now func() time.Time
}
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher) *ResetPasswordUseCase {
return &ResetPasswordUseCase{userRepository, hasher}
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
}
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
@@ -25,7 +28,7 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
return user.ErrInvalidPasswordResetCode
}
if time.Now().After(dbUser.PasswordResetExpiration) {
if r.now().After(dbUser.PasswordResetExpiration) {
return user.ErrPasswordResetCodeExpired
}
@@ -34,6 +37,9 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
return err
}
_, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword)
return err
if _, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword); err != nil {
return err
}
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
}
@@ -0,0 +1,39 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"time"
)
type RevokeAPIKeyUseCase struct {
apiKeyRepository apikey.Repository
userRepository user.Repository
now func() time.Time
}
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
}
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
key, err := r.apiKeyRepository.Get(keyId)
if err != nil {
return err
}
requester, err := r.userRepository.Get(requesterId)
if err != nil {
return err
}
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
return apikey.ErrAPIKeyNotFound
}
if key.RevokedAt != nil {
return nil
}
return r.apiKeyRepository.Revoke(key.ID, r.now())
}
+228
View File
@@ -0,0 +1,228 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
type apiKeyCreatorLookup struct {
user.Repository
gets map[uint32]int
failOn uint32
}
func newAPIKeyCreatorLookup(inner user.Repository) *apiKeyCreatorLookup {
return &apiKeyCreatorLookup{Repository: inner, gets: map[uint32]int{}}
}
func (a *apiKeyCreatorLookup) Get(id uint32) (user.User, error) {
a.gets[id]++
if id == a.failOn {
return user.User{}, errRepositoryFailure
}
return a.Repository.Get(id)
}
var _ user.Repository = &apiKeyCreatorLookup{}
func (e *apiKeyEnv) useCreatorLookup(lookup *apiKeyCreatorLookup) {
clock := func() time.Time { return e.now }
e.container.ListAPIKeysUseCase = useCases.NewListAPIKeysUseCase(e.keys, lookup, clock)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
e.router = router
}
func (e *apiKeyEnv) nameUsers() {
owner := e.users.users[apiKeyOwnerId]
owner.DisplayName = "Marta Alves"
e.users.users[apiKeyOwnerId] = owner
dev := e.users.users[apiKeyDevId]
dev.DisplayName = "Dev Two"
e.users.users[apiKeyDevId] = dev
}
func TestAPIKeysCreatorNameHttp(t *testing.T) {
allFields := []string{"id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at"}
t.Run("API: an owner's list carries the creator's name", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.nameUsers()
env.issueKey(apiKeyOwnerId, "a")
env.issueKey(apiKeyDevId, "b")
env.issueKey(apiKeyPatId, "c")
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyStatus(t, rec, http.StatusOK)
entries := apiKeyListedByName(t, rec)
want := map[string]string{"a": "Marta Alves", "b": "Dev Two", "c": "pat"}
for name, creator := range want {
if entries[name]["created_by_name"] != creator {
t.Errorf("entry %s: expected created_by_name %q, got %v", name, creator, entries[name]["created_by_name"])
}
apiKeyAssertExactFields(t, entries[name], append([]string{"created_by_name"}, allFields...)...)
}
})
t.Run("API: a whitespace-only display name falls back to the username", func(t *testing.T) {
env := newAPIKeyEnv(t)
pat := env.users.users[apiKeyPatId]
pat.DisplayName = " "
env.users.users[apiKeyPatId] = pat
env.issueKey(apiKeyPatId, "c")
entries := apiKeyListedByName(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId))
if entries["c"]["created_by_name"] != "pat" {
t.Errorf("expected pat, got %v", entries["c"]["created_by_name"])
}
})
t.Run("API: an admin's list is unchanged", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.nameUsers()
env.issueKey(apiKeyDevId, "a")
env.issueKey(apiKeyDevId, "b")
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
assertAPIKeyStatus(t, rec, http.StatusOK)
list := apiKeyDecodeList(t, rec)
if len(list) != 2 {
t.Fatalf("expected two entries, got %d", len(list))
}
for _, entry := range list {
apiKeyAssertExactFields(t, entry, allFields...)
}
})
t.Run("API: a key whose creator no longer exists", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.issueKey(apiKeyPatId, "gone")
env.issueKey(apiKeyDevId, "kept")
delete(env.users.users, apiKeyPatId)
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyStatus(t, rec, http.StatusOK)
entries := apiKeyListedByName(t, rec)
if name, ok := entries["gone"]["created_by_name"]; !ok || name != "" {
t.Errorf("expected an empty created_by_name, got %v", name)
}
if entries["kept"]["created_by_name"] != "dev" {
t.Errorf("expected dev, got %v", entries["kept"]["created_by_name"])
}
})
t.Run("API: a failing user lookup fails the list", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.issueKey(apiKeyDevId, "a")
lookup := newAPIKeyCreatorLookup(env.users)
lookup.failOn = apiKeyDevId
env.useCreatorLookup(lookup)
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
})
t.Run("API: the creator name is resolved once per user", func(t *testing.T) {
env := newAPIKeyEnv(t)
baseline := newAPIKeyCreatorLookup(env.users)
env.useCreatorLookup(baseline)
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
env.issueKey(apiKeyOwnerId, "o1")
env.issueKey(apiKeyOwnerId, "o2")
env.issueKey(apiKeyOwnerId, "o3")
env.issueKey(apiKeyDevId, "d1")
env.issueKey(apiKeyDevId, "d2")
lookup := newAPIKeyCreatorLookup(env.users)
env.useCreatorLookup(lookup)
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
if lookup.gets[apiKeyDevId] != 1 {
t.Errorf("expected one Get for the admin creator, got %d", lookup.gets[apiKeyDevId])
}
if lookup.gets[apiKeyOwnerId] != baseline.gets[apiKeyOwnerId] {
t.Errorf("expected no extra Get for the owner, got %d against %d", lookup.gets[apiKeyOwnerId], baseline.gets[apiKeyOwnerId])
}
})
}
func TestListAPIKeysCreatorName(t *testing.T) {
seed := func() *mockAPIKeyRepo {
repo := newMockAPIKeyRepo()
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 1 })
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2 })
return repo
}
users := func() *mockUserRepo {
repo := apiKeyUnitUsers()
marta := repo.users[1]
marta.DisplayName = "Marta Alves"
repo.users[1] = marta
return repo
}
t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
ownerEntries, err := uc.ListAPIKeys(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
names := map[string]string{}
for _, entry := range ownerEntries {
names[entry.Key.Prefix] = entry.CreatorName
}
if names["rcms_a"] != "Marta Alves" || names["rcms_b"] != "dev" {
t.Errorf("unexpected creator names %v", names)
}
adminEntries, err := uc.ListAPIKeys(2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" {
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries)
}
})
}
type apiKeyDuplicateIdRepo struct {
apikey.Repository
keys []apikey.APIKey
}
func (a *apiKeyDuplicateIdRepo) ListAll() ([]apikey.APIKey, error) {
return append([]apikey.APIKey(nil), a.keys...), nil
}
var _ apikey.Repository = &apiKeyDuplicateIdRepo{}
func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) {
createdAt := apiKeyUnitNow.Add(-time.Hour)
repo := &apiKeyDuplicateIdRepo{keys: []apikey.APIKey{
{ID: 7, UserID: 1, Name: "first", CreatedAt: createdAt},
{ID: 7, UserID: 1, Name: "second", CreatedAt: createdAt},
}}
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
entries, err := uc.ListAPIKeys(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 || entries[0].Key.Name != "first" || entries[1].Key.Name != "second" {
t.Errorf("expected a stable order for identical keys, got %+v", entries)
}
}
File diff suppressed because it is too large. Load diff
+642
View File
@@ -0,0 +1,642 @@
package test
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases"
"crypto/sha256"
"encoding/hex"
"errors"
"regexp"
"sort"
"testing"
"time"
)
type apiKeyRevocation struct {
userId uint32
at time.Time
}
type mockAPIKeyRepo struct {
keys map[uint32]apikey.APIKey
nextID uint32
fail failSwitch
createCalls []apikey.APIKey
touchCalls []time.Time
revokeCalls int
revokeAllCalls []apiKeyRevocation
getByHashedCalls int
}
func newMockAPIKeyRepo() *mockAPIKeyRepo {
return &mockAPIKeyRepo{keys: make(map[uint32]apikey.APIKey)}
}
func (m *mockAPIKeyRepo) sorted() []apikey.APIKey {
result := make([]apikey.APIKey, 0, len(m.keys))
for _, key := range m.keys {
result = append(result, key)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (m *mockAPIKeyRepo) Create(key apikey.APIKey) (apikey.APIKey, error) {
if err := m.fail.trigger("Create"); err != nil {
return apikey.APIKey{}, err
}
m.createCalls = append(m.createCalls, key)
m.nextID++
key.ID = m.nextID
m.keys[key.ID] = key
return key, nil
}
func (m *mockAPIKeyRepo) Get(id uint32) (apikey.APIKey, error) {
if err := m.fail.trigger("Get"); err != nil {
return apikey.APIKey{}, err
}
key, ok := m.keys[id]
if !ok {
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
}
return key, nil
}
func (m *mockAPIKeyRepo) GetByHashedKey(hashedKey string) (apikey.APIKey, error) {
m.getByHashedCalls++
if err := m.fail.trigger("GetByHashedKey"); err != nil {
return apikey.APIKey{}, err
}
for _, key := range m.sorted() {
if key.HashedKey == hashedKey {
return key, nil
}
}
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
}
func (m *mockAPIKeyRepo) ListByUser(userId uint32) ([]apikey.APIKey, error) {
if err := m.fail.trigger("ListByUser"); err != nil {
return nil, err
}
result := make([]apikey.APIKey, 0)
for _, key := range m.sorted() {
if key.UserID == userId {
result = append(result, key)
}
}
return result, nil
}
func (m *mockAPIKeyRepo) ListAll() ([]apikey.APIKey, error) {
if err := m.fail.trigger("ListAll"); err != nil {
return nil, err
}
return m.sorted(), nil
}
func (m *mockAPIKeyRepo) Revoke(id uint32, at time.Time) error {
m.revokeCalls++
if err := m.fail.trigger("Revoke"); err != nil {
return err
}
key, ok := m.keys[id]
if !ok || key.RevokedAt != nil {
return nil
}
key.RevokedAt = &at
m.keys[id] = key
return nil
}
func (m *mockAPIKeyRepo) RevokeAllByUser(userId uint32, at time.Time) error {
m.revokeAllCalls = append(m.revokeAllCalls, apiKeyRevocation{userId, at})
if err := m.fail.trigger("RevokeAllByUser"); err != nil {
return err
}
for id, key := range m.keys {
if key.UserID == userId && key.RevokedAt == nil {
revokedAt := at
key.RevokedAt = &revokedAt
m.keys[id] = key
}
}
return nil
}
func (m *mockAPIKeyRepo) TouchLastUsed(id uint32, at time.Time) error {
m.touchCalls = append(m.touchCalls, at)
if err := m.fail.trigger("TouchLastUsed"); err != nil {
return err
}
key, ok := m.keys[id]
if !ok {
return apikey.ErrAPIKeyNotFound
}
key.LastUsedAt = &at
m.keys[id] = key
return nil
}
var _ apikey.Repository = &mockAPIKeyRepo{}
type stubKeyGenerator struct {
raw string
prefix string
}
func (s *stubKeyGenerator) Generate() (string, string, string) {
return s.raw, s.prefix, s.Hash(s.raw)
}
func (s *stubKeyGenerator) Hash(rawKey string) string {
return "hash:" + rawKey
}
var _ apikey.KeyGenerator = &stubKeyGenerator{}
var apiKeyUnitNow = time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC)
func apiKeyUnitClock() time.Time {
return apiKeyUnitNow
}
func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) apikey.APIKey {
key := apikey.APIKey{UserID: 2, Name: "k", Prefix: raw, HashedKey: "hash:" + raw, CreatedAt: apiKeyUnitNow.Add(-time.Hour)}
if mutate != nil {
mutate(&key)
}
stored, _ := repo.Create(key)
return stored
}
func apiKeyUnitUsers() *mockUserRepo {
users := newMockUserRepo()
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3}
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin}
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
return users
}
func TestSHA256KeyGenerator(t *testing.T) {
generator := hashing.NewSHA256KeyGenerator()
rawKeyPattern := regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`)
t.Run("A generated key has the rcms_ format, a 12 character prefix and its SHA-256 hash", func(t *testing.T) {
raw, prefix, hashed := generator.Generate()
if !rawKeyPattern.MatchString(raw) {
t.Fatalf("unexpected raw key %q", raw)
}
if prefix != raw[:12] {
t.Errorf("expected prefix %q, got %q", raw[:12], prefix)
}
sum := sha256.Sum256([]byte(raw))
if hashed != hex.EncodeToString(sum[:]) {
t.Errorf("expected the SHA-256 of the raw key, got %q", hashed)
}
})
t.Run("Hash is deterministic and never the raw key", func(t *testing.T) {
if generator.Hash("rcms_abc") != generator.Hash("rcms_abc") {
t.Errorf("expected a deterministic hash")
}
if generator.Hash("rcms_abc") == generator.Hash("rcms_abd") {
t.Errorf("expected different hashes for different keys")
}
})
t.Run("Two generated keys differ", func(t *testing.T) {
first, _, _ := generator.Generate()
second, _, _ := generator.Generate()
if first == second {
t.Errorf("expected two different keys")
}
})
}
func TestCreateAPIKeyUseCase(t *testing.T) {
t.Run("Use case: creating a key hashes it and stores no plaintext", func(t *testing.T) {
repo := newMockAPIKeyRepo()
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
created, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(repo.createCalls) != 1 {
t.Fatalf("expected one Create call, got %d", len(repo.createCalls))
}
stored := repo.createCalls[0]
if stored.HashedKey != "hash:rcms_fake" || stored.Prefix != "rcms_fake" || stored.UserID != 2 || stored.Name != "ci" {
t.Errorf("unexpected stored key: %+v", stored)
}
if !stored.CreatedAt.Equal(apiKeyUnitNow) {
t.Errorf("expected CreatedAt from the clock, got %v", stored.CreatedAt)
}
if created.RawKey != "rcms_fake" {
t.Errorf("expected the raw key in the result, got %q", created.RawKey)
}
if stored.HashedKey == created.RawKey || stored.Prefix == "" {
t.Errorf("the raw key must not be stored")
}
})
t.Run("An expiry that is not in the future is refused and nothing is stored", func(t *testing.T) {
for name, expiry := range map[string]time.Time{"in the past": apiKeyUnitNow.Add(-time.Hour), "exactly now": apiKeyUnitNow} {
repo := newMockAPIKeyRepo()
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
_, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry})
if !errors.Is(err, apikey.ErrExpiryNotInFuture) {
t.Errorf("%s: expected ErrExpiryNotInFuture, got %v", name, err)
}
if len(repo.createCalls) != 0 {
t.Errorf("%s: expected nothing stored", name)
}
}
})
t.Run("A repository failure is returned", func(t *testing.T) {
repo := newMockAPIKeyRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
if _, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
})
}
func TestAuthenticateAPIKeyUseCase(t *testing.T) {
newUseCase := func(repo *mockAPIKeyRepo, users *mockUserRepo) *useCases.AuthenticateAPIKeyUseCase {
return useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, users, apiKeyUnitClock)
}
t.Run("Use case: authenticating returns the user and rejects every invalid case with one sentinel", func(t *testing.T) {
repo := newMockAPIKeyRepo()
expiredAt := apiKeyUnitNow.Add(-time.Second)
exactlyNow := apiKeyUnitNow
revokedAt := apiKeyUnitNow.Add(-time.Minute)
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expiredAt })
seedStubKey(repo, "rcms_exactlynow", func(k *apikey.APIKey) { k.ExpiresAt = &exactlyNow })
seedStubKey(repo, "rcms_revoked", func(k *apikey.APIKey) { k.RevokedAt = &revokedAt })
seedStubKey(repo, "rcms_missinguser", func(k *apikey.APIKey) { k.UserID = 99 })
seedStubKey(repo, "rcms_unverified", func(k *apikey.APIKey) { k.UserID = 4 })
uc := newUseCase(repo, apiKeyUnitUsers())
for _, raw := range []string{"rcms_unknown", "rcms_", "", "nope", "rcms_expired", "rcms_exactlynow", "rcms_revoked", "rcms_missinguser", "rcms_unverified"} {
if _, err := uc.AuthenticateAPIKey(raw); !errors.Is(err, apikey.ErrAPIKeyInvalid) {
t.Errorf("%q: expected ErrAPIKeyInvalid, got %v", raw, err)
}
}
if len(repo.touchCalls) != 0 {
t.Errorf("a rejected key must never record last_used_at")
}
})
t.Run("A valid key returns the user from the user repository", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_valid", nil)
uc := newUseCase(repo, apiKeyUnitUsers())
authenticated, err := uc.AuthenticateAPIKey("rcms_valid")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if authenticated.User.ID != 2 || authenticated.User.TokenVersion != 3 || authenticated.Key.ID != stored.ID {
t.Errorf("unexpected result: %+v", authenticated)
}
})
t.Run("A key expiring in the future is valid", func(t *testing.T) {
repo := newMockAPIKeyRepo()
later := apiKeyUnitNow.Add(time.Second)
seedStubKey(repo, "rcms_later", func(k *apikey.APIKey) { k.ExpiresAt = &later })
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_later"); err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("Use case: a failing repository is not reported as an invalid key", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seedStubKey(repo, "rcms_valid", nil)
repo.fail.failNext("GetByHashedKey")
_, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid")
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
t.Errorf("expected the raw repository failure, got %v", err)
}
})
t.Run("A failing user repository is not reported as an invalid key", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seedStubKey(repo, "rcms_valid", nil)
users := apiKeyUnitUsers()
users.fail.failNext("Get")
_, err := newUseCase(repo, users).AuthenticateAPIKey("rcms_valid")
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
t.Errorf("expected the raw repository failure, got %v", err)
}
})
t.Run("Using a key records last_used_at at most once per minute", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_valid", nil)
now := apiKeyUnitNow
uc := useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, apiKeyUnitUsers(), func() time.Time { return now })
steps := []struct {
offset time.Duration
writes int
lastUsed time.Duration
}{
{0, 1, 0},
{30 * time.Second, 1, 0},
{60 * time.Second, 2, 60 * time.Second},
{61*time.Second + 30*time.Second, 2, 60 * time.Second},
{130 * time.Second, 3, 130 * time.Second},
}
for _, step := range steps {
now = apiKeyUnitNow.Add(step.offset)
if _, err := uc.AuthenticateAPIKey("rcms_valid"); err != nil {
t.Fatalf("unexpected error at +%v: %v", step.offset, err)
}
if len(repo.touchCalls) != step.writes {
t.Errorf("at +%v: expected %d writes, got %d", step.offset, step.writes, len(repo.touchCalls))
}
if got := repo.keys[stored.ID].LastUsedAt; got == nil || !got.Equal(apiKeyUnitNow.Add(step.lastUsed)) {
t.Errorf("at +%v: expected last_used_at +%v, got %v", step.offset, step.lastUsed, got)
}
}
})
t.Run("A failure to record last_used_at doesn't fail the authentication", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seedStubKey(repo, "rcms_valid", nil)
repo.fail.failNext("TouchLastUsed")
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid"); err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
func TestListAPIKeysUseCase(t *testing.T) {
seed := func(repo *mockAPIKeyRepo) {
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-3 * time.Hour) })
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-2 * time.Hour) })
seedStubKey(repo, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3; k.CreatedAt = apiKeyUnitNow.Add(-90 * time.Minute) })
seedStubKey(repo, "rcms_d", func(k *apikey.APIKey) { k.UserID = 1; k.CreatedAt = apiKeyUnitNow.Add(-time.Hour) })
}
prefixes := func(entries []useCases.APIKeyEntry) []string {
result := make([]string, 0, len(entries))
for _, entry := range entries {
result = append(result, entry.Key.Prefix)
}
return result
}
t.Run("An admin lists only their own keys, newest first", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := prefixes(entries); len(got) != 2 || got[0] != "rcms_b" || got[1] != "rcms_a" {
t.Errorf("unexpected listing %v", got)
}
})
t.Run("The owner lists every key, newest first", func(t *testing.T) {
repo := newMockAPIKeyRepo()
seed(repo)
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := prefixes(entries)
want := []string{"rcms_d", "rcms_c", "rcms_b", "rcms_a"}
for i := range want {
if len(got) != len(want) || got[i] != want[i] {
t.Fatalf("expected %v, got %v", want, got)
}
}
})
t.Run("Keys created at the same instant are listed by descending id", func(t *testing.T) {
repo := newMockAPIKeyRepo()
same := apiKeyUnitNow.Add(-time.Hour)
for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same })
}
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := prefixes(entries)
want := []string{"rcms_third", "rcms_second", "rcms_first"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Errorf("expected %v, got %v", want, got)
}
})
t.Run("Each entry carries its status at the clock's now", func(t *testing.T) {
repo := newMockAPIKeyRepo()
expired := apiKeyUnitNow.Add(-time.Hour)
revoked := apiKeyUnitNow.Add(-time.Minute)
seedStubKey(repo, "rcms_active", nil)
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked })
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
statuses := map[string]apikey.Status{}
for _, entry := range entries {
statuses[entry.Key.Prefix] = entry.Status
}
if statuses["rcms_active"] != apikey.StatusActive || statuses["rcms_expired"] != apikey.StatusExpired || statuses["rcms_both"] != apikey.StatusRevoked {
t.Errorf("unexpected statuses %v", statuses)
}
})
t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
repo := newMockAPIKeyRepo()
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
repo.fail.failNext("ListByUser")
if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
repo.fail.failNext("ListAll")
if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
})
}
func TestRevokeAPIKeyUseCase(t *testing.T) {
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
t.Errorf("expected revoked_at %v, got %v", apiKeyUnitNow, got)
}
})
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if err := uc.RevokeAPIKey(3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
}
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
t.Errorf("expected the key untouched")
}
})
t.Run("The owner revokes any key", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if err := uc.RevokeAPIKey(1, stored.ID); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.keys[stored.ID].RevokedAt == nil {
t.Errorf("expected the key revoked")
}
})
t.Run("Revoking an already revoked key keeps its original revoked_at", func(t *testing.T) {
repo := newMockAPIKeyRepo()
original := apiKeyUnitNow.Add(-time.Hour)
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
t.Errorf("expected revoked_at %v, got %v", original, got)
}
})
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
repo := newMockAPIKeyRepo()
stored := seedStubKey(repo, "rcms_a", nil)
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
if err := uc.RevokeAPIKey(2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
}
if err := uc.RevokeAPIKey(99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
repo.fail.failNext("Revoke")
if err := uc.RevokeAPIKey(2, stored.ID); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
})
}
func TestResetPasswordRevokesAPIKeys(t *testing.T) {
resetUser := func(expiration time.Time) *mockUserRepo {
repo := newMockUserRepo()
repo.users[2] = user.User{ID: 2, Email: "dev@example.com", Password: "hashed:old", PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: expiration}
repo.users[3] = user.User{ID: 3, Email: "pat@example.com"}
return repo
}
t.Run("Use case: ResetPassword revokes the user's keys through the domain repository", func(t *testing.T) {
keys := newMockAPIKeyRepo()
seedStubKey(keys, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2 })
other := seedStubKey(keys, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3 })
uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock)
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(keys.revokeAllCalls) != 1 || keys.revokeAllCalls[0].userId != 2 || !keys.revokeAllCalls[0].at.Equal(apiKeyUnitNow) {
t.Errorf("expected exactly one RevokeAllByUser(2, T), got %+v", keys.revokeAllCalls)
}
if keys.keys[other.ID].RevokedAt != nil {
t.Errorf("expected user 3's key untouched")
}
})
t.Run("Use case: ResetPassword does not call RevokeAllByUser when the reset fails", func(t *testing.T) {
cases := []struct {
name string
expiration time.Time
code string
email string
want error
}{
{"wrong code", apiKeyUnitNow.Add(time.Hour), "wrong", "dev@example.com", user.ErrInvalidPasswordResetCode},
{"expired code", apiKeyUnitNow.Add(-time.Second), "reset-code-1", "dev@example.com", user.ErrPasswordResetCodeExpired},
{"unknown email", apiKeyUnitNow.Add(time.Hour), "reset-code-1", "nobody@example.com", user.ErrInvalidPasswordResetCode},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
keys := newMockAPIKeyRepo()
uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock)
if err := uc.ResetPassword(tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) {
t.Errorf("expected %v, got %v", tc.want, err)
}
if len(keys.revokeAllCalls) != 0 {
t.Errorf("expected RevokeAllByUser never called")
}
})
}
})
t.Run("A failed password change and a failed hash revoke nothing", func(t *testing.T) {
keys := newMockAPIKeyRepo()
users := resetUser(apiKeyUnitNow.Add(time.Hour))
users.fail.failNext("ResetPassword")
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the repository failure, got %v", err)
}
hashErr := errors.New("hash failed")
uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock)
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) {
t.Errorf("expected the hash error, got %v", err)
}
if len(keys.revokeAllCalls) != 0 {
t.Errorf("expected RevokeAllByUser never called")
}
})
t.Run("Use case: a failure to revoke keys after a reset is reported", func(t *testing.T) {
keys := newMockAPIKeyRepo()
keys.fail.failNext("RevokeAllByUser")
users := resetUser(apiKeyUnitNow.Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1")
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the revoke failure, got %v", err)
}
if users.users[2].Password != "hashed:newpassword1" {
t.Errorf("expected the password already changed")
}
})
}
@@ -42,7 +42,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
)
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
+1 -1
View File
@@ -19,7 +19,7 @@ func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRep
hasher := &stubHasher{}
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
@@ -13,6 +13,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
@@ -46,7 +47,7 @@ func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseR
hasher := resetMailFailureHasher{}
handler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
router := chi.NewRouter()
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
+1 -1
View File
@@ -19,7 +19,7 @@ func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux
mailRepo := newMockMailRepo()
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
+9 -9
View File
@@ -22,7 +22,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("The use case itself sets the password and clears the code in one write, independently of any handler", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
@@ -49,7 +49,7 @@ func TestResetPasswordUseCase(t *testing.T) {
PasswordResetCode: "hashed:reset-code-1",
PasswordResetExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
@@ -63,7 +63,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "wrong-code", "newpassword1")
@@ -77,7 +77,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("The use case rejects an expired code without touching the repository's ResetPassword method", func(t *testing.T) {
repo := newRepo(time.Now().Add(-time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
@@ -91,7 +91,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
@@ -107,7 +107,7 @@ func TestResetPasswordUseCase(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
hasher := &stubHasher{}
uc := useCases.NewResetPasswordUseCase(repo, hasher)
uc := useCases.NewResetPasswordUseCase(repo, hasher, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
@@ -125,7 +125,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("The use case treats any GetByEmail failure, not just not-found, as an invalid code", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
@@ -140,7 +140,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("A hashing failure propagates and the repository's ResetPassword method is never called", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
hashErr := errors.New("hash blew up")
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
@@ -155,7 +155,7 @@ func TestResetPasswordUseCase(t *testing.T) {
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
repo.fail.failNext("ResetPassword")
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
+1 -1
View File
@@ -43,7 +43,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
)
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
@@ -29,7 +29,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
)
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
+1 -1
View File
@@ -32,7 +32,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
)
passwordResetHandler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
)
r := chi.NewRouter()
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ApiField } from '@/data/apiCatalogue'
defineProps<{
fields: ApiField[]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
accessorKey: 'name',
get header() {
return t('Name')
},
...fixed('w-33'),
},
{
accessorKey: 'type',
get header() {
return t('Type')
},
...fixed('w-21'),
},
{
accessorKey: 'required',
get header() {
return t('Required')
},
...fixed('w-21'),
},
{
accessorKey: 'rule',
get header() {
return t('Rule')
},
},
])
</script>
<template>
<UTable
:data="fields"
:columns="columns"
:ui="{
root: 'border border-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #name-cell="{ row }">
<span class="font-mono text-xs font-medium break-words text-highlighted">{{
row.original.name
}}</span>
</template>
<template #type-cell="{ row }">
<span class="font-mono text-xs">{{ row.original.type }}</span>
</template>
<template #required-cell="{ row }">
{{ row.original.required ? t('Yes') : t('No') }}
</template>
<template #rule-cell="{ row }">
<span class="text-pretty">{{ row.original.rule }}</span>
</template>
</UTable>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import {
creatorLabel,
displayPrefix,
expiresLabel,
formatKeyDate,
lastUsedLabel,
} from '@/lib/apiKeys'
defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
function createdText(key: ApiKey, showCreatedBy: boolean) {
const date = formatKeyDate(key.created_at, '')
return showCreatedBy
? t('Created {0} by {1}', [date, creatorLabel(key)])
: t('Created {0}', [date])
}
function detailsText(key: ApiKey, showCreatedBy: boolean) {
return [
createdText(key, showCreatedBy),
t('Last used {0}', [lastUsedLabel(key)]),
t('Expires {0}', [expiresLabel(key)]),
].join(' · ')
}
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<li
v-for="n in API_KEY_SKELETON_ROWS"
:key="n"
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
>
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li
v-for="key in keys"
:key="key.id"
data-testid="api-key-card"
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
:class="key.status === 'revoked' ? 'bg-muted' : 'bg-default'"
>
<div class="flex items-center gap-2">
<span
class="min-w-0 flex-1 truncate text-[0.9375rem]/5 font-medium"
:class="key.status === 'revoked' ? 'text-muted' : 'text-highlighted'"
>{{ key.name }}</span
>
<ApiKeyStatusBadge :status="key.status" />
</div>
<span
class="font-mono text-xs leading-[normal]"
:class="key.status === 'revoked' ? 'text-muted' : 'text-toned'"
>{{ displayPrefix(key.prefix) }}</span
>
<span :class="key.status === 'revoked' ? 'text-muted' : 'text-toned'">{{
detailsText(key, showCreatedBy)
}}</span>
<UButton
v-if="key.status !== 'revoked'"
color="neutral"
variant="outline"
class="h-11 self-start border border-accented bg-default px-3.5 text-sm font-medium text-highlighted ring-0"
@click="emit('revoke', key)"
>
{{ t('Revoke') }}
</UButton>
</li>
</template>
</ul>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
name: string
apiKey: string
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ done: [] }>()
const { copied, copy } = useCopyToClipboard()
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const description = computed(() =>
t('"{0}" is ready. Copy the key and store it somewhere safe.', [props.name]),
)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
watch(
open,
async (isOpen) => {
if (!isOpen) {
return
}
await nextTick()
await nextTick()
copyButton.value?.$el?.focus()
},
{ immediate: true },
)
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
emit('done')
}
}
function keepOpenOnOutsideInteraction(event: Event) {
event.preventDefault()
}
</script>
<template>
<UModal
:open="open"
:title="t('Your new API key')"
:description="description"
:close="false"
:content="{
onPointerDownOutside: keepOpenOnOutsideInteraction,
onInteractOutside: keepOpenOnOutsideInteraction,
}"
:ui="{
content:
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
title: 'text-[1.125rem]/6.5 font-semibold',
description: 'mt-1 text-base/5.5 text-toned text-pretty',
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-3.5">
<UAlert
role="alert"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('You won\'t be able to see this key again.')"
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
/>
<div class="flex flex-col border border-accented bg-elevated md:flex-row">
<span
class="min-w-0 flex-1 px-3 py-2.5 font-mono text-code/5 break-all text-highlighted"
>{{ apiKey }}</span
>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-11 justify-center gap-1.5 rounded-none border-t border-accented px-3.5 font-medium md:border-t-0 md:border-l"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copy(apiKey)"
>
{{ copyLabel }}
</UButton>
</div>
<span class="text-muted">{{
t('Closing this window discards the key from the screen. Copy it first.')
}}</span>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col sm:flex-row sm:justify-end">
<UButton
block
class="h-11 justify-center sm:w-auto sm:px-3.5"
@click="emit('done')"
>
{{ t('Done') }}
</UButton>
</div>
</template>
</UModal>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ApiKeyStatus } from '@/composables/useApiKeys'
const props = defineProps<{ status: ApiKeyStatus }>()
const badges = {
active: {
get label() {
return t('active')
},
color: 'success',
class: 'bg-(--rc-success-soft) text-success',
},
expired: {
get label() {
return t('expired')
},
color: 'warning',
class: 'bg-(--rc-warning-soft) text-warning',
},
revoked: {
get label() {
return t('revoked')
},
color: 'neutral',
class: 'bg-elevated text-muted',
},
} as const
const badge = computed(() => badges[props.status])
</script>
<template>
<UBadge
:color="badge.color"
variant="soft"
:class="badge.class"
:ui="{ base: 'h-5 px-1.5 py-0 text-xs' }"
>
{{ badge.label }}
</UBadge>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import {
creatorLabel,
displayPrefix,
expiresLabel,
formatKeyDate,
lastUsedLabel,
} from '@/lib/apiKeys'
const props = defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
type Row = ApiKey | { skeleton: true; id: number }
const skeletonRows: Row[] = Array.from({ length: API_KEY_SKELETON_ROWS }, (_, index) => ({
skeleton: true,
id: index,
}))
const data = computed<Row[]>(() => (props.loading ? skeletonRows : props.keys))
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
id: 'name',
get header() {
return t('Name')
},
},
{
id: 'prefix',
get header() {
return t('Key')
},
...fixed('w-30.5'),
},
{
id: 'status',
get header() {
return t('Status')
},
...fixed('w-22'),
},
...(props.showCreatedBy
? [
{
id: 'created_by',
get header() {
return t('Created by')
},
...fixed('w-54.75'),
},
]
: []),
{
id: 'created_at',
get header() {
return t('Created')
},
...fixed('w-28'),
},
{
id: 'last_used_at',
get header() {
return t('Last used')
},
...fixed('w-28'),
},
{
id: 'expires_at',
get header() {
return t('Expires')
},
...fixed('w-28'),
},
{ id: 'actions', header: '', ...fixed('w-24.5') },
])
const meta = {
class: {
tr: (row: { original: Row }) => {
const original = row.original
return 'skeleton' in original || original.status !== 'revoked' ? '' : 'bg-muted'
},
},
}
function key(row: { original: Row }) {
return row.original as ApiKey
}
function isSkeleton(row: { original: Row }) {
return 'skeleton' in row.original
}
function isRevoked(row: { original: Row }) {
return !isSkeleton(row) && key(row).status === 'revoked'
}
function mutedClass(row: { original: Row }) {
return isRevoked(row) ? 'text-muted' : 'text-toned'
}
</script>
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<span v-if="loading" role="status" class="sr-only">{{ t('Loading API keys') }}</span>
<UTable
:data="data"
:columns="columns"
:meta="meta"
:ui="{
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'h-9 px-1.5 py-0 first:pl-3 last:pr-3',
td: 'h-12 px-1.5 py-0 first:pl-3 last:pr-3',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #name-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
<span
v-else
class="block truncate font-medium"
:class="isRevoked(row) ? 'text-muted' : 'text-highlighted'"
>{{ key(row).name }}</span
>
</template>
<template #prefix-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[70%] rounded-none bg-accented" />
<span v-else class="font-mono text-xs" :class="mutedClass(row)">{{
displayPrefix(key(row).prefix)
}}</span>
</template>
<template #status-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-5 w-14 rounded-none bg-accented" />
<ApiKeyStatusBadge v-else :status="key(row).status" />
</template>
<template #created_by-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
<span v-else class="block truncate" :class="mutedClass(row)">{{
creatorLabel(key(row))
}}</span>
</template>
<template #created_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
formatKeyDate(key(row).created_at, '')
}}</span>
</template>
<template #last_used_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
lastUsedLabel(key(row))
}}</span>
</template>
<template #expires_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
expiresLabel(key(row))
}}</span>
</template>
<template #actions-cell="{ row }">
<span v-if="!isSkeleton(row) && !isRevoked(row)" class="flex justify-end">
<UButton
color="neutral"
variant="outline"
size="xs"
class="h-7 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
@click="emit('revoke', key(row))"
>
{{ t('Revoke') }}
</UButton>
</span>
</template>
</UTable>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HttpMethod } from '@/data/apiCatalogue'
defineProps<{
method: HttpMethod
path: string
description: string
}>()
</script>
<template>
<div
class="flex min-h-9 flex-col gap-0.5 px-3.5 py-2 lg:grid lg:grid-cols-[280px_minmax(0,1fr)_auto] lg:items-center lg:gap-4 lg:py-0"
>
<span class="flex min-w-0 items-center gap-2.5">
<span class="w-14 shrink-0 font-mono text-xs font-medium text-muted">{{ method }}</span>
<span class="min-w-0 truncate font-mono text-sm leading-4.25 text-toned">{{
path
}}</span>
</span>
<span class="text-muted">{{ description }}</span>
<slot />
</div>
</template>
+4 -4
View File
@@ -30,7 +30,7 @@ function onCopy() {
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-2 pl-4">
<div class="flex h-8 items-center justify-between border-b border-default pr-1 pl-3">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
@@ -41,7 +41,7 @@ function onCopy() {
size="xs"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
class="gap-1.5 px-2 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
@@ -51,13 +51,13 @@ function onCopy() {
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre"
class="shiki-block overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre text-default"
class="overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { splitInlineCode } from '@/lib/apiReference'
defineProps<{
text: string
}>()
</script>
<template>
<template v-for="(part, index) in splitInlineCode(text)" :key="index">
<code v-if="part.code" class="font-mono text-xs text-highlighted">{{ part.value }}</code>
<template v-else>{{ part.value }}</template>
</template>
</template>
+9 -1
View File
@@ -80,6 +80,13 @@ async function onConfirm() {
}
}
function onOpenChange(value: boolean) {
if (!value && pending.value) {
return
}
open.value = value
}
function onCancel() {
if (pending.value) {
return
@@ -90,10 +97,11 @@ function onCancel() {
<template>
<UModal
v-model:open="open"
:open="open"
:title="title"
:description="description"
:dismissible="!pending"
@update:open="onOpenChange"
:close="false"
:content="{
role: 'alertdialog',
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useApiKeys, type CreateApiKeyInput, type CreatedApiKey } from '@/composables/useApiKeys'
import { expiryInstant, isFutureExpiry } from '@/lib/apiKeys'
const NAME_MAX_LENGTH = 60
const fieldUi = { help: 'mt-1.5 text-sm/5 text-muted', error: 'mt-1.5 text-sm/5' }
const OPTIONAL_SUFFIX = /\s*\([^)]*\)$/
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [created: CreatedApiKey] }>()
const { createApiKey } = useApiKeys()
const name = ref('')
const expiry = ref('')
const submitted = ref(false)
const focused = ref(false)
const submitting = ref(false)
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed' | 'network'>('')
const nameLength = computed(() => [...name.value].length)
const trimmedName = computed(() => name.value.trim())
const nameError = computed(() => {
if (nameLength.value > NAME_MAX_LENGTH) {
return t('Use 60 characters or fewer.')
}
if (submitted.value && !trimmedName.value) {
return t('Enter a name.')
}
return ''
})
const expiryError = computed(() =>
submitted.value && expiry.value && !isFutureExpiry(expiry.value)
? t('Choose a date in the future.')
: '',
)
const expiryLabel = computed(() => t('Expiry date (optional)').replace(OPTIONAL_SUFFIX, ''))
const expiryOptional = computed(
() => OPTIONAL_SUFFIX.exec(t('Expiry date (optional)'))?.[0].trim() ?? '',
)
const alertMessage = computed(() => {
switch (formAlert.value) {
case 'invalid':
return t("Something in the form isn't valid. Check the highlighted fields.")
case 'rateLimited':
return t('Too many requests. Wait a moment, then try again.')
case 'network':
return t("Couldn't reach RenewCMS. Check your connection and try again.")
case 'failed':
return t("Couldn't create the key. Try again in a moment.")
default:
return ''
}
})
function resetForm() {
name.value = ''
expiry.value = ''
submitted.value = false
focused.value = false
submitting.value = false
formAlert.value = ''
}
async function focusNameField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[name="name"]')?.focus()
focused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusNameField()
},
{ immediate: true },
)
function showFailure(error: unknown) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return
}
if (error instanceof ApiError && error.status === 400) {
formAlert.value = 'invalid'
} else if (error instanceof ApiError && error.status === 429) {
formAlert.value = 'rateLimited'
} else if (error instanceof NetworkError) {
formAlert.value = 'network'
} else {
formAlert.value = 'failed'
}
}
async function onSubmit() {
if (submitting.value) {
return
}
submitted.value = true
focused.value = false
if (!trimmedName.value || nameLength.value > NAME_MAX_LENGTH || expiryError.value) {
return
}
const input: CreateApiKeyInput = { name: trimmedName.value }
if (expiry.value) {
input.expires_at = expiryInstant(expiry.value)
}
formAlert.value = ''
submitting.value = true
try {
const created = await createApiKey(input)
emit('created', created)
open.value = false
resetForm()
} catch (error) {
showFailure(error)
} finally {
submitting.value = false
}
}
function onCancel() {
if (submitting.value) {
return
}
open.value = false
}
function onOpenChange(isOpen: boolean) {
if (!isOpen && submitting.value) {
return
}
open.value = isOpen
}
function keepOpenOnOutsideInteraction(event: Event) {
event.preventDefault()
}
</script>
<template>
<UModal
:open="open"
:title="t('Create API key')"
:description="t('The key acts as you, with your role. It is shown in full once.')"
:dismissible="!submitting"
:close="false"
:content="{
onPointerDownOutside: keepOpenOnOutsideInteraction,
onInteractOutside: keepOpenOnOutsideInteraction,
}"
:ui="{
content:
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
title: 'text-[1.125rem]/6.5 font-semibold',
description: 'mt-1 text-base/5.5 text-toned text-pretty',
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-3.5">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ name, expiry }" class="flex flex-col gap-3.5" @submit="onSubmit">
<UFormField
:label="t('Name')"
name="name"
:error="nameError || undefined"
:help="t('Required. Up to 60 characters.')"
:ui="fieldUi"
>
<template #hint>
<CharacterCounter
:value="nameLength"
:max="NAME_MAX_LENGTH"
variant="field"
/>
</template>
<UInput
v-model="name"
name="name"
autocomplete="off"
:disabled="submitting"
color="primary"
:highlight="focused || !!nameError"
:ui="{ base: 'h-10' }"
class="w-full"
@focus="focused = true"
@blur="focused = false"
/>
</UFormField>
<UFormField
name="expires_at"
:ui="fieldUi"
:error="expiryError || undefined"
:help="
t(
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
)
"
>
<template #label>
{{ expiryLabel }}
<span v-if="expiryOptional" class="font-normal text-muted">{{
expiryOptional
}}</span>
</template>
<UInput
v-model="expiry"
type="date"
name="expires_at"
:disabled="submitting"
color="primary"
:highlight="!!expiryError"
:ui="{ base: 'h-10' }"
class="w-full"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 sm:w-auto sm:px-3.5"
:disabled="submitting"
@click="onCancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
block
class="h-11 justify-center sm:w-auto sm:px-3.5"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? t('Creating…') : t('Create key') }}
</UButton>
</div>
</template>
</UModal>
</template>
+21 -1
View File
@@ -18,6 +18,9 @@ import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
defineProps<{
mobileTitle?: string
backTo?: string
backLabel?: string
stickyFooter?: boolean
fullBleed?: boolean
compactPadding?: boolean
renderWhileChecking?: boolean
@@ -26,6 +29,9 @@ const props = withDefaults(
}>(),
{
mobileTitle: undefined,
backTo: undefined,
backLabel: undefined,
stickyFooter: false,
offlineMessage: undefined,
offlineIcon: undefined,
fullBleed: false,
@@ -36,11 +42,15 @@ const props = withDefaults(
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
if (props.stickyFooter) {
return STICKY_FOOTER_BODY
}
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
})
@@ -229,7 +239,17 @@ async function onLogout() {
}"
>
<template #toggle>
<span class="relative lg:hidden">
<UButton
v-if="backTo"
:to="backTo"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-11 justify-center lg:hidden"
:ui="{ leadingIcon: 'size-5' }"
:aria-label="backLabel"
/>
<span v-else class="relative lg:hidden">
<UDashboardSidebarToggle
data-slot="toggle"
color="neutral"
+154 -54
View File
@@ -1,18 +1,42 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeBlock from '@/components/CodeBlock.vue'
import {
RATE_LIMIT_SENTENCE,
NO_CONTENT_RESPONSE,
endpointExamples,
type ApiEndpoint,
} from '@/data/apiCatalogue'
import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
defineProps<{
method: 'GET'
path: string
description: string
examples: { fetch: string; curl: string; response: string }
const props = defineProps<{
endpoint: ApiEndpoint
origin: string
}>()
const open = ref(false)
const activeTab = ref('fetch')
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
const rowLabel = computed(
() => `${props.endpoint.method} ${props.endpoint.path}. ${props.endpoint.description}`,
)
const bodyTitle = computed(() => {
if (props.endpoint.bodyKind === 'multipart') {
return t('Request body · multipart/form-data, 1 file field')
}
return props.endpoint.bodyKind === 'json' ? t('Request body · JSON') : t('Request body')
})
const responseLabel = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'HTTP' : 'JSON',
)
const responseLang = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'text' : 'json',
)
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
@@ -26,55 +50,131 @@ const tabItems = [
</script>
<template>
<UCard
:ui="{
root: 'border border-default ring-0 divide-default',
header: 'p-4 sm:p-4',
body: 'p-0 sm:p-0',
}"
<UCollapsible
v-model:open="open"
:data-anchor="endpointAnchor(endpoint.id)"
class="scroll-mt-22 lg:scroll-mt-14 border-t border-default first:border-t-0"
:ui="{ content: 'border-t border-muted' }"
>
<template #header>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2.5">
<UBadge :label="method" color="neutral" variant="solid" class="font-mono" />
<span class="font-mono text-base font-medium text-highlighted">{{ path }}</span>
<button
type="button"
:aria-label="rowLabel"
class="flex min-h-11 w-full cursor-pointer flex-col items-stretch gap-1.5 px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary lg:grid lg:grid-cols-[300px_minmax(0,1fr)_auto_16px] lg:items-center lg:gap-4 lg:px-4 lg:py-0"
:class="open ? 'bg-muted' : ''"
>
<span class="flex min-w-0 items-center gap-2.5">
<UBadge
:label="endpoint.method"
color="neutral"
variant="outline"
:class="METHOD_CLASS[endpoint.method]"
/>
<span class="min-w-0 truncate font-mono text-sm font-medium text-highlighted">{{
endpoint.path
}}</span>
<span class="flex-1 lg:hidden" />
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-4 shrink-0 text-muted lg:hidden"
/>
</span>
<span class="min-w-0 text-toned lg:truncate">{{ endpoint.description }}</span>
<span class="flex flex-wrap gap-1.5">
<UBadge
:label="translate(endpoint.access)"
color="neutral"
variant="outline"
:class="
endpoint.access === 'Owner only' ? CHIP_CLASS.strong : CHIP_CLASS.neutral
"
/>
<UBadge
:label="t('API key OK')"
icon="i-lucide-check"
color="success"
variant="soft"
:class="CHIP_CLASS.success"
:ui="{ leadingIcon: 'size-3' }"
/>
</span>
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="hidden size-4 text-muted lg:block"
/>
</button>
<template #content>
<div class="flex flex-col gap-3.5 px-3 pt-2.5 pb-4 lg:px-4">
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Query parameters') }}</span>
<ApiFieldTable v-if="endpoint.query.length > 0" :fields="endpoint.query" />
<span v-else class="text-muted">{{ t('No query parameters.') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ bodyTitle }}</span>
<ApiFieldTable v-if="endpoint.body.length > 0" :fields="endpoint.body" />
<span v-else class="text-muted">{{ t('No request body.') }}</span>
</div>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned max-lg:h-11',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<CodeBlock
v-else-if="item.value === 'curl'"
label="Shell"
lang="shell"
:code="examples.curl"
/>
<CodeBlock
v-else
:label="responseLabel"
:lang="responseLang"
:code="examples.response"
/>
</template>
</UTabs>
<div v-if="endpoint.rateLimited" class="flex gap-2.5 text-toned">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-pretty">{{ translate(RATE_LIMIT_SENTENCE) }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Errors') }}</span>
<div
v-for="error in endpoint.errors"
:key="`${error.status}-${error.caption}`"
:title="error.caption"
class="flex items-baseline gap-2.5"
>
<UBadge
:label="String(error.status)"
color="neutral"
variant="outline"
class="h-5 w-9 shrink-0 justify-center rounded-none border border-accented px-0 py-0 font-mono text-xs font-medium text-toned ring-0"
/>
<span class="min-w-0 text-pretty text-toned">{{ error.caption }}</span>
</div>
</div>
<span class="text-toned">{{ description }}</span>
</div>
</template>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0',
list: 'gap-5 border-b border-default px-4 py-0 before:hidden',
trigger: 'h-9 grow-0 px-0 text-sm font-normal data-[state=active]:font-medium',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<pre
v-else-if="item.value === 'curl'"
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.curl }}</pre
>
<pre
v-else
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.response }}</pre
>
</template>
</UTabs>
</UCard>
</UCollapsible>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
export interface IndexSection {
id: string
label: string
}
const props = defineProps<{
sections: IndexSection[]
active: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const activeLabel = computed(
() => props.sections.find((section) => section.id === props.active)?.label ?? '',
)
function onJump(id: string) {
emit('select', id)
}
</script>
<template>
<nav
:aria-label="t('On this page')"
class="sticky top-0 hidden w-42 shrink-0 flex-col gap-0.5 self-start border-l border-default lg:flex"
>
<span
class="px-3 pb-1.5 text-xs leading-5 font-medium tracking-wide text-muted uppercase"
>{{ t('On this page') }}</span
>
<button
v-for="section in sections"
:key="section.id"
type="button"
:aria-current="section.id === active ? 'true' : undefined"
class="-ml-px flex h-7 cursor-pointer items-center border-l-2 px-3 text-left hover:text-highlighted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="
section.id === active
? 'border-primary font-medium text-highlighted'
: 'border-transparent text-toned'
"
@click="onJump(section.id)"
>
{{ section.label }}
</button>
</nav>
<div
class="sticky -top-5 z-5 order-first -mx-4 -mt-5 mb-5 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
:items="sections"
value-key="id"
label-key="label"
trailing-icon="i-lucide-chevrons-up-down"
:search-input="false"
:aria-label="t('Jump to section')"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
trailingIcon: 'size-4 text-muted',
content: 'max-h-none w-(--reka-popper-anchor-width) border border-default',
item: 'h-11 px-2.5 text-base text-highlighted',
}"
@update:model-value="onJump($event as string)"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="text-muted">{{ t('Jump to') }}</span>
<span class="flex-1 truncate text-left font-medium text-highlighted">{{
activeLabel
}}</span>
</span>
</USelectMenu>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import ApiRouteRow from '@/components/ApiRouteRow.vue'
import type { PublicRow, SessionGroup } from '@/data/apiCatalogue'
import { CHIP_CLASS } from '@/lib/apiReference'
defineProps<{
groups: SessionGroup[]
publicRows: PublicRow[]
}>()
const SUBGROUP_TITLE_CLASS =
'px-3.5 pt-2 pb-1 text-xs font-medium tracking-wide text-muted uppercase'
</script>
<template>
<div class="flex flex-col gap-3">
<div class="border border-dashed border-accented bg-muted">
<div class="flex gap-2.5 px-3.5 py-3 text-pretty text-toned">
<UIcon name="i-lucide-lock" class="mt-0.5 size-4 shrink-0 text-muted" />
<span>{{
t(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
}}</span>
</div>
<div
v-for="group in groups"
:key="group.title"
class="border-t border-dashed border-accented"
>
<div :class="SUBGROUP_TITLE_CLASS">{{ translate(group.title) }}</div>
<ApiRouteRow
v-for="row in group.rows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-session-row
>
<UBadge
v-if="row.answers403"
:label="t('Session only')"
icon="i-lucide-lock"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.dashed, 'self-start lg:self-center']"
:ui="{ leadingIcon: 'size-3' }"
/>
</ApiRouteRow>
</div>
</div>
<div class="border border-default bg-default">
<div :class="SUBGROUP_TITLE_CLASS">{{ t('Public auth flow') }}</div>
<ApiRouteRow
v-for="row in publicRows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-public-row
>
<UBadge
:label="t('Public')"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.neutral, 'self-start lg:self-center']"
/>
</ApiRouteRow>
</div>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
import { useApi } from '@/composables/useApi'
export type ApiKeyStatus = 'active' | 'expired' | 'revoked'
export interface ApiKey {
id: number
user_id: number
name: string
prefix: string
status: ApiKeyStatus
created_at: string
last_used_at: string | null
expires_at: string | null
revoked_at: string | null
created_by_name?: string
}
export interface CreatedApiKey {
id: number
name: string
prefix: string
key: string
created_at: string
expires_at: string | null
}
export interface CreateApiKeyInput {
name: string
expires_at?: string
}
export const API_KEY_SKELETON_ROWS = 4
export function useApiKeys() {
const { request } = useApi()
function listApiKeys(): Promise<ApiKey[]> {
return request<ApiKey[]>('/v1/api-key')
}
function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
return request<CreatedApiKey>('/v1/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
function revokeApiKey(id: number): Promise<void> {
return request<void>(`/v1/api-key/${id}`, { method: 'DELETE' })
}
return { listApiKeys, createApiKey, revokeApiKey }
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function useShikiHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['javascript'],
langs: ['javascript', 'shellscript', 'json'],
})
}
return highlighterPromise
+559
View File
@@ -0,0 +1,559 @@
import { describe, expect, it } from 'vitest'
import {
API_GROUPS,
API_KEY_PLACEHOLDER,
ERROR_ROWS,
LOGIN_FAMILY_CAPTION,
NO_CONTENT_RESPONSE,
OWNER_REQUIRED_MESSAGE,
PUBLIC_AUTH_ROWS,
RATE_LIMIT_SENTENCE,
SESSION_GROUPS,
SESSION_REQUIRED_MESSAGE,
allEndpoints,
endpointExamples,
type ApiEndpoint,
} from '../apiCatalogue'
const ORIGIN = 'https://cms.renew.example'
const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
'DELETE /v1/article/{id}',
],
Media: [
'GET /v1/media/',
'POST /v1/media/',
'PATCH /v1/media/{id}',
'DELETE /v1/media/{id}',
'POST /v1/media/merge',
'GET /v1/media/{id}/usage',
],
'Users and invites': [
'GET /v1/user/',
'PATCH /v1/user/me',
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
}
const EXPECTED_SESSION: Record<string, string[]> = {
'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',
],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
}
const EXPECTED_PUBLIC = [
'GET /v1/',
'GET /v1/auth/registration',
'POST /v1/auth/password-reset/confirm',
'GET /v1/auth/invite',
'POST /v1/auth/invite/accept',
'GET /healthz',
]
const ARTICLE_KEYS = ['id', 'title', 'body', 'body_format', 'is_online', 'created_at', 'updated_at']
const MEDIA_ITEM_KEYS = [
'id',
'filename',
'url',
'size',
'content_type',
'width',
'height',
'sha256',
'uploaded_by',
'created_at',
'used_in',
'duplicate',
]
const USER_LIST_KEYS = [
'id',
'kind',
'username',
'display_name',
'email',
'role',
'status',
'created_at',
]
const INVITE_KEYS = ['id', 'kind', 'email', 'status', 'created_at', 'expires_at']
const ME_KEYS = [
'id',
'username',
'email',
'is_verified',
'created_at',
'updated_at',
'display_name',
'role',
]
const label = (item: { method: string; path: string }) => `${item.method} ${item.path}`
function find(route: string) {
const found = allEndpoints().find((endpoint) => label(endpoint) === route)
if (!found) {
throw new Error(`catalogue has no ${route}`)
}
return found
}
function parsed(endpoint: ApiEndpoint) {
return JSON.parse(endpoint.response)
}
function keysOf(value: unknown) {
return Object.keys(value as Record<string, unknown>).sort()
}
function expectOnlyKeys(value: unknown, allowed: string[]) {
expect(keysOf(value).filter((key) => !allowed.includes(key))).toEqual([])
}
describe('Catalogue routes', () => {
it('holds exactly the 20 key-callable routes in 4 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(20)
})
it('holds the 13 session-only rows in 3 sub-groups', () => {
expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(13)
})
it('holds the 6 public auth flow rows', () => {
expect(PUBLIC_AUTH_ROWS.map(label)).toEqual(EXPECTED_PUBLIC)
})
it('has no /v1/system route in an expandable group', () => {
expect(allEndpoints().filter((endpoint) => endpoint.path.startsWith('/v1/system'))).toEqual(
[],
)
})
it('gives every route, group, session row and public row a non-blank description', () => {
const described = [
...API_GROUPS.map((group) => [`group ${group.title}`, group.description] as const),
...allEndpoints().map((item) => [label(item), item.description] as const),
...SESSION_GROUPS.flatMap((group) => group.rows).map(
(item) => [label(item), item.description] as const,
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(4 + 20 + 13 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
it.each(Object.values(EXPECTED_GROUPS).flat())(
'describes %s with a description that is not its path',
(route) => {
const item = find(route)
expect(item.description.trim().length, `${route} has no description`).toBeGreaterThan(5)
expect(item.description).not.toContain(item.path)
},
)
})
describe('Catalogue completeness', () => {
it.each(allEndpoints().map((endpoint) => [label(endpoint), endpoint] as const))(
'%s has method, path, chips, tables and examples',
(_route, endpoint) => {
const examples = endpointExamples(endpoint, ORIGIN)
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method)
expect(endpoint.path.startsWith('/v1/')).toBe(true)
expect(['Public', 'Verified user', 'Owner only']).toContain(endpoint.access)
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
expect(endpoint.errors.length).toBeGreaterThan(0)
for (const error of endpoint.errors) {
expect(error.caption.trim()).not.toBe('')
}
for (const field of [...endpoint.query, ...endpoint.body]) {
expect(field.name.trim()).not.toBe('')
expect(field.rule.trim()).not.toBe('')
}
},
)
it('gives Public to the two article reads and Owner only to the invite routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Public')
.map(label),
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner only')
.map(label),
).toEqual([
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
])
})
})
describe('Response samples use the real domain JSON keys', () => {
it('uses the article keys, without images or status', () => {
const single = parsed(find('GET /v1/article/{id}'))
expect(keysOf(single)).toEqual([...ARTICLE_KEYS].sort())
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list[0], ARTICLE_KEYS)
expect(list[0]).not.toHaveProperty('images')
expect(list[0]).not.toHaveProperty('status')
})
it('returns articles from create, update, publish and unpublish', () => {
for (const route of [
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
]) {
expect(keysOf(parsed(find(route))), route).toEqual([...ARTICLE_KEYS].sort())
}
expect(parsed(find('PATCH /v1/article/{id}/publish')).is_online).toBe(true)
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
})
it('uses items, next_cursor and counts for the media list', () => {
const list = parsed(find('GET /v1/media/'))
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
})
it('uses media item keys for upload and rename', () => {
expectOnlyKeys(parsed(find('POST /v1/media/')), MEDIA_ITEM_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/media/{id}')), MEDIA_ITEM_KEYS)
})
it('uses articles_updated and merged for the merge', () => {
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual(['articles_updated', 'merged'])
})
it('uses id, title and published for usage items', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['id', 'published', 'title'])
})
it('uses user and invite keys for the user list and invites', () => {
const list = parsed(find('GET /v1/user/'))
expectOnlyKeys(list[0], USER_LIST_KEYS)
expectOnlyKeys(list[1], INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite')), INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite/{id}/renew')), INVITE_KEYS)
})
it('uses the public user keys for the profile routes', () => {
expectOnlyKeys(parsed(find('GET /v1/auth/me')), ME_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
})
it('uses version for the settings', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual(['version'])
})
it('answers 204 for the three deletes without a JSON body', () => {
for (const route of [
'DELETE /v1/article/{id}',
'DELETE /v1/media/{id}',
'DELETE /v1/user/invite/{id}',
]) {
expect(find(route).response, route).toBe(NO_CONTENT_RESPONSE)
}
})
it('parses every other response sample as JSON', () => {
for (const endpoint of allEndpoints().filter(
(item) => item.response !== NO_CONTENT_RESPONSE,
)) {
expect(() => JSON.parse(endpoint.response), label(endpoint)).not.toThrow()
}
})
})
describe('Request bodies and query parameters', () => {
const bodyOf = (route: string) =>
find(route).body.map((field) => [field.name, field.type, field.required])
it('matches the handler validate tags', () => {
expect(bodyOf('POST /v1/article/')).toEqual([
['title', 'string', true],
['body', 'string', true],
])
expect(find('POST /v1/article/').body[0]!.rule).toContain('3 to 200')
expect(find('POST /v1/article/').body[1]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([['body', 'string', true]])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
expect(bodyOf('POST /v1/media/merge')).toEqual([
['keep_id', 'number', true],
['merge_ids', 'number[]', true],
])
expect(bodyOf('PATCH /v1/user/me')).toEqual([['display_name', 'string', true]])
expect(find('PATCH /v1/user/me').body[0]!.rule).toContain('60')
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
})
it('uses the multipart field "file" for the upload', () => {
const upload = find('POST /v1/media/')
expect(upload.bodyKind).toBe('multipart')
expect(upload.body.map((field) => field.name)).toEqual(['file'])
})
it('has no request body on reads, publish, unpublish and deletes', () => {
for (const route of [
'GET /v1/article/',
'GET /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'DELETE /v1/article/{id}',
'GET /v1/media/',
'GET /v1/settings',
]) {
expect(find(route).body, route).toEqual([])
}
})
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(
endpoint.query.map((param) => param.name),
label(endpoint),
).toEqual(['format'])
}
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q',
'hash',
'cursor',
'filter',
'sort',
'limit',
])
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
'number',
)
expect(find('POST /v1/media/').query).toEqual([])
expect(find('GET /v1/settings').query).toEqual([])
})
})
describe('Owner-only invite routes', () => {
const inviteRoutes = [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]
it.each(inviteRoutes)('%s is owner only, rate limited, and lists the 403', (route) => {
const endpoint = find(route)
expect(endpoint.access).toBe('Owner only')
expect(endpoint.rateLimited).toBe(true)
expect(endpoint.errors).toContainEqual({ status: 403, caption: OWNER_REQUIRED_MESSAGE })
expect(RATE_LIMIT_SENTENCE).toContain('rate limited')
expect(RATE_LIMIT_SENTENCE).toContain('429')
expect(RATE_LIMIT_SENTENCE.replace('429', '')).not.toMatch(/\d/)
})
it('does not rate limit other routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.rateLimited)
.map(label),
).toEqual(inviteRoutes)
})
})
describe('Per-endpoint error captions use the real strings', () => {
const captions: [string, number, string][] = [
['POST /v1/media/', 400, 'the image could not be read'],
['POST /v1/media/', 413, 'the file exceeds the maximum upload size'],
['POST /v1/media/', 415, 'the file must be a PNG, JPEG, WEBP, or SVG image'],
['GET /v1/media/', 400, 'The media query is invalid.'],
[
'POST /v1/media/merge',
400,
'Choose an image to keep and at least one other image to merge into it.',
],
[
'POST /v1/media/merge',
409,
'Some of the images to merge changed. Refresh and try again.',
],
['PATCH /v1/media/{id}', 400, 'Enter a filename.'],
['POST /v1/article/', 400, 'Article title must be between 3 and 200 characters.'],
[
'GET /v1/article/{id}',
400,
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).',
],
['PATCH /v1/user/me', 400, 'Display name must be 60 characters or fewer.'],
['POST /v1/user/invite', 409, 'This address already has an account.'],
['POST /v1/user/invite', 409, "There's already a pending invite for this address."],
['POST /v1/user/invite/{id}/renew', 409, 'This invite has already been accepted.'],
['POST /v1/user/invite', 403, 'Only the owner can do this.'],
]
it.each(captions)('%s lists %i "%s"', (route, status, caption) => {
expect(find(route).errors).toContainEqual({ status, caption })
})
it('adds the format error to every article card', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(endpoint.errors, label(endpoint)).toContainEqual({
status: 400,
caption: 'The format must be one of: markdown, html, json.',
})
}
})
it('lists 400 and 401 on POST /v1/article/', () => {
const statuses = find('POST /v1/article/').errors.map((error) => error.status)
expect(statuses).toContain(400)
expect(statuses).toContain(401)
})
})
describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, api-key and system rows', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
})
it('captions login, logout, register and password reset without a 403 claim', () => {
const family = rows.filter((row) => !row.answers403)
expect(family.map(label)).toEqual([
'POST /v1/auth/login',
'POST /v1/auth/logout',
'POST /v1/auth/register',
'POST /v1/auth/password-reset',
])
for (const row of family) {
expect(row.description).toContain(LOGIN_FAMILY_CAPTION)
}
})
})
describe('Errors table', () => {
it('lists the real statuses and messages', () => {
expect(ERROR_ROWS.map((row) => [row.status, row.messages])).toEqual([
[
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,
[
SESSION_REQUIRED_MESSAGE,
'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.',
],
],
[429, ['Too many requests. Please try again later.']],
[500, ['Internal server error']],
])
})
})
describe('Examples', () => {
it('start from the origin and carry no ?format= query', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
expect(examples.fetch, label(endpoint)).toContain(`'${ORIGIN}/v1/`)
expect(examples.curl, label(endpoint)).toContain(`"${ORIGIN}/v1/`)
expect(examples.fetch).not.toContain('?format=')
expect(examples.curl).not.toContain('?format=')
}
})
it('carry the Authorization header on non-public routes only', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
const header = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
if (endpoint.access === 'Public') {
expect(examples.curl, label(endpoint)).not.toContain('Authorization')
expect(examples.fetch, label(endpoint)).not.toContain('Authorization')
} else {
expect(examples.curl, label(endpoint)).toContain(header)
expect(examples.fetch, label(endpoint)).toContain(
`Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
)
}
}
})
it('send the multipart field "file" for the upload', () => {
const examples = endpointExamples(find('POST /v1/media/'), ORIGIN)
expect(examples.curl).toContain('-F "file=@north-wall.jpg"')
expect(examples.fetch).toContain("form.append('file', file)")
})
it('start with the rcms_ prefix for the key', () => {
expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true)
})
})
+756
View File
@@ -0,0 +1,756 @@
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner only'
export interface ApiField {
name: string
type: string
required: boolean
rule: string
}
export interface ApiError {
status: number
caption: string
}
export type BodyKind = 'none' | 'json' | 'multipart'
export interface ApiEndpoint {
id: string
method: HttpMethod
path: string
description: string
access: AccessLevel
pathId: number | null
query: ApiField[]
bodyKind: BodyKind
body: ApiField[]
bodySample: Record<string, unknown> | null
errors: ApiError[]
response: string
rateLimited: boolean
}
export interface ApiGroup {
key: string
title: string
description: string
endpoints: ApiEndpoint[]
}
export interface SessionRow {
method: HttpMethod
path: string
description: string
answers403: boolean
}
export interface SessionGroup {
title: string
rows: SessionRow[]
}
export interface PublicRow {
method: HttpMethod
path: string
description: string
}
export interface ErrorRow {
status: number
meaning: string
messages: string[]
}
export interface EndpointExamples {
fetch: string
curl: string
response: string
}
export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
export const NO_CONTENT_RESPONSE = '204 No Content'
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const UNAUTHORIZED = 'You must be logged in to access this resource.'
const VERIFY_REQUIRED = 'You must verify your account to access this resource.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
const FORMAT_INVALID = 'The format must be one of: markdown, html, json.'
const TOO_MANY_REQUESTS = 'Too many requests. Please try again later.'
const FORMAT_QUERY: ApiField = {
name: 'format',
type: 'string',
required: false,
rule: 'markdown, html or json. Default markdown.',
}
const MEDIA_QUERY: ApiField[] = [
{ name: 'q', type: 'string', required: false, rule: 'Search text.' },
{
name: 'hash',
type: 'string',
required: false,
rule: 'SHA-256 of the content, lowercased.',
},
{
name: 'cursor',
type: 'string',
required: false,
rule: 'The next_cursor of the previous page.',
},
{
name: 'filter',
type: 'string',
required: false,
rule: 'all, unused or duplicates. Default all.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'newest, oldest, name or largest. Default newest.',
},
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
]
const json = (value: unknown) => JSON.stringify(value, null, 2)
const ARTICLE_TITLE = 'How we price a bathroom renovation'
const article = (overrides: Record<string, unknown> = {}) => ({
id: 12,
title: ARTICLE_TITLE,
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
body_format: 'markdown',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
size: 3900000,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
uploaded_by: 'marta',
created_at: '2026-09-10T07:00:00Z',
used_in: 2,
duplicate: false,
...overrides,
})
const me = {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
created_at: '2026-01-05T08:30:00Z',
updated_at: '2026-09-10T07:00:00Z',
display_name: 'Marta Alves',
role: 'owner',
}
const invite = (overrides: Record<string, unknown> = {}) => ({
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
expires_at: '2026-09-13T07:00:00Z',
...overrides,
})
const e = (status: number, caption: string): ApiError => ({ status, caption })
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
function endpoint(
id: string,
method: HttpMethod,
path: string,
description: string,
access: AccessLevel,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
): ApiEndpoint {
return {
id,
method,
path,
description,
access,
pathId: path.includes('{id}') ? 12 : null,
query: none,
bodyKind: 'none',
body: none,
bodySample: null,
errors,
response,
rateLimited: false,
...extra,
}
}
const ownerOnly = (
id: string,
method: HttpMethod,
path: string,
description: string,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
) =>
endpoint(
id,
method,
path,
description,
'Owner only',
[...errors, e(403, OWNER_REQUIRED_MESSAGE), e(429, TOO_MANY_REQUESTS)],
response,
{ rateLimited: true, pathId: path.includes('{id}') ? 7 : null, ...extra },
)
export const API_GROUPS: ApiGroup[] = [
{
key: 'articles',
title: 'Articles',
description: "Read, write and publish articles. Titles can't be changed after creation.",
endpoints: [
endpoint(
'art-list',
'GET',
'/v1/article/',
'List articles. Anonymous callers get online articles only.',
'Public',
articleErrors(),
json([article()]),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-get',
'GET',
'/v1/article/{id}',
'Get one article. Drafts answer 404 to anonymous callers.',
'Public',
articleErrors(...articleIdErrors),
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-create',
'POST',
'/v1/article/',
'Create a draft article.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'Article title must be between 3 and 200 characters.'),
),
),
json(
article({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
}),
),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'title',
type: 'string',
required: true,
rule: "3 to 200 characters. Can't be changed later.",
},
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: {
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
},
},
),
endpoint(
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article.',
'Verified user',
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
json(article({ body: 'Updated text.', is_online: false })),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: { body: 'Updated text.' },
},
),
endpoint(
'art-pub',
'PATCH',
'/v1/article/{id}/publish',
'Publish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: true })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-unpub',
'PATCH',
'/v1/article/{id}/unpublish',
'Unpublish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: false })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-del',
'DELETE',
'/v1/article/{id}',
'Delete an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
],
},
{
key: 'media',
title: 'Media',
description: 'The image library. One image can be used in many articles.',
endpoints: [
endpoint(
'med-list',
'GET',
'/v1/media/',
'List images.',
'Verified user',
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
json({
items: [mediaItem()],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
}),
{ query: MEDIA_QUERY },
),
endpoint(
'med-up',
'POST',
'/v1/media/',
'Upload an image (multipart).',
'Verified user',
verified([
e(400, 'the image could not be read'),
e(401, UNAUTHORIZED),
e(413, 'the file exceeds the maximum upload size'),
e(415, 'the file must be a PNG, JPEG, WEBP, or SVG image'),
]),
json(mediaItem()),
{
pathId: null,
bodyKind: 'multipart',
body: [
{
name: 'file',
type: 'file',
required: true,
rule: 'PNG, JPEG, WEBP or SVG.',
},
],
bodySample: { file: '@north-wall.jpg' },
},
),
endpoint(
'med-ren',
'PATCH',
'/v1/media/{id}',
'Rename an image. The URL stays the same.',
'Verified user',
verified([
e(400, 'Enter a filename.'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Image not found'),
]),
json(mediaItem({ filename: 'north-wall-01.jpg' })),
{
pathId: 4,
bodyKind: 'json',
body: [
{
name: 'filename',
type: 'string',
required: true,
rule: 'The new filename.',
},
],
bodySample: { filename: 'north-wall-01.jpg' },
},
),
endpoint(
'med-del',
'DELETE',
'/v1/media/{id}',
'Delete an image. Articles that use it show a broken image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
NO_CONTENT_RESPONSE,
{ pathId: 4 },
),
endpoint(
'med-merge',
'POST',
'/v1/media/merge',
'Merge identical copies into one image.',
'Verified user',
verified([
e(
400,
'Choose an image to keep and at least one other image to merge into it.',
),
e(401, UNAUTHORIZED),
e(409, 'Some of the images to merge changed. Refresh and try again.'),
]),
json({ articles_updated: 5, merged: 2 }),
{
bodyKind: 'json',
body: [
{
name: 'keep_id',
type: 'number',
required: true,
rule: 'Greater than 0. The image to keep.',
},
{
name: 'merge_ids',
type: 'number[]',
required: true,
rule: 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.',
},
],
bodySample: { keep_id: 4, merge_ids: [9, 11] },
},
),
endpoint(
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
{ pathId: 4 },
),
],
},
{
key: 'users',
title: 'Users and invites',
description: 'People who can sign in, and pending invitations.',
endpoints: [
endpoint(
'usr-list',
'GET',
'/v1/user/',
'List users and invites.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json([
{
id: 1,
kind: 'user',
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-01-05T08:30:00Z',
},
{
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
},
]),
),
endpoint(
'usr-me',
'PATCH',
'/v1/user/me',
'Update my profile.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'Display name must be 60 characters or fewer.'),
e(401, UNAUTHORIZED),
]),
json({ ...me, display_name: 'Marta Alves' }),
{
bodyKind: 'json',
body: [
{
name: 'display_name',
type: 'string',
required: true,
rule: 'Up to 60 characters.',
},
],
bodySample: { display_name: 'Marta Alves' },
},
),
ownerOnly(
'usr-inv',
'POST',
'/v1/user/invite',
'Invite a user by email.',
[
e(400, BODY_MISMATCH),
e(401, UNAUTHORIZED),
e(409, 'This address already has an account.'),
e(409, "There's already a pending invite for this address."),
],
json(invite()),
{
bodyKind: 'json',
body: [
{
name: 'email',
type: 'string',
required: true,
rule: 'A valid email address.',
},
],
bodySample: { email: 'lea.martin@renew.example' },
},
),
ownerOnly(
'usr-cancel',
'DELETE',
'/v1/user/invite/{id}',
'Cancel a pending invite.',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Invite not found')],
NO_CONTENT_RESPONSE,
),
ownerOnly(
'usr-renew',
'POST',
'/v1/user/invite/{id}/renew',
'Renew an expired invite.',
[
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Invite not found'),
e(409, 'This invite has already been accepted.'),
],
json(invite()),
),
endpoint(
'auth-me',
'GET',
'/v1/auth/me',
"Get the current user. With a key, the key's owner.",
'Verified user',
[e(401, UNAUTHORIZED)],
json(me),
),
],
},
{
key: 'settings',
title: 'Settings',
description: 'Read the site settings.',
endpoints: [
endpoint(
'set-get',
'GET',
'/v1/settings',
'Get settings.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json({ version: 'v2.2.0-beta' }),
),
],
},
]
const row = (
method: HttpMethod,
path: string,
description: string,
answers403 = true,
): SessionRow => ({
method,
path,
description: answers403 ? description : `${description}. ${LOGIN_FAMILY_CAPTION}`,
answers403,
})
export const SESSION_GROUPS: SessionGroup[] = [
{
title: 'System · owner only',
rows: [
row('GET', '/v1/system/update', 'Update status'),
row('POST', '/v1/system/update/check', 'Check for update'),
row('POST', '/v1/system/update/download', 'Download update'),
row('POST', '/v1/system/update/restart', 'Restart'),
],
},
{
title: 'Sign in and account',
rows: [
row('POST', '/v1/auth/login', 'Log in', false),
row('POST', '/v1/auth/logout', 'Log out', false),
row('POST', '/v1/auth/register', 'Register', false),
row('POST', '/v1/auth/verify', 'Verify email'),
row('DELETE', '/v1/auth/register', 'Cancel registration'),
row('POST', '/v1/auth/password-reset', 'Reset password', false),
],
},
{
title: 'API key management',
rows: [
row('GET', '/v1/api-key', 'List keys'),
row('POST', '/v1/api-key', 'Create a key'),
row('DELETE', '/v1/api-key/{id}', 'Revoke a key'),
],
},
]
export const PUBLIC_AUTH_ROWS: PublicRow[] = [
{ method: 'GET', path: '/v1/', description: 'Hello world.' },
{ method: 'GET', path: '/v1/auth/registration', description: 'Whether registration is open.' },
{
method: 'POST',
path: '/v1/auth/password-reset/confirm',
description: 'Set a new password with the emailed code.',
},
{ method: 'GET', path: '/v1/auth/invite', description: 'Check an invite code.' },
{ method: 'POST', path: '/v1/auth/invite/accept', description: 'Accept an invite.' },
{ method: 'GET', path: '/healthz', description: 'Plain text "ok". Outside /v1.' },
]
export const ERROR_ROWS: ErrorRow[] = [
{
status: 400,
meaning: 'Bad request',
messages: [BODY_MISMATCH, 'The expiry must be a date in the future.'],
},
{ status: 401, meaning: 'Unauthorized', messages: [UNAUTHORIZED] },
{
status: 403,
meaning: 'Forbidden',
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
},
{
status: 404,
meaning: 'Not found',
messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
},
{
status: 409,
meaning: 'Conflict',
messages: [
"There's already a pending invite for this address.",
'This invite has already been accepted.',
],
},
{ status: 429, meaning: 'Too many requests', messages: [TOO_MANY_REQUESTS] },
{ status: 500, meaning: 'Server error', messages: ['Internal server error'] },
]
export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\n${SESSION_REQUIRED_MESSAGE}`
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
}
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
const url = `${origin}${resolvedPath(item)}`
const isPublic = item.access === 'Public'
const isJson = item.bodyKind === 'json' && item.bodySample !== null
const isForm = item.bodyKind === 'multipart'
const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`]
if (!isPublic) curlParts.push(` -H "${authHeader}"`)
if (isJson) {
curlParts.push(` -H "Content-Type: application/json"`)
curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`)
}
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
const options: string[] = []
if (item.method !== 'GET') options.push(`method: '${item.method}'`)
const headers = [
isPublic ? null : `Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
isJson ? `'Content-Type': 'application/json'` : null,
].filter((header) => header !== null)
if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`)
if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`)
if (isForm) options.push('body: form')
const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : ''
const call = `const res = await fetch('${url}'${
options.length > 0 ? `, {\n ${options.join(',\n ')}\n})` : ')'
}`
return {
fetch: formSetup + call,
curl: curlParts.join(' \\\n'),
response: item.response,
}
}
export function authExamples(origin: string) {
const url = `${origin}${AUTH_EXAMPLE_PATH}`
return {
curl: `curl "${url}" \\\n -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}"`,
fetch: `const res = await fetch('${url}', {\n headers: { Authorization: 'Bearer ${API_KEY_PLACEHOLDER}' }\n})\nconst me = await res.json()`,
}
}
export function allEndpoints() {
return API_GROUPS.flatMap((group) => group.endpoints)
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const API_KEYS_MESSAGES = [
'API keys',
'Create key',
'Key',
'Created by',
'Last used',
'Expires',
'active',
'expired',
'revoked',
'Revoke',
'None',
'Never',
'Created {0}',
'Created {0} by {1}',
'Last used {0}',
'Expires {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.",
'Keys act as you, with your role. You only see your own keys.',
"Resetting a user's password revokes all of their keys.",
'No API keys yet',
'Create a key to call the API from a script or service.',
"Couldn't load your API keys",
'Create API key',
'The key acts as you, with your role. It is shown in full once.',
'Required. Up to 60 characters.',
'Expiry date (optional)',
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
'Enter a name.',
'Use 60 characters or fewer.',
'Choose a date in the future.',
'Your new API key',
'"{0}" is ready. Copy the key and store it somewhere safe.',
"You won't be able to see this key again.",
'Closing this window discards the key from the screen. Copy it first.',
'Done',
'Revoke this key?',
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
'Keep key',
'Revoke key',
'Key revoked',
'"{0}" can no longer be used.',
'Revoking…',
"Couldn't create the key. Try again in a moment.",
"Couldn't reach RenewCMS. Check your connection and try again.",
'Too many requests. Wait a moment, then try again.',
"Something in the form isn't valid. Check the highlighted fields.",
"Couldn't revoke the key. Try again in a moment.",
'That key no longer exists.',
'Manage API keys',
'Loading API keys',
]
describe('API keys screen messages', () => {
it.each(API_KEYS_MESSAGES)('has a French entry for "%s"', (key) => {
const french = (messages as Record<string, string>)[key]
expect(french).toBeTruthy()
if (key !== 'active') {
expect(french).not.toBe(key)
}
})
})
+126
View File
@@ -293,6 +293,7 @@ export const messages = {
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
'Back to Articles': 'Retour aux articles',
'Back to Integration': 'Retour à l’intégration',
'Your changes to "{0}" haven\'t been saved.':
'Vos modifications de « {0} » n’ont pas été enregistrées.',
'Shorten the text or remove an image. Formatting counts too.':
@@ -824,6 +825,131 @@ export const messages = {
'images, {0} selected': 'images, {0} sélectionnées',
'Used in 1 article': 'Utilisée dans 1 article',
'Used in {0} articles': 'Utilisée dans {0} articles',
'API keys': 'Clés d’API',
'Create key': 'Créer une clé',
Key: 'Clé',
'Created by': 'Créée par',
'Last used': 'Dernière utilisation',
Expires: 'Expire le',
active: 'active',
expired: 'expirée',
revoked: 'révoquée',
Revoke: 'Révoquer',
None: 'Aucune',
Never: 'Jamais',
'Created {0}': 'Créée le {0}',
'Created {0} by {1}': 'Créée le {0} par {1}',
'Last used {0}': 'Dernière utilisation : {0}',
'Expires {0}': 'Expire : {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.":
'Les clés agissent comme l’utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.',
'Keys act as you, with your role. You only see your own keys.':
'Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.',
"Resetting a user's password revokes all of their keys.":
'Réinitialiser le mot de passe d’un utilisateur révoque toutes ses clés.',
'No API keys yet': 'Aucune clé d’API pour l’instant',
'Create a key to call the API from a script or service.':
'Créez une clé pour appeler l’API depuis un script ou un service.',
"Couldn't load your API keys": 'Impossible de charger vos clés d’API',
'Create API key': 'Créer une clé d’API',
'The key acts as you, with your role. It is shown in full once.':
'La clé agit en votre nom, avec votre rôle. Elle n’est affichée en entier qu’une seule fois.',
'Required. Up to 60 characters.': 'Obligatoire. 60 caractères maximum.',
'Expiry date (optional)': 'Date d’expiration (facultative)',
'Optional. Must be in the future. Leave it empty for a key that does not expire.':
'Facultative. Doit être dans le futur. Laissez vide pour une clé qui n’expire pas.',
'Enter a name.': 'Saisissez un nom.',
'Choose a date in the future.': 'Choisissez une date dans le futur.',
'Your new API key': 'Votre nouvelle clé d’API',
'"{0}" is ready. Copy the key and store it somewhere safe.':
'« {0} » est prête. Copiez la clé et conservez-la en lieu sûr.',
"You won't be able to see this key again.": 'Vous ne pourrez plus revoir cette clé.',
'Closing this window discards the key from the screen. Copy it first.':
'Fermer cette fenêtre supprime la clé de l’écran. Copiez-la d’abord.',
Done: 'Terminé',
'Revoke this key?': 'Révoquer cette clé ?',
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.':
'« {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l’utilisent recevront une réponse 401. Cette action est irréversible.',
'Keep key': 'Garder la clé',
'Revoke key': 'Révoquer la clé',
'Key revoked': 'Clé révoquée',
'"{0}" can no longer be used.': '« {0} » ne peut plus être utilisée.',
'Revoking…': 'Révocation…',
"Couldn't create the key. Try again in a moment.":
'Impossible de créer la clé. Réessayez dans un instant.',
'Too many requests. Wait a moment, then try again.':
'Trop de requêtes. Patientez un instant, puis réessayez.',
"Couldn't revoke the key. Try again in a moment.":
'Impossible de révoquer la clé. Réessayez dans un instant.',
'That key no longer exists.': 'Cette clé n’existe plus.',
'Manage API keys': 'Gérer les clés d’API',
'Loading API keys': 'Chargement des clés d’API',
Required: 'Obligatoire',
Rule: 'Règle',
Yes: 'Oui',
No: 'Non',
'Request body · multipart/form-data, 1 file field':
'Corps de la requête · multipart/form-data, 1 champ fichier',
'Request body · JSON': 'Corps de la requête · JSON',
'Request body': 'Corps de la requête',
'API key OK': 'Clé d’API acceptée',
'Query parameters': 'Paramètres d’URL',
'No query parameters.': 'Aucun paramètre d’URL.',
'No request body.': 'Aucun corps de requête.',
Errors: 'Erreurs',
'On this page': 'Sur cette page',
'Jump to section': 'Aller à la section',
'Jump to': 'Aller à',
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."':
'Ces routes sont prévues pour une session de navigateur connectée. Avec une clé d’API, verify, DELETE /auth/register, /api-key/* et /system/* répondent 403 « This action needs a real session. »',
'Session only': 'Session uniquement',
'Public auth flow': 'Parcours d’authentification public',
Public: 'Public',
'Verified user': 'Utilisateur vérifié',
'Owner only': 'Propriétaire uniquement',
Authentication: 'Authentification',
Notes: 'Notes',
'Users and invites': 'Utilisateurs et invitations',
'System · owner only': 'Système · propriétaire uniquement',
'Sign in and account': 'Connexion et compte',
'API key management': 'Gestion des clés d’API',
'Bad request': 'Requête invalide',
Unauthorized: 'Non autorisé',
Forbidden: 'Interdit',
Conflict: 'Conflit',
'Server error': 'Erreur serveur',
'This route is rate limited. Too many requests answer 429.':
'Cette route est limitée en fréquence. Trop de requêtes reçoivent la réponse 429.',
Message: 'Message',
'{0} route': '{0} route',
'{0} routes': '{0} routes',
'{0} routes, made for a browser session': '{0} routes, prévues pour une session de navigateur',
'API reference': 'Référence de l’API',
'Everything a developer needs to build on RenewCMS.':
'Tout ce dont une personne développeuse a besoin pour construire sur RenewCMS.',
'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.':
'RenewCMS est headless : il stocke votre contenu et votre propre code l’affiche. Il y a deux façons d’y accéder. L’accès public en lecture seule permet à un site de lire les articles publiés. Les clés d’API permettent à un script ou à un service de gérer les articles, les médias et les utilisateurs sans le back-office.',
'No key': 'Sans clé',
'No key needed. Two read-only routes, and only online articles are returned.':
'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.',
'API key': 'Clé d’API',
'Full access': 'Accès complet',
"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.":
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.',
'Keys start with `rcms_` and are shown in full once, when you create them.':
'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.',
'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.':
'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié.',
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.':
'Chaque clé peut faire 120 requêtes par minute. Au-delà, l’API répond 429 avec un en-tête `Retry-After`.',
'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.':
'Chaque erreur est un corps en texte brut d’une seule phrase. Le code d’état est dans la ligne d’état HTTP, et le Content-Type est text/plain.',
'Error response · text/plain': 'Réponse d’erreur · text/plain',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.':
'Les images dans `body` utilisent des chemins comme `/static/uploadedImages/<uuid>.png`. Ajoutez l’URL de base devant avant l’affichage.',
'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.':
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
} as const
export type MessageKey = keyof typeof messages
+4 -1
View File
@@ -391,9 +391,12 @@ describe('Hardener localization outcomes', () => {
document.body.textContent?.includes('/v1/article/'),
document.body.textContent?.includes('GET'),
]).toEqual([window.location.origin, true, true, true])
document.body
.querySelector<HTMLButtonElement>('button[aria-label^="GET /v1/article/. "]')!
.click()
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
+60
View File
@@ -0,0 +1,60 @@
import { t } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import type { ApiKey } from '@/composables/useApiKeys'
const VISIBLE_PREFIX_LENGTH = 9
const END_OF_DAY = { hours: 23, minutes: 59, seconds: 59 }
const MINUTES_PER_HOUR = 60
export function displayPrefix(prefix: string) {
return `${prefix.slice(0, VISIBLE_PREFIX_LENGTH)}…`
}
function twoDigits(value: number) {
return String(value).padStart(2, '0')
}
function localEndOfDay(dateValue: string) {
const [year, month, day] = dateValue.split('-').map(Number)
return new Date(
year!,
month! - 1,
day,
END_OF_DAY.hours,
END_OF_DAY.minutes,
END_OF_DAY.seconds,
)
}
function localOffset(date: Date) {
const totalMinutes = -date.getTimezoneOffset()
const sign = totalMinutes < 0 ? '-' : '+'
const absolute = Math.abs(totalMinutes)
return `${sign}${twoDigits(Math.floor(absolute / MINUTES_PER_HOUR))}:${twoDigits(absolute % MINUTES_PER_HOUR)}`
}
export function expiryInstant(dateValue: string) {
const date = localEndOfDay(dateValue)
const day = `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
return `${day}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}${localOffset(date)}`
}
export function isFutureExpiry(dateValue: string, now: Date = new Date()) {
return localEndOfDay(dateValue).getTime() > now.getTime()
}
export function formatKeyDate(iso: string | null, emptyLabel: string) {
return iso ? formatAbsoluteDateFromIso(iso) : emptyLabel
}
export function lastUsedLabel(key: ApiKey) {
return formatKeyDate(key.last_used_at, t('Never'))
}
export function expiresLabel(key: ApiKey) {
return formatKeyDate(key.expires_at, t('None'))
}
export function creatorLabel(key: ApiKey) {
return key.created_by_name || '—'
}
+28
View File
@@ -0,0 +1,28 @@
import type { HttpMethod } from '@/data/apiCatalogue'
const CHIP_BASE = 'h-5 gap-1 rounded-none px-1.5 py-0 text-xs whitespace-nowrap ring-0 border'
export const CHIP_CLASS = {
neutral: `${CHIP_BASE} border-accented bg-transparent font-normal text-toned`,
strong: `${CHIP_BASE} border-accented bg-elevated font-medium text-highlighted`,
success: `${CHIP_BASE} border-transparent bg-(--rc-success-soft) font-medium text-success`,
dashed: `${CHIP_BASE} border-dashed border-accented bg-transparent font-normal text-muted`,
}
const METHOD_BASE =
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
export const METHOD_CLASS: Record<HttpMethod, string> = {
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
PATCH: `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`,
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
}
export function splitInlineCode(text: string) {
return text.split('`').map((value, index) => ({ value, code: index % 2 === 1 }))
}
export function endpointAnchor(id: string) {
return `ep-${id}`
}
+5
View File
@@ -91,6 +91,11 @@ const router = createRouter({
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
+378
View File
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ApiKeyCardList from '@/components/ApiKeyCardList.vue'
import ApiKeyReveal from '@/components/ApiKeyReveal.vue'
import ApiKeysTable from '@/components/ApiKeysTable.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import CreateApiKeyModal from '@/components/CreateApiKeyModal.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useApiKeys'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { displayPrefix } from '@/lib/apiKeys'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
const CONNECTION_FAILED_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const { listApiKeys, revokeApiKey } = useApiKeys()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
const keys = ref<ApiKey[]>([])
const createOpen = ref(false)
const revealed = ref<{ name: string; key: string } | null>(null)
const revokeDialogOpen = ref(false)
let opener: HTMLElement | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
const showList = computed(
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
)
const showPinnedButton = computed(() => !isDesktop.value && !isEmpty.value)
const anyDialogOpen = computed(
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
)
const description = computed(() =>
isOwner.value
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
: t('Keys act as you, with your role. You only see your own keys.'),
)
const sessionName = computed(
() => session.session?.displayName?.trim() || session.session?.username || '',
)
function isHandledByApiClient(err: unknown) {
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
}
async function loadKeys() {
loadState.value = 'loading'
try {
keys.value = await listApiKeys()
loadState.value = 'ready'
} catch (err) {
if (!isHandledByApiClient(err)) {
loadState.value = 'error'
}
}
}
function rememberOpener() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
}
async function restoreFocus() {
await nextTick()
opener?.focus()
opener = null
}
function openCreateModal() {
rememberOpener()
createOpen.value = true
}
function onCreateOpenChange(isOpen: boolean) {
createOpen.value = isOpen
if (!isOpen && revealed.value === null) {
restoreFocus()
}
}
function onCreated(created: CreatedApiKey) {
revealed.value = { name: created.name, key: created.key }
keys.value = [
{
id: created.id,
user_id: session.session?.id ?? 0,
name: created.name,
prefix: created.prefix,
status: 'active',
created_at: created.created_at,
last_used_at: null,
expires_at: created.expires_at,
revoked_at: null,
created_by_name: sessionName.value,
},
...keys.value,
]
}
function onRevealDone() {
revealed.value = null
restoreFocus()
}
function markRevoked(id: number) {
const revokedAt = new Date().toISOString()
keys.value = keys.value.map((key) =>
key.id === id ? { ...key, status: 'revoked', revoked_at: revokedAt } : key,
)
}
function removeKey(id: number) {
keys.value = keys.value.filter((key) => key.id !== id)
}
function announceRevoked(key: ApiKey) {
toast.add(() => ({
id: `api-key-revoked-${key.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Key revoked')
},
get description() {
return t('"{0}" can no longer be used.', [key.name])
},
}))
}
function announceMissing(key: ApiKey) {
toast.add(() => ({
id: `api-key-missing-${key.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t('That key no longer exists.')
},
}))
}
async function revoke(key: ApiKey) {
try {
await revokeApiKey(key.id)
} catch (err) {
if (isHandledByApiClient(err)) {
return
}
if (err instanceof ApiError && err.status === NOT_FOUND_STATUS) {
removeKey(key.id)
announceMissing(key)
return
}
throw new Error(
err instanceof NetworkError ? t(CONNECTION_FAILED_COPY) : t(REVOKE_FAILED_COPY),
)
}
markRevoked(key.id)
announceRevoked(key)
}
function confirmRevoke(key: ApiKey) {
rememberOpener()
revokeDialogOpen.value = true
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Revoke this key?')
},
get description() {
return t(
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
[key.name, displayPrefix(key.prefix)],
)
},
get confirmLabel() {
return t('Revoke key')
},
get cancelLabel() {
return t('Keep key')
},
get pendingLabel() {
return t('Revoking…')
},
tone: 'danger',
action: () => revoke(key),
},
})
.open()
.finally(() => {
revokeDialogOpen.value = false
restoreFocus()
})
}
watch(showPinnedButton, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
onMounted(loadKeys)
</script>
<template>
<DashboardShell
:mobile-title="t('API keys')"
back-to="/integration"
sticky-footer
:back-label="t('Back to Integration')"
>
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<UBreadcrumb
data-testid="api-keys-breadcrumb"
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
separator: 'px-0',
}"
>
<template #separator>
<span aria-hidden="true" class="text-muted">›</span>
</template>
</UBreadcrumb>
<h1 class="text-xl font-semibold text-highlighted md:text-2xl">
{{ t('API keys') }}
</h1>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<UButton v-if="isDesktop" class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="horizontal"
:close="false"
:title="t('Couldn\'t load your API keys')"
:description="t('Check your connection and try again.')"
:ui="{ title: 'font-bold', description: 'mt-0 text-error' }"
>
<template #actions>
<UButton
color="neutral"
variant="link"
class="p-0 font-medium text-highlighted"
@click="loadKeys"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-if="isEmpty"
variant="naked"
:title="t('No API keys yet')"
:description="t('Create a key to call the API from a script or service.')"
:ui="{
root: 'gap-2.5 border border-default bg-default px-6 py-12',
title: 'text-lg font-semibold',
description: 'max-w-90 text-sm text-toned text-pretty',
}"
>
<template #actions>
<UButton class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</template>
</UEmpty>
<template v-if="showList">
<ApiKeysTable
v-if="isDesktop"
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
<ApiKeyCardList
v-else
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
</template>
<div class="flex gap-2.5">
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
<span class="text-base text-pretty text-toned">{{
t("Resetting a user's password revokes all of their keys.")
}}</span>
</div>
</div>
<div
v-if="showPinnedButton"
v-show="!anyDialogOpen"
class="sticky bottom-0 z-10 -mx-4 mt-4 border-t border-default bg-default p-4"
>
<UButton
class="h-12 w-full justify-center text-[0.9375rem]"
@click="openCreateModal"
>
{{ t('Create key') }}
</UButton>
</div>
<CreateApiKeyModal
:open="createOpen"
@update:open="onCreateOpenChange"
@created="onCreated"
/>
<ApiKeyReveal
v-if="revealed"
:open="true"
:name="revealed.name"
:api-key="revealed.key"
@done="onRevealDone"
/>
</DashboardShell>
</template>
+384 -109
View File
@@ -1,155 +1,430 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {
API_GROUPS,
ERROR_ROWS,
ERROR_SAMPLE,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
const SPY_OFFSET_PX = 120
const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() =>
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
)
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
function copyBaseUrl() {
copy(origin.value)
}
const sampleArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>Every bathroom is different, but the way we price them isn’t.</p>',
images: [
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
])
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
{
id: 1,
path: '/static/uploadedImages/2f6a1c0e-2b8b-4c3d-9c1e-1a2b3c4d5e6f.png',
article_id: 12,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-03-12T09:14:00Z',
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
],
body_format: 'html',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
}
]
})
const sampleArticleList = [sampleArticle]
const endpoints = computed(() => [
{
method: 'GET' as const,
path: '/v1/article/',
get description() {
return t('Lists every online article.')
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2),
},
},
{
method: 'GET' as const,
path: '/v1/article/{id}',
get description() {
return t(
'Returns one online article. Drafts and unknown ids both return 404.',
)
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2),
},
},
const authNotes = computed(() => [
t(
'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.',
),
t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
),
])
const notes = computed(() => [
t(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
),
t(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
),
t(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
),
t(
'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.',
),
])
const errorColumns = computed(() => [
{
accessorKey: 'status',
get header() {
return t('Status')
},
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
},
{
accessorKey: 'meaning',
get header() {
return t('Meaning')
},
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
},
{
accessorKey: 'messages',
get header() {
return t('Message')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
function quoteMessages(messages: string[]) {
return messages.map((message) => `"${message}"`).join(' / ')
}
function routeCount(count: number) {
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
}
function scrollParent(element: HTMLElement) {
let current = element.parentElement
while (current) {
const overflow = getComputedStyle(current).overflowY
if (overflow === 'auto' || overflow === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function sectionElement(id: string) {
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
}
function goTo(id: string) {
activeSection.value = id
sectionElement(id)?.scrollIntoView?.({ block: 'start' })
}
function updateActive() {
const element = root.value
const parent = element ? scrollParent(element) : null
if (!parent) {
return
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
for (const section of sections.value) {
const target = sectionElement(section.id)
if (target && target.getBoundingClientRect().top <= limit) {
current = section.id
}
}
activeSection.value = current
}
let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
})
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</script>
<template>
<DashboardShell :mobile-title="t('Integration')">
<div class="flex max-w-199 flex-col gap-6">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Connect your website') }}
</h1>
<p class="text-muted">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
</div>
<p class="max-w-180 text-base text-pretty text-toned">
{{
t(
'A guide for the developer who builds your public site.',
'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>
</div>
<p class="text-base text-pretty text-toned">
{{
t(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{
t('Base URL')
}}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
</div>
</div>
</div>
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.path"
:method="endpoint.method"
:path="endpoint.path"
:description="endpoint.description"
:examples="endpoint.examples"
/>
<section
data-anchor="auth"
aria-labelledby="h-auth"
:class="[SECTION_CLASS, 'gap-3.5']"
>
<h2 id="h-auth" :class="SECTION_TITLE">{{ t('Authentication') }}</h2>
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div :class="AUTH_CARD">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('Public') }}</span>
<UBadge
:label="t('No key')"
color="neutral"
variant="outline"
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
/>
</div>
<span class="text-pretty text-toned">{{
t(
'No key needed. Two read-only routes, and only online articles are returned.',
)
}}</span>
<div
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
>
<span>GET /v1/article/</span>
<span>GET /v1/article/{id}</span>
</div>
</div>
<div class="flex flex-col gap-2.5">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon
name="i-lucide-info"
class="mt-0.75 size-4 shrink-0 text-info"
/>
<span class="text-base text-pretty text-toned">{{
note
<div :class="[AUTH_CARD, 'min-w-0']">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('API key') }}</span>
<UBadge
:label="t('Full access')"
color="success"
variant="soft"
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
/>
</div>
<span class="text-pretty text-toned"
><CodeText
:text="
t(
'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.',
)
"
/></span>
<UTabs
v-model="authTab"
:items="authItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
:label="(item as (typeof authItems)[number]).title"
:lang="(item as (typeof authItems)[number]).lang"
:code="(item as (typeof authItems)[number]).code"
/>
</template>
</UTabs>
<span class="text-pretty text-muted"
><CodeText
:text="
t(
'Keys start with `rcms_` and are shown in full once, when you create them.',
)
"
/></span>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="min-h-11 self-start px-0 lg:min-h-7"
>
{{ t('Manage API keys') }}
</UButton>
</div>
</div>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
<section
v-for="group in API_GROUPS"
:key="group.key"
:data-anchor="group.key"
:aria-labelledby="`h-${group.key}`"
:class="SECTION_CLASS"
>
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 :id="`h-${group.key}`" :class="SECTION_TITLE">
{{ translate(group.title) }}
</h2>
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span>
</div>
<span class="text-pretty text-toned">{{ group.description }}</span>
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in group.endpoints"
:key="endpoint.id"
:endpoint="endpoint"
:origin="origin"
/>
</div>
</section>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
<span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount])
}}</span>
</div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
</section>
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
<h2 id="h-errors" :class="SECTION_TITLE">{{ t('Errors') }}</h2>
<span class="text-pretty text-toned">{{
t(
'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>
<UTable
:data="ERROR_ROWS"
:columns="errorColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-3 py-2 text-sm font-medium text-muted',
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #status-cell="{ row }">
<span class="font-mono text-sm font-medium text-highlighted">{{
row.original.status
}}</span>
</template>
<template #meaning-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-highlighted">{{
translate(row.original.meaning)
}}</span>
<div class="flex flex-col gap-0.5 lg:hidden">
<span class="text-pretty text-toned">{{
quoteMessages(row.original.messages)
}}</span>
</div>
</div>
</template>
<template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty">{{
quoteMessages(row.original.messages)
}}</span>
</div>
</template>
</UTable>
<div class="min-w-0 border border-default bg-elevated">
<div
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
>
{{ t('Error response · text/plain') }}
</div>
<pre
tabindex="0"
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
>{{ ERROR_SAMPLE }}</pre
>
</div>
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
<h2 id="h-notes" :class="SECTION_TITLE">{{ t('Notes') }}</h2>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
</div>
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
</div>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
+488 -88
View File
@@ -1,15 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import {
API_KEY_PLACEHOLDER,
ERROR_SAMPLE,
authExamples,
allEndpoints,
endpointExamples,
} from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Media',
'Users and invites',
'Settings',
'Session only',
'Errors',
'Notes',
]
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
function stubOrigin(origin: string) {
vi.stubGlobal('location', { ...window.location, origin })
@@ -25,6 +44,9 @@ function stubClipboard() {
}
async function mountIntegration() {
stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -41,10 +63,56 @@ async function mountIntegration() {
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, name: string) {
function findButton(wrapper: VueWrapper, name: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === name)
}
function row(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))
if (!found) {
throw new Error(`no row for ${route}`)
}
return found
}
function card(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
await row(wrapper, route).trigger('click')
await flushPromises()
return card(wrapper, route)
}
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
function rowRoute(item: ReturnType<VueWrapper['get']>) {
const [method, path] = item.findAll('span.font-mono').map((span) => span.text())
return `${method} ${path}`
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
@@ -53,64 +121,154 @@ afterEach(async () => {
await router.push('/login')
})
describe("Default — the page loads with the app's own origin", () => {
it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => {
stubOrigin(STUB_ORIGIN)
describe('Default, every card collapsed', () => {
it('renders the header, intro, base URL and collapsed cards', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Connect your website')
expect(wrapper.text()).toContain('A guide for the developer who builds your public site.')
expect(wrapper.text()).toContain('Integration')
expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference')
expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.')
expect(wrapper.text()).toContain(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors.",
'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.',
)
expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text())
expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.')
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.',
)
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0)
fetchTabs.forEach((tab) => {
expect(tab.attributes('aria-selected')).toBe('true')
})
expect(wrapper.text()).toContain(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
)
expect(wrapper.text()).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(wrapper.text()).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
const rows = wrapper
.findAllComponents(EndpointCard)
.map((item) => item.get('button[aria-expanded]'))
expect(rows).toHaveLength(allEndpoints().length)
rows.forEach((button) => expect(button.attributes('aria-expanded')).toBe('false'))
expect(
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
).toHaveLength(0)
})
it('renders the breadcrumb Integration', async () => {
stubOrigin(STUB_ORIGIN)
it('lists the sections in the sticky index and the Jump to select', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Integration')
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to')
})
it('shows Owner only on the three invite rows and Verified user elsewhere', async () => {
const wrapper = await mountIntegration()
for (const route of [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]) {
expect(row(wrapper, route).text()).toContain('Owner only')
expect(row(wrapper, route).text()).not.toContain('Verified user')
}
expect(row(wrapper, 'GET /v1/auth/me').text()).toContain('Verified user')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('Public')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('API key OK')
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the 20 cards as 7, 6, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([7, 6, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('7 routes')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request while rendering or expanding cards', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
expect(globalThis.fetch).toBe(fetchSpy)
expect(fetchSpy).not.toHaveBeenCalled()
})
})
describe('Copied — the Base URL copy button', () => {
describe('Group scrolled, one card expanded', () => {
it('reports aria-expanded and shows the tables, tabs, code and errors', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
expect(row(wrapper, 'POST /v1/article/').attributes('aria-expanded')).toBe('true')
const text = created.text()
expect(text.indexOf('Query parameters')).toBeGreaterThan(-1)
expect(text.indexOf('Query parameters')).toBeLessThan(text.indexOf('Request body'))
expect(text).toContain('format')
expect(text).toContain('markdown, html or json. Default markdown.')
expect(text).toContain('title')
expect(text).toContain("3 to 200 characters. Can't be changed later.")
expect(text).toContain('Up to 10 000 characters.')
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(text).toContain('JavaScript')
expect(
created
.findAll('button')
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
).toBe(true)
expect(text).toContain('Errors')
const statuses = created
.findAll('[title]')
.map((item) => item.text())
.join(' ')
expect(statuses).toContain('400')
expect(statuses).toContain('401')
expect(text).toContain('Article title must be between 3 and 200 characters.')
expect(text).toContain('The format must be one of: markdown, html, json.')
})
it('shows "No query parameters." and "No request body." when a card has none', async () => {
const wrapper = await mountIntegration()
const published = await expand(wrapper, 'GET /v1/settings')
expect(published.text()).toContain('No query parameters.')
expect(published.text()).toContain('No request body.')
})
it('shows the six media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('1 to 100. Default 24.')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
const wrapper = await mountIntegration()
const upload = await expand(wrapper, 'POST /v1/media/')
expect(upload.text()).toContain('Request body · multipart/form-data, 1 file field')
expect(upload.text()).toContain('file')
expect(upload.text()).toContain('the image could not be read')
expect(upload.text()).toContain('the file exceeds the maximum upload size')
expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image')
const invite = await expand(wrapper, 'POST /v1/user/invite')
expect(invite.text()).toContain('Only the owner can do this.')
expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.')
expect(invite.text()).toContain("There's already a pending invite for this address.")
})
})
describe('Copied, the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
stubOrigin(STUB_ORIGIN)
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const copyButton = findButton(wrapper, 'Copy')!
await copyButton.trigger('click')
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
@@ -124,66 +282,308 @@ describe('Copied — the Base URL copy button', () => {
})
})
describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => {
it("copies only that card's code and leaves the other buttons unaffected", async () => {
stubOrigin(STUB_ORIGIN)
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
const cards = wrapper.findAllComponents(EndpointCard)
const firstCardCopyButton = cards[0]
const button = created
.findAll('button')
.find((button) => button.text().trim() === 'Copy')!
await firstCardCopyButton.trigger('click')
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
await button.trigger('click')
await flushPromises()
const expectedCode =
`const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode)
const expected = endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const copyButtons = wrapper.findAll('button').filter((button) => {
const text = button.text().trim()
return text === 'Copy' || text === 'Copied'
})
const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied')
expect(copiedButtons).toHaveLength(1)
const baseUrlCopyButton = findButton(wrapper, 'Copy')
expect(baseUrlCopyButton).toBeTruthy()
const secondCardCopyButton = cards[1]
.findAll('button')
.find((button) => button.text().trim() === 'Copy')
expect(secondCardCopyButton).toBeTruthy()
await vi.advanceTimersByTimeAsync(2000)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
describe('Authentication', () => {
it('renders the Public and API key cards, the tabs for GET /v1/auth/me, the link and both notes', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
describe('curl and Response tabs render, unchecked visually', () => {
it('shows the curl command and the Response JSON sample built from the real Article fields', async () => {
stubOrigin(STUB_ORIGIN)
expect(auth.text()).toContain(
'No key needed. Two read-only routes, and only online articles are returned.',
)
expect(auth.text()).toContain('GET /v1/article/')
expect(auth.text()).toContain('GET /v1/article/{id}')
expect(auth.text()).toContain(
"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.",
)
expect(auth.text()).toContain(
'Keys start with rcms_ and are shown in full once, when you create them.',
)
const tabs = auth.findAll('[role="tab"]').map((tab) => tab.text())
expect(tabs).toEqual(['curl', 'fetch'])
const examples = authExamples(STUB_ORIGIN)
expect(auth.text()).toContain(`${STUB_ORIGIN}/v1/auth/me`)
expect(auth.text()).toContain(API_KEY_PLACEHOLDER)
await activateTab(auth.element as HTMLElement, 'fetch')
expect(auth.text()).toContain(`fetch('${STUB_ORIGIN}/v1/auth/me'`)
expect(examples.curl).toContain('Authorization: Bearer rcms_')
const link = auth.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(link.attributes('href')).toBe('/integration/api-keys')
expect(auth.text()).toContain(
'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.',
)
expect(auth.text()).toContain(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a Retry-After header.',
)
})
it('navigates the Manage API keys link to the keys screen', async () => {
const wrapper = await mountIntegration()
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl')
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
await activateTab(firstCard.element as HTMLElement, 'Response')
describe('Session only', () => {
it('renders the banner, 13 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(firstCard.text()).toContain('"is_online"')
expect(firstCard.text()).toContain('"created_at"')
expect(firstCard.text()).toContain('"id"')
expect(session.text()).toContain('13 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(13)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
const chipRows = rows.filter((item) => item.text().includes('Session only'))
expect(chipRows.map(rowRoute)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
chipRows.forEach((item) =>
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
)
})
it('captions Log in, Log out, Register and Reset password without a 403 claim', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-session-row]')
for (const path of [
'/v1/auth/login',
'/v1/auth/logout',
'/v1/auth/register',
'/v1/auth/password-reset',
]) {
const item = rows.find(
(candidate) =>
candidate.text().includes(path) &&
candidate.text().includes('Made for the browser.'),
)!
expect(item.text()).toContain('A key is ignored here, with no 403.')
expect(item.text()).not.toContain('Session only')
}
})
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('System · owner only')
expect(session.text()).toContain('Sign in and account')
expect(session.text()).toContain('API key management')
const rowLabels = wrapper
.findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label'))
expect(rowLabels.some((label) => label?.includes('/v1/system'))).toBe(false)
})
it('lists the 6 Public auth flow rows with the Public chip and no lock', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-public-row]')
expect(rows).toHaveLength(6)
expect(rows.map((item) => item.text())).toEqual([
expect.stringContaining('GET/v1/Hello world.'),
expect.stringContaining('GET/v1/auth/registrationWhether registration is open.'),
expect.stringContaining(
'POST/v1/auth/password-reset/confirmSet a new password with the emailed code.',
),
expect.stringContaining('GET/v1/auth/inviteCheck an invite code.'),
expect.stringContaining('POST/v1/auth/invite/acceptAccept an invite.'),
expect.stringContaining('GET/healthzPlain text "ok". Outside /v1.'),
])
rows.forEach((item) => {
expect(item.text()).toContain('Public')
expect(item.text()).not.toContain('Session only')
expect(item.find('[class*="lucide-lock"]').exists()).toBe(false)
})
})
})
describe('Errors', () => {
it('renders the intro, the real messages and the 403 sample', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.text()).toContain(
'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.',
)
const rows = errors.findAll('tbody tr')
expect(rows.map((item) => item.find('td').text())).toEqual([
'400',
'401',
'403',
'404',
'409',
'429',
'500',
])
const text = errors.text()
for (const message of [
'The request cannot be processed due to a mismatch in the format of the body.',
'The expiry must be a date in the future.',
'You must be logged in to access this resource.',
'This action needs a real session.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
'Article not found',
'Image not found',
'Invite not found',
'API key not found',
"There's already a pending invite for this address.",
'This invite has already been accepted.',
'Too many requests. Please try again later.',
'Internal server error',
]) {
expect(text).toContain(message)
}
const sample = errors.get('pre').text()
expect(sample).toBe(ERROR_SAMPLE)
expect(sample.split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
})
describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
const media = index.findAll('button').find((button) => button.text() === 'Media')!
expect(media.attributes('aria-current')).toBeUndefined()
await media.trigger('click')
await flushPromises()
expect(media.attributes('aria-current')).toBe('true')
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView.mock.contexts[0]).toBe(section(wrapper, 'media').element)
const current = index.findAll('button[aria-current="true"]')
expect(current.map((button) => button.text())).toEqual(['Media'])
})
it('lists the same sections in the mobile Jump to select', async () => {
const wrapper = await mountIntegration()
const jump = wrapper.get('button[aria-label="Jump to section"]')
await jump.trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]')).map(
(option) => option.textContent?.trim(),
)
expect(options).toEqual(SECTION_LABELS)
})
})
describe('Tabs, curl and Response render, unchecked visually', () => {
it('shows curl with the origin and the Authorization header on a non-public route', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
await activateTab(created.element as HTMLElement, 'curl')
expect(created.text()).toContain(`curl -X POST "${STUB_ORIGIN}/v1/article/"`)
expect(created.text()).toContain(`Authorization: Bearer ${API_KEY_PLACEHOLDER}`)
})
it('shows curl without an Authorization header on a public route', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/article/')
await activateTab(list.element as HTMLElement, 'curl')
expect(list.text()).toContain(`curl "${STUB_ORIGIN}/v1/article/"`)
expect(list.text()).not.toContain('Authorization')
})
it('shows the Response sample built from the real article fields', async () => {
const wrapper = await mountIntegration()
const single = await expand(wrapper, 'GET /v1/article/{id}')
await activateTab(single.element as HTMLElement, 'Response')
for (const key of [
'"id"',
'"title"',
'"body"',
'"body_format"',
'"is_online"',
'"created_at"',
'"updated_at"',
]) {
expect(single.text()).toContain(key)
}
expect(single.text()).not.toContain('"images"')
expect(single.text()).not.toContain('"status"')
})
})
describe('Notes', () => {
it('renders the format, image-prefix and CORS notes', async () => {
const wrapper = await mountIntegration()
const notes = section(wrapper, 'notes').text()
expect(notes).toContain(
'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.',
)
expect(notes).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(notes).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
})
+327 -11
View File
@@ -823,6 +823,43 @@ async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
}
function integrationState(overrides: Partial<VisualState> = {}): VisualState {
return {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
...overrides,
}
}
async function scrollToAnchor(
page: Page,
anchor: string,
offsets?: { desktop?: number; mobile?: number },
) {
const mobile = (page.viewportSize()?.width ?? 0) < 1024
const offset = mobile ? offsets?.mobile : offsets?.desktop
await page.evaluate(
({ id, offset }) => {
const target = document.querySelector(`[data-anchor="${id}"]`)
if (!target) return
if (offset === undefined) {
target.scrollIntoView({ block: 'start' })
return
}
let scroller = target.parentElement
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
if (!scroller) return
scroller.scrollTop +=
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
},
{ id: anchor, offset },
)
await page.waitForTimeout(150)
}
const acceptInvitePath = '/invite/accept?c=abc123'
const acceptInviteEmail = 'lea.martin@renew.example'
@@ -1976,6 +2013,239 @@ const pickerStates: Record<string, VisualState> = {
),
}
const apiKeysFullKey = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
const apiKeysTooLongName =
'Nightly import of the new media library from the Brussels office file server'
const apiKeysOwnerList = [
{
id: 4,
user_id: 1,
name: 'Website build',
prefix: 'rcms_Kf3x9Qw',
status: 'active',
created_at: '2026-03-12T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: null,
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
created_by_name: 'Jonas',
},
{
id: 3,
user_id: 1,
name: 'Staging preview',
prefix: 'rcms_Zt2dL8a',
status: 'expired',
created_at: '2026-06-14T09:00:00Z',
last_used_at: '2026-08-20T09:00:00Z',
expires_at: '2026-08-30T12:00:00Z',
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 1,
user_id: 3,
name: 'Old migration script',
prefix: 'rcms_Hn5wR4c',
status: 'revoked',
created_at: '2026-04-03T09:00:00Z',
last_used_at: '2026-05-09T09:00:00Z',
expires_at: null,
revoked_at: '2026-05-10T09:00:00Z',
created_by_name: 'Inès Duarte',
},
]
const apiKeysAdminList = [
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
},
{
id: 1,
user_id: 2,
name: 'Old CI token',
prefix: 'rcms_Lp9cE1x',
status: 'revoked',
created_at: '2026-01-18T09:00:00Z',
last_used_at: '2026-02-02T09:00:00Z',
expires_at: null,
revoked_at: '2026-02-03T09:00:00Z',
},
]
function apiKeysApi(
list: ApiMock,
overrides: Record<string, ApiMock> = {},
session: ApiMock = verifiedSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/api-key': list,
...overrides,
}
}
const apiKeysOwnerApi = (overrides: Record<string, ApiMock> = {}) =>
apiKeysApi({ status: 200, body: apiKeysOwnerList }, overrides)
const apiKeysCreated: ApiMock = {
status: 201,
body: {
id: 5,
name: 'Nightly media import',
prefix: apiKeysFullKey.slice(0, 12),
key: apiKeysFullKey,
created_at: '2026-10-04T09:00:00Z',
expires_at: null,
},
}
async function openApiKeyForm(page: Page) {
await page.getByRole('button', { name: 'Create key', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
async function fillApiKeyForm(page: Page, name: string, expiry: string) {
await page.locator('input[name="name"]').fill(name)
if (expiry) {
await page.locator('input[name="expires_at"]').fill(expiry)
}
}
async function submitApiKeyForm(page: Page) {
await page.getByRole('dialog').getByRole('button', { name: 'Create key', exact: true }).click()
}
async function settleApiKeyDialog(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.mouse.move(0, 0)
}
async function revealApiKey(page: Page) {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '')
await submitApiKeyForm(page)
await page.getByText('Your new API key').waitFor()
await page.mouse.move(0, 0)
}
function nightlyRevokeButton(page: Page) {
return page
.locator('tbody tr, li')
.filter({ hasText: 'Nightly media import' })
.getByRole('button', { name: 'Revoke', exact: true })
}
async function openApiKeyRevoke(page: Page) {
await nightlyRevokeButton(page).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
async function confirmApiKeyRevoke(page: Page) {
await openApiKeyRevoke(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Revoke key', exact: true })
.click()
await page.getByText('Key revoked').waitFor()
await page.mouse.move(0, 0)
}
const apiKeysStates: Record<string, VisualState> = {
'Owner: list': { path: '/integration/api-keys', api: apiKeysOwnerApi() },
'Admin: own keys only': {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, body: apiKeysAdminList }, {}, adminSession),
},
Loading: {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, hang: true }),
},
Empty: { path: '/integration/api-keys', api: apiKeysApi({ status: 200, body: [] }) },
Error: { path: '/integration/api-keys', api: apiKeysApi({ status: 500 }) },
'Create key': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '2026-12-31')
await settleApiKeyDialog(page)
},
},
'Create key: validation': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, '', '2026-03-12')
await submitApiKeyForm(page)
await settleApiKeyDialog(page)
},
},
'Create key: name too long': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, apiKeysTooLongName, '')
await settleApiKeyDialog(page)
},
},
'One-time reveal': {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: revealApiKey,
},
'One-time reveal: copied': {
path: '/integration/api-keys',
origin: integrationOrigin,
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: async (page) => {
await revealApiKey(page)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy', exact: true })
.click()
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copied', exact: true })
.waitFor()
await page.mouse.move(0, 0)
},
},
'Revoke: confirm': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: openApiKeyRevoke,
},
Revoked: {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'DELETE /v1/api-key/2': { status: 204 } }),
steps: confirmApiKeyRevoke,
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -2237,18 +2507,64 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
},
'integration-api-keys': apiKeysStates,
integration: {
Default: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
},
Copied: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
steps: clickBaseUrlCopy,
},
'1. Default': integrationState(),
'2. Group scrolled, one card expanded': integrationState({
steps: async (page) => {
await page.getByRole('button', { name: /^POST \/v1\/media\/\. / }).click()
await page
.locator('[data-anchor="ep-med-up"]')
.getByRole('tab', { name: 'curl', exact: true })
.click()
await scrollToAnchor(page, 'ep-med-up', { desktop: 126, mobile: 83 })
},
exception: {
maxPercent: 37.5,
reason: 'The new Query parameters block adds about 70 px above Request body, which shifts every row below the card, and the captions use the real server strings instead of the board samples',
approvedBy: 'Florian Sylvain',
},
}),
'3a. Copied: base URL': integrationState({ steps: clickBaseUrlCopy }),
'3b. Copied: CodeBlock': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'auth')
await page.getByRole('button', { name: 'Copy Shell example' }).click()
await page.mouse.move(0, 0)
},
}),
'4. Authentication': integrationState({
steps: (page) => scrollToAnchor(page, 'auth'),
}),
'5. Session only': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: 55 }),
exception: {
maxPercent: 12,
reason: 'The banner names the real routes and wraps differently, a 13th row (DELETE /v1/auth/register) and the login-family captions are new, and a Public auth flow block follows',
approvedBy: 'Florian Sylvain',
},
}),
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 48, mobile: 47 }),
exception: {
maxPercent: 12.5,
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board scroll position varies between captures',
approvedBy: 'Florian Sylvain',
},
}),
'7. Navigation': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: 14 })
if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}
},
exception: {
maxPercent: 12.5,
reason: 'The board frame scroll position varies by 18px between captures, so the desktop diff flickers between about 8 % and 12 %',
approvedBy: 'Florian Sylvain',
},
}),
},
articles: {
Default: {