Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e01c29657 | ||
|
|
acd392c29d | ||
|
|
514a082c96 | ||
|
|
844842fdae | ||
|
|
7e9bd9ba30 | ||
|
|
68401d66e0 | ||
|
|
daf497576f | ||
|
|
0490a4c494 |
No files matched your search
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# DB
|
# DB
|
||||||
*.db
|
*.db
|
||||||
data/
|
/data/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
bin
|
bin
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||||
domainArticle "RenewCMS/internal/domain/article"
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
domainImage "RenewCMS/internal/domain/image"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
domainInvite "RenewCMS/internal/domain/invite"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"RenewCMS/internal/infrastructure/hashing"
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
"RenewCMS/internal/infrastructure/mailer"
|
"RenewCMS/internal/infrastructure/mailer"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
"RenewCMS/internal/infrastructure/persistence/models"
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
"RenewCMS/internal/infrastructure/update"
|
"RenewCMS/internal/infrastructure/update"
|
||||||
@@ -17,9 +19,11 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -28,6 +32,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UseCases struct {
|
type UseCases struct {
|
||||||
|
apiKeyAuthOnce sync.Once
|
||||||
|
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||||
Token *jwtauth.JWTAuth
|
Token *jwtauth.JWTAuth
|
||||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||||
GetArticleUseCase *useCases.GetArticleUseCase
|
GetArticleUseCase *useCases.GetArticleUseCase
|
||||||
@@ -62,6 +68,10 @@ type UseCases struct {
|
|||||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||||
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
||||||
|
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
|
||||||
|
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
|
||||||
|
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
|
||||||
|
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
@@ -69,6 +79,7 @@ type Repositories struct {
|
|||||||
UserRepo domainUser.Repository
|
UserRepo domainUser.Repository
|
||||||
ImageRepo domainImage.Repository
|
ImageRepo domainImage.Repository
|
||||||
InviteRepo domainInvite.Repository
|
InviteRepo domainInvite.Repository
|
||||||
|
APIKeyRepo domainAPIKey.Repository
|
||||||
MailRepo domainMail.Repository
|
MailRepo domainMail.Repository
|
||||||
ReleaseFeed domainUpdate.ReleaseFeed
|
ReleaseFeed domainUpdate.ReleaseFeed
|
||||||
UpdateStore domainUpdate.StatusStore
|
UpdateStore domainUpdate.StatusStore
|
||||||
@@ -105,7 +116,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
|||||||
panic("Unable to open the database: " + err.Error())
|
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())
|
panic("Failed to migrate database: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +146,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
|||||||
UserRepo: persistence.NewUserRepository(db),
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||||
InviteRepo: persistence.NewInviteRepository(db),
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
|
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||||
@@ -152,13 +164,15 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
|||||||
|
|
||||||
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
renderer := markdown.NewConverter()
|
||||||
|
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||||
|
|
||||||
return &UseCases{
|
return &UseCases{
|
||||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
|
||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
|
||||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
|
||||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||||
@@ -166,7 +180,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
|
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
|
||||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
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),
|
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
||||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||||
@@ -188,6 +202,10 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
|
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
|
||||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
|
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),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -41,6 +42,53 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type articleResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
BodyFormat domain.BodyFormat `json:"body_format"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
|
||||||
|
body := json.RawMessage(article.Body)
|
||||||
|
if format != domain.BodyFormatJSON {
|
||||||
|
encoded, err := json.Marshal(article.Body)
|
||||||
|
if err != nil {
|
||||||
|
return articleResponse{}, err
|
||||||
|
}
|
||||||
|
body = encoded
|
||||||
|
}
|
||||||
|
return articleResponse{
|
||||||
|
ID: article.ID,
|
||||||
|
Title: article.Title,
|
||||||
|
Body: body,
|
||||||
|
BodyFormat: format,
|
||||||
|
IsOnline: article.IsOnline,
|
||||||
|
CreatedAt: article.CreatedAt,
|
||||||
|
UpdatedAt: article.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
|
||||||
|
response, err := toArticleResponse(article, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
|
||||||
|
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return format, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseArticleId(r *http.Request) (uint32, error) {
|
func parseArticleId(r *http.Request) (uint32, error) {
|
||||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
return uint32(id), err
|
return uint32(id), err
|
||||||
@@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var article domain.Article
|
var article domain.Article
|
||||||
if h.callerIsVerified(r) {
|
if h.callerIsVerified(r) {
|
||||||
article, err = h.GetUseCase.GetArticle(id)
|
article, err = h.GetUseCase.GetArticle(id, format)
|
||||||
} else {
|
} else {
|
||||||
article, err = h.GetUseCase.GetOnlineArticle(id)
|
article, err = h.GetUseCase.GetOnlineArticle(id, format)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
@@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
articleJson, err := json.Marshal(article)
|
articleJson, err := encodeArticle(article, format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -80,6 +133,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var localArticle PostArticle
|
var localArticle PostArticle
|
||||||
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
||||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
@@ -94,6 +152,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
|
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
|
||||||
Title: localArticle.Title,
|
Title: localArticle.Title,
|
||||||
Body: localArticle.Body,
|
Body: localArticle.Body,
|
||||||
|
Format: format,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||||
@@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
articleJson, err := json.Marshal(createdArticle)
|
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
if _, err := w.Write(articleJson); err != nil {
|
|
||||||
log.Printf("Failed to write response: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||||
@@ -124,13 +174,16 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) {
|
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
|
||||||
articleJson, err := json.Marshal(article)
|
articleJson, err := encodeArticle(article, format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}
|
||||||
if _, err := w.Write(articleJson); err != nil {
|
if _, err := w.Write(articleJson); err != nil {
|
||||||
log.Printf("Failed to write response: %v", err)
|
log.Printf("Failed to write response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -143,13 +196,18 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline)
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeArticleErrorResponse(w, err)
|
writeArticleErrorResponse(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeArticleJsonResponse(w, updatedArticle)
|
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateArticleBody struct {
|
type UpdateArticleBody struct {
|
||||||
@@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var localArticle UpdateArticleBody
|
var localArticle UpdateArticleBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
||||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
@@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body)
|
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeArticleErrorResponse(w, err)
|
writeArticleErrorResponse(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeArticleJsonResponse(w, updatedArticle)
|
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -206,19 +269,34 @@ func (h *Handler) callerIsVerified(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var articles []domain.Article
|
var articles []domain.Article
|
||||||
var err error
|
var err error
|
||||||
if h.callerIsVerified(r) {
|
if h.callerIsVerified(r) {
|
||||||
articles, err = h.ListUseCase.ListAllArticles()
|
articles, err = h.ListUseCase.ListAllArticles(format)
|
||||||
} else {
|
} else {
|
||||||
articles, err = h.ListUseCase.ListOnlineArticles()
|
articles, err = h.ListUseCase.ListOnlineArticles(format)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
articlesJson, err := json.Marshal(articles)
|
responses := make([]articleResponse, 0, len(articles))
|
||||||
|
for _, article := range articles {
|
||||||
|
response, err := toArticleResponse(article, format)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
articlesJson, err := json.Marshal(responses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -34,3 +34,7 @@ const UpdateAlreadyRunningErrorMessage = "An update is already running or waitin
|
|||||||
const NothingToUpdateErrorMessage = "There is nothing to update."
|
const NothingToUpdateErrorMessage = "There is nothing to update."
|
||||||
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
||||||
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
|
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
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/handlers/apikey"
|
||||||
"RenewCMS/api/handlers/article"
|
"RenewCMS/api/handlers/article"
|
||||||
"RenewCMS/api/handlers/auth"
|
"RenewCMS/api/handlers/auth"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
@@ -33,8 +34,21 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_, _ = w.Write(msg)
|
_, _ = 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 {
|
func NewArticleRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
|
||||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
||||||
|
|
||||||
@@ -85,11 +99,18 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
|||||||
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Post("/verify", verificationHandler.Verify)
|
r.Post("/verify", verificationHandler.Verify)
|
||||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(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)
|
r.Get("/me", meHandler.Me)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -102,6 +123,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
|||||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
@@ -124,6 +146,7 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
|||||||
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(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)
|
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.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
@@ -165,12 +189,29 @@ func NewSystemRouter(container *UseCases) http.Handler {
|
|||||||
return r
|
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 {
|
func NewMediaRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(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("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container))
|
r.Mount("/settings", NewSettingsRouter(container))
|
||||||
r.Mount("/media", NewMediaRouter(container))
|
r.Mount("/media", NewMediaRouter(container))
|
||||||
|
r.Mount("/api-key", NewAPIKeyRouter(container))
|
||||||
r.Mount("/system", NewSystemRouter(container))
|
r.Mount("/system", NewSystemRouter(container))
|
||||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
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{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: origins,
|
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"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
|
|||||||
+39
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Reading articles
|
||||||
|
|
||||||
|
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
|
||||||
|
|
||||||
|
## Routes that honour `format`
|
||||||
|
|
||||||
|
- `GET /v1/article/`
|
||||||
|
- `GET /v1/article/{id}`
|
||||||
|
- `POST /v1/article/`
|
||||||
|
- `PATCH /v1/article/{id}`
|
||||||
|
- `PATCH /v1/article/{id}/publish`
|
||||||
|
- `PATCH /v1/article/{id}/unpublish`
|
||||||
|
|
||||||
|
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
|
||||||
|
|
||||||
|
## The `format` parameter
|
||||||
|
|
||||||
|
| Value | `body` | `body_format` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| empty or absent | markdown string | `markdown` |
|
||||||
|
| `markdown` | markdown string | `markdown` |
|
||||||
|
| `html` | the stored HTML string | `html` |
|
||||||
|
| `json` | JSON array of blocks | `json` |
|
||||||
|
|
||||||
|
Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format.
|
||||||
|
|
||||||
|
Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so.
|
||||||
|
|
||||||
|
## Article object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Backyard Renovation Update",
|
||||||
|
"body": "## Plan\n\nReplace the **old** deck.",
|
||||||
|
"body_format": "markdown",
|
||||||
|
"is_online": true,
|
||||||
|
"created_at": "2026-03-12T09:14:00Z",
|
||||||
|
"updated_at": "2026-09-10T07:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Markdown output
|
||||||
|
|
||||||
|
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
|
||||||
|
|
||||||
|
### Text alignment
|
||||||
|
|
||||||
|
Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<div style="text-align: center">
|
||||||
|
|
||||||
|
A **centered** paragraph
|
||||||
|
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field.
|
||||||
|
|
||||||
|
## JSON block schema
|
||||||
|
|
||||||
|
`body` is a native JSON array. Every block has a `type`.
|
||||||
|
|
||||||
|
| Block | Fields |
|
||||||
|
| --- | --- |
|
||||||
|
| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) |
|
||||||
|
| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) |
|
||||||
|
| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) |
|
||||||
|
| `blockquote` | `children` (blocks) |
|
||||||
|
| `code` | `language` (empty string when unknown), `text` |
|
||||||
|
| `hr` | none |
|
||||||
|
| `image` | `src`, `alt` (empty string when missing) |
|
||||||
|
|
||||||
|
Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`.
|
||||||
|
|
||||||
|
Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
|
||||||
|
{"type":"paragraph","children":[
|
||||||
|
{"type":"text","text":"Replace the ","marks":[]},
|
||||||
|
{"type":"text","text":"old","marks":[{"type":"bold"}]},
|
||||||
|
{"type":"text","text":" deck.","marks":[]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Body |
|
||||||
|
| --- | --- |
|
||||||
|
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
|
||||||
|
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
|
||||||
|
| `404` | `Article not found` |
|
||||||
|
| `500` | `Internal server error` when the body cannot be converted |
|
||||||
@@ -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` |
|
| [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](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` |
|
| [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` |
|
| [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` |
|
| [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` |
|
| [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
|
## 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/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 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 Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
||||||
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
|
- `source/RenewCMS 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".
|
||||||
|
|||||||
@@ -30,7 +30,12 @@
|
|||||||
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
||||||
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
|
| 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 |
|
| 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 |
|
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
|
||||||
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
||||||
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
||||||
@@ -47,7 +52,7 @@ These are the RenewCMS components, built from the primitives on the first page .
|
|||||||
|
|
||||||
### AuthShell
|
### AuthShell
|
||||||
|
|
||||||
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task.
|
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
|
||||||
|
|
||||||
Log in Welcome back. Log in to manage your articles.
|
Log in Welcome back. Log in to manage your articles.
|
||||||
|
|
||||||
@@ -55,7 +60,7 @@ default slot
|
|||||||
|
|
||||||
Forgot password? System
|
Forgot password? System
|
||||||
|
|
||||||
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
|
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
|
||||||
|
|
||||||
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
|
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
|
||||||
|
|
||||||
@@ -383,12 +388,13 @@ The Product components board was not regenerated for these. They are written fro
|
|||||||
|
|
||||||
### UserMenu (changed)
|
### UserMenu (changed)
|
||||||
|
|
||||||
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice and Log out.
|
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
|
||||||
|
|
||||||
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
|
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
|
||||||
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
|
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
|
||||||
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, Log out (log-out). The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
|
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
|
||||||
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme items keep the same order and separators.
|
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
|
||||||
|
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
|
||||||
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
|
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
|
||||||
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
|
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
|
||||||
|
|
||||||
@@ -734,7 +740,16 @@ Three choices: System (the default), Light and Dark. The toggle at the top of th
|
|||||||
|
|
||||||
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
|
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
|
||||||
|
|
||||||
ARIA Menu items use menuitemcheckbox , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
|
ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
|
||||||
|
|
||||||
|
### LanguageSwitcher
|
||||||
|
|
||||||
|
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
|
||||||
|
|
||||||
|
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
|
||||||
|
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
|
||||||
|
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
|
||||||
|
- The choice persists across reloads and applies to every screen at once.
|
||||||
|
|
||||||
## Added with roles, invite actions and Settings
|
## Added with roles, invite actions and Settings
|
||||||
|
|
||||||
@@ -792,7 +807,7 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
|
|||||||
| Component | Nuxt UI base | Props | Emits | Used in |
|
| Component | Nuxt UI base | Props | Emits | Used in |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings |
|
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings |
|
||||||
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta | none | UpdatesCard |
|
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta, pendingTag?, action? (in-block Restart, Ready only) | none | UpdatesCard |
|
||||||
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard |
|
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard |
|
||||||
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
|
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
|
||||||
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) |
|
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) |
|
||||||
|
|||||||
@@ -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} |
|
| `/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 |
|
| `/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` | 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 |
|
| `/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 |
|
| `/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 |
|
| `/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`.
|
||||||
@@ -1,22 +1,82 @@
|
|||||||
# Connect your website
|
# Integration (API reference)
|
||||||
|
|
||||||
- **Route:** `/integration`
|
Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
|
||||||
- **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).
|
|
||||||
|
|
||||||
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
|
## 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")
|
### 5. Session only
|
||||||
- copied: true
|
|
||||||
|
### 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.
|
||||||
@@ -48,8 +48,12 @@ Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the accou
|
|||||||
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) |
|
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) |
|
||||||
| Item | Profile (circle-user icon), highlighted while on `/profile` |
|
| Item | Profile (circle-user icon), highlighted while on `/profile` |
|
||||||
| Separator | |
|
| Separator | |
|
||||||
|
| Label | "Theme": 11 px, weight 500, uppercase, letter-spacing .04em, muted |
|
||||||
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
|
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
|
||||||
| Separator | |
|
| Separator | |
|
||||||
|
| Label | "Language", styled like "Theme" |
|
||||||
|
| Items | English, Français (each in its own language, with `lang`): a radio-style group with a check on the current one; choosing one applies the language at once and keeps the menu open |
|
||||||
|
| Separator | |
|
||||||
| Item | Log out (log-out icon) |
|
| Item | Log out (log-out icon) |
|
||||||
|
|
||||||
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
|
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
|
||||||
@@ -64,7 +68,7 @@ The board's menu now carries System, Light and Dark, matching the shipped UserMe
|
|||||||
|
|
||||||
- Breadcrumbs: Home (mobile title "Home")
|
- Breadcrumbs: Home (mobile title "Home")
|
||||||
- Page behind the menu: Home, default
|
- Page behind the menu: Home, default
|
||||||
- Menu open, as described above, with the System, Light and Dark group between Profile and Log out. The board shows Light checked
|
- Menu open, as described above, with the Theme group (System, Light, Dark) and the Language group (English, Français) between Profile and Log out. The board shows Light and English checked
|
||||||
|
|
||||||
### Loading
|
### Loading
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
|
|||||||
|
|
||||||
| Part | Content |
|
| Part | Content |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). |
|
| Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). The title row is a flex row with an 8 px gap that wraps. In "Ready to install" it carries a **Pending tag** after the title and the block holds the **Restart** button (see below). |
|
||||||
|
| Pending tag | Only in "Ready to install", right after the title: 20 px high, 0 6 px padding, IBM Plex Mono 11 px medium, `--ui-bg` fill, `--ui-warning` text and 1 px `--ui-warning` border, square corners, text "Pending". |
|
||||||
|
| In-block button | Only in "Ready to install", "Restart: confirm" and "Restart: request failed": the primary Restart button sits inside the status block, under the `meta` line, after 8 px of extra top padding, left-aligned (content width, 14 px side padding, 32 px high, 44 px on mobile, not stretched). The buttons row under the block is not drawn in these states. The hint stays under the card. |
|
||||||
| Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. |
|
| Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. |
|
||||||
| Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. |
|
| Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. |
|
||||||
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
|
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
|
||||||
@@ -28,7 +30,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
|
|||||||
| Hint | 12 px muted line under the buttons. |
|
| Hint | 12 px muted line under the buttons. |
|
||||||
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
|
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
|
||||||
|
|
||||||
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary when an update is ready, otherwise secondary), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
|
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
|
||||||
|
|
||||||
## Copy
|
## Copy
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`down
|
|||||||
| Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." |
|
| Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." |
|
||||||
| Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled |
|
| Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled |
|
||||||
| Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." |
|
| Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." |
|
||||||
| Ready | `circle-check`, success: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart"; hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." |
|
| Ready | `clock`, warning, with the "Pending" tag after the title: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart" (primary, inside the block); hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." |
|
||||||
| Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." |
|
| Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." |
|
||||||
| Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." |
|
| Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." |
|
||||||
| Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" |
|
| Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" |
|
||||||
@@ -105,9 +107,9 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
|
|||||||
| Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. |
|
| Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. |
|
||||||
| Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. |
|
| Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. |
|
||||||
| Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". |
|
| Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". |
|
||||||
| Ready to install | The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page. |
|
| Ready to install | The file passed its checks. The card turns warning (orange) with a "Pending" tag and a `clock` icon, and Restart, the primary action, sits inside it. "Restart later" is closing the dialog or leaving the page. |
|
||||||
| Restart: confirm | Restart pressed. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. |
|
| Restart: confirm | Restart pressed. The card behind the dialog keeps the Ready look. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. |
|
||||||
| Restart: request failed | 500 or network failure before the restart began. The site is still running. |
|
| Restart: request failed | 500 or network failure before the restart began. The site is still running. The card behind the dialog keeps the Ready look. |
|
||||||
| Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." |
|
| Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." |
|
||||||
| Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. |
|
| Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. |
|
||||||
| Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. |
|
| Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||||
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
|
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
|
||||||
</div>
|
</div>
|
||||||
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
|
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
||||||
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
|
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
|
||||||
@@ -177,25 +177,8 @@
|
|||||||
</sc-if>
|
</sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
|
|
||||||
<sc-if value="{{ isInteg }}">
|
<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>
|
||||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px;max-width:860px;overflow:hidden">
|
<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>
|
||||||
<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/<uuid>.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="{{ isUsers }}">
|
<sc-if value="{{ isUsers }}">
|
||||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
||||||
@@ -233,7 +216,7 @@
|
|||||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
|
||||||
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
|
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
|
||||||
<span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span>
|
<span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span>
|
||||||
<div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if></div></div>
|
<div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><sc-if value="{{ s.upd.pendingTag }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-bg);color:var(--ui-warning);border:1px solid var(--ui-warning);font:500 11px 'IBM Plex Mono',monospace">Pending</span></sc-if></div><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if><sc-if value="{{ s.upd.inBtn }}"><div style="display:flex;padding-top:8px"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ s.upd.inBtn.bg }};color:{{ s.upd.inBtn.fg }};border:1px solid {{ s.upd.inBtn.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.inBtn.t }}</span></div></sc-if></div></div>
|
||||||
<sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if>
|
<sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if>
|
||||||
<sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if>
|
<sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if>
|
||||||
<sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if>
|
<sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if>
|
||||||
@@ -280,7 +263,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
|
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.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></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
|
||||||
|
|
||||||
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
|
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
|
||||||
|
|
||||||
@@ -435,14 +418,14 @@ class Component extends DCLogic {
|
|||||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||||
return {
|
return {
|
||||||
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||||
themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
||||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
|
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
|
||||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||||
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||||
desk: !m, mob: m, notBoot: !s.boot,
|
desk: !m, mob: m, notBoot: !s.boot,
|
||||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||||
mLead: s.mLead || 'menu',
|
mLead: s.mLead || 'menu',
|
||||||
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,
|
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))',
|
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',
|
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
||||||
|
|||||||
@@ -54,16 +54,17 @@
|
|||||||
<sc-if value="{{ hasFooter }}">
|
<sc-if value="{{ hasFooter }}">
|
||||||
<div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)">
|
<div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)">
|
||||||
<span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span>
|
<span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span>
|
||||||
<span style="height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span>
|
<span style="position:relative;display:flex;align-items:center;gap:8px"><sc-if value="{{ langOpen }}"><div role="menu" aria-label="Language" style="position:absolute;right:0;bottom:36px;z-index:10;width:168px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:{{ optH }};padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.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></div></sc-for></div></sc-if><span data-act="langmenu" role="button" aria-haspopup="menu" aria-expanded="{{ langOpen }}" aria-label="Language: {{ langName }}" style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);background:{{ langBg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span>{{ langName }}</span><span style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span></span>
|
||||||
</div>
|
</div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</div>
|
</div>
|
||||||
</x-dc>
|
</x-dc>
|
||||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":480,"height":620},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":480,"height":620},"s":{"editor":null,"tsType":"object"},"lang":{"editor":"enum","options":["en","fr"],"default":"en","tsType":"string"},"langOpen":{"editor":"boolean","default":false,"tsType":"boolean"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
||||||
class Component extends DCLogic {
|
class Component extends DCLogic {
|
||||||
renderVals() {
|
renderVals() {
|
||||||
const s = this.props.s || {}
|
const s = this.props.s || {}
|
||||||
const m = !!this.props.mobile
|
const m = !!this.props.mobile
|
||||||
|
const lang = this.props.lang || s.lang || 'en'
|
||||||
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
|
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
|
||||||
const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null
|
const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null
|
||||||
const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null
|
const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null
|
||||||
@@ -87,7 +88,9 @@ class Component extends DCLogic {
|
|||||||
return {
|
return {
|
||||||
s, notice, result, fields,
|
s, notice, result, fields,
|
||||||
hasForm: fields.length > 0 && !!s.submit,
|
hasForm: fields.length > 0 && !!s.submit,
|
||||||
hasFooter: !!(s.footer || s.pending || !m),
|
hasFooter: true,
|
||||||
|
langOpen: !!(this.props.langOpen ?? s.langOpen), langName: lang === 'fr' ? 'Français' : 'English', langBg: this.props.langOpen ?? s.langOpen ? 'var(--ui-bg-elevated)' : 'transparent', chipH: m ? '44px' : '28px', optH: m ? '44px' : '32px',
|
||||||
|
langSeg: [['en','English'],['fr','Français']].map(([k,t]) => ({ act: 'lang-' + k, code: k, t, on: lang === k, op: lang === k ? 1 : 0 })),
|
||||||
submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1',
|
submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1',
|
||||||
w: m ? '100%' : '400px',
|
w: m ? '100%' : '400px',
|
||||||
border: m ? '0' : '1px solid var(--ui-border)',
|
border: m ? '0' : '1px solid var(--ui-border)',
|
||||||
|
|||||||
@@ -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/<uuid>.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>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px">
|
<main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px">
|
||||||
<div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden">
|
<div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden">
|
||||||
<div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)">
|
<div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)">
|
||||||
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
|
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" lang="{{ lang }}" lang-open="{{ langOpen }}" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
|
||||||
<sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if>
|
<sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if>
|
||||||
<sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if>
|
<sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if>
|
||||||
<sc-if value="{{ mnav }}">
|
<sc-if value="{{ mnav }}">
|
||||||
@@ -187,6 +187,9 @@ class Component extends DCLogic {
|
|||||||
act(a) {
|
act(a) {
|
||||||
const s = this.state
|
const s = this.state
|
||||||
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
|
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
|
||||||
|
if (a === 'langmenu') return this.setState({ langOpen: !s.langOpen })
|
||||||
|
if (a && a.startsWith('lang-')) return this.setState({ lang: a.slice(5), langOpen: false })
|
||||||
|
if (s.langOpen) this.setState({ langOpen: false })
|
||||||
if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) })
|
if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) })
|
||||||
if (s.userMenu) this.setState({ userMenu: false })
|
if (s.userMenu) this.setState({ userMenu: false })
|
||||||
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
|
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
|
||||||
@@ -428,8 +431,8 @@ class Component extends DCLogic {
|
|||||||
rootRef: this.rootRef, stageRef: this.stageRef,
|
rootRef: this.rootRef, stageRef: this.stageRef,
|
||||||
devices: devs, modes, flows, hint, tools,
|
devices: devs, modes, flows, hint, tools,
|
||||||
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
|
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
|
||||||
mobile, isAuth, isApp, isEmail,
|
mobile, isAuth, isApp, isEmail, lang: s.lang || 'en', langOpen: !!s.langOpen,
|
||||||
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode } : {},
|
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {},
|
||||||
authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
|
authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
|
||||||
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
|
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
|
||||||
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
|
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
|
||||||
|
|||||||
@@ -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="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||||
|
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>
|
||||||
@@ -120,7 +120,7 @@ class Component extends DCLogic {
|
|||||||
const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x })
|
const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x })
|
||||||
const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' }
|
const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' }
|
||||||
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
|
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
|
||||||
const UPD = (o) => { const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
|
const UPD = (o) => { const rdy = o.title === 'Version 2.3.0 is ready to install' && o.btns && o.btns.length; if (rdy) o = { ...o, tone: 'warning', icon: 'clock', pendingTag: true, inBtn: o.btns[0], btns: undefined }; const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
|
||||||
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
|
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
|
||||||
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
|
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
|
||||||
const LAST = 'Last checked today at 09:41'
|
const LAST = 'Last checked today at 09:41'
|
||||||
@@ -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', 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.')] })
|
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: [
|
{ 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: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
|
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', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
|
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: [
|
{ 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.' }),
|
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({ 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.' })
|
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: '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: '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.' }),
|
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
@@ -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` | 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` | 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) |
|
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
|
||||||
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
|
| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) |
|
||||||
| Integration | `/integration` | Copied | Copy clicked (2 s) | [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` | Default | List loaded, newest first | [users](screens/users.md) |
|
||||||
| Users | `/users` | Loading | First load or retry | [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) |
|
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
|
||||||
|
|||||||
@@ -29,3 +29,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
|||||||
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
|
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
|
||||||
| `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart |
|
| `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.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 |
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
This release lets your website read article bodies as markdown, HTML or structured blocks, and fixes text alignment in the editor.
|
||||||
|
|
||||||
|
## New
|
||||||
|
|
||||||
|
**Article body formats**
|
||||||
|
|
||||||
|
- Read an article body as markdown, HTML or a typed block array with the `format` query parameter, so a headless frontend can render it with markdown-it, comark or its own components.
|
||||||
|
- Keep text alignment in every format: markdown wraps centered, right-aligned and justified blocks in a `div` with an inline style, and the block array carries an `align` field.
|
||||||
|
- Keep plain text plain: text that merely looks like markdown, such as `# not a heading` or `- not a list`, is escaped in the markdown output.
|
||||||
|
- See the Integration page snippets and [the article API reference](../api/articles.md) for the full contract.
|
||||||
|
|
||||||
|
## Improvements and fixes
|
||||||
|
|
||||||
|
- Fix the text alignment buttons in the article editor, which were disabled and showed a blocked cursor. Paragraphs and headings can be aligned left, center, right or justified again, and unaligned text stays free of inline styles.
|
||||||
|
- The web interface requests HTML explicitly when it loads, saves, publishes, unpublishes or creates an article, so the editor and the image counts behave exactly as before.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- Changed: `GET /v1/article/`, `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` now return `body` as markdown by default. Pass `?format=html` to keep the previous HTML body.
|
||||||
|
- Every article object now includes `body_format` (`markdown`, `html` or `json`), which names the format of `body`.
|
||||||
|
- `?format=json` on the same routes returns `body` as a JSON array of typed blocks instead of a string.
|
||||||
|
- An unsupported `format` answers `400` before anything is read or written.
|
||||||
|
- Storage and the request bodies of `POST` and `PATCH` stay HTML.
|
||||||
|
|
||||||
|
## Under the hood
|
||||||
|
|
||||||
|
- Add the article body renderer port in the domain, with a markdown and JSON block adapter, and tests for every supported element.
|
||||||
|
- Update `golang.org/x/crypto` to v0.57.0 and add `golang.org/x/net` as a direct dependency for HTML parsing.
|
||||||
|
- Add `@tiptap/extension-text-align` to the web interface.
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
- Replace the binary. No database migration or new environment variable is required.
|
||||||
|
- Clients that read an article `body` as HTML must add `?format=html` to their article requests, because the default is now markdown.
|
||||||
|
- A markdown renderer needs raw HTML enabled for text alignment to show, for example `html: true` in markdown-it.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
Feature: Article body content negotiation on read
|
||||||
|
|
||||||
|
As a headless frontend developer
|
||||||
|
I want article routes to return the body as markdown (default), html or a typed json block array
|
||||||
|
So that my client can render it without handling editor HTML
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given article bodies are stored as Tiptap HTML
|
||||||
|
And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged
|
||||||
|
And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json"
|
||||||
|
And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat
|
||||||
|
And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise
|
||||||
|
And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter
|
||||||
|
And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at
|
||||||
|
And body_format is "markdown", "html" or "json" and always names the format of body
|
||||||
|
And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string)
|
||||||
|
And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish
|
||||||
|
And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found"
|
||||||
|
And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write
|
||||||
|
And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check
|
||||||
|
And a repeated "format" parameter uses its first value
|
||||||
|
And a renderer failure answers 500 "Internal server error"
|
||||||
|
And article 1 is online, titled "Backyard Renovation Update", with body "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
|
||||||
|
|
||||||
|
Scenario Outline: Single read negotiates the body format
|
||||||
|
When an anonymous client sends GET /v1/article/1<query>
|
||||||
|
Then the response status is 200
|
||||||
|
And "body" is <body>
|
||||||
|
And "body_format" is "<body_format>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| query | body | body_format |
|
||||||
|
| | "## Plan\n\nReplace the **old** deck." | markdown |
|
||||||
|
| ?format= | "## Plan\n\nReplace the **old** deck." | markdown |
|
||||||
|
| ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown |
|
||||||
|
| ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown |
|
||||||
|
| ?format=html | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
|
||||||
|
| ?format=HTML | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
|
||||||
|
| ?format=json | [{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}] | json |
|
||||||
|
| ?format=JSON | the same array as ?format=json | json |
|
||||||
|
|
||||||
|
Scenario: List read negotiates every element
|
||||||
|
Given the repository also holds online article 3 with body "<p>Second</p>"
|
||||||
|
When an anonymous client sends GET /v1/article/
|
||||||
|
Then every element has body_format "markdown" and article 3 has body "Second"
|
||||||
|
When an anonymous client sends GET /v1/article/?format=html
|
||||||
|
Then every element has body_format "html" and its stored HTML as body
|
||||||
|
When an anonymous client sends GET /v1/article/?format=json
|
||||||
|
Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]
|
||||||
|
|
||||||
|
Scenario Outline: An unsupported format is rejected
|
||||||
|
When an anonymous client sends GET <path>
|
||||||
|
Then the response status is 400
|
||||||
|
And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline
|
||||||
|
And no use case reads or renders an article
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| path |
|
||||||
|
| /v1/article/1?format=pdf |
|
||||||
|
| /v1/article/?format=xml |
|
||||||
|
| /v1/article/9999?format=pdf |
|
||||||
|
| /v1/article/1?format=md |
|
||||||
|
|
||||||
|
Scenario: An invalid id is reported before an invalid format
|
||||||
|
When an anonymous client sends GET /v1/article/abc?format=pdf
|
||||||
|
Then the response status is 400
|
||||||
|
And the response body is apperr.IdUint32ErrorMessage followed by a newline
|
||||||
|
|
||||||
|
Scenario: The first repeated format parameter wins
|
||||||
|
When an anonymous client sends GET /v1/article/1?format=html&format=json
|
||||||
|
Then "body_format" is "html"
|
||||||
|
|
||||||
|
Scenario: Offline articles stay hidden whatever the format
|
||||||
|
Given article 2 is offline
|
||||||
|
When an anonymous client sends GET /v1/article/2?format=json
|
||||||
|
Then the response status is 404 and the body is "Article not found"
|
||||||
|
|
||||||
|
Scenario Outline: Authenticated routes negotiate the response body
|
||||||
|
Given a valid jwt cookie for a verified user with id 1
|
||||||
|
When the client sends <method> <path> with <request_body>
|
||||||
|
Then the response status is <status>
|
||||||
|
And "body_format" is "<body_format>"
|
||||||
|
And the stored body is the HTML that was sent, never markdown or json
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| method | path | request_body | status | body_format |
|
||||||
|
| POST | /v1/article/ | {"title":"Deck","body":"<p>A</p>"} | 201 | markdown |
|
||||||
|
| POST | /v1/article/?format=html | {"title":"Deck","body":"<p>A</p>"} | 201 | html |
|
||||||
|
| POST | /v1/article/?format=json | {"title":"Deck","body":"<p>A</p>"} | 201 | json |
|
||||||
|
| PATCH | /v1/article/1 | {"body":"<p>B</p>"} | 200 | markdown |
|
||||||
|
| PATCH | /v1/article/1?format=json | {"body":"<p>B</p>"} | 200 | json |
|
||||||
|
| PATCH | /v1/article/1/publish | | 200 | markdown |
|
||||||
|
| PATCH | /v1/article/1/unpublish?format=html | | 200 | html |
|
||||||
|
|
||||||
|
Scenario: An unsupported format on a write route changes nothing
|
||||||
|
Given a valid jwt cookie for a verified user with id 1
|
||||||
|
When the client sends PATCH /v1/article/1?format=pdf with {"body":"<p>B</p>"}
|
||||||
|
Then the response status is 400
|
||||||
|
And the stored body of article 1 is unchanged
|
||||||
|
When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"<p>A</p>"}
|
||||||
|
Then the response status is 400 and no article is created
|
||||||
|
|
||||||
|
Scenario: The authenticated list includes offline articles in the negotiated format
|
||||||
|
Given a valid jwt cookie for a verified user and offline article 2
|
||||||
|
When the client sends GET /v1/article/?format=json
|
||||||
|
Then the array contains articles 1 and 2 with body_format "json"
|
||||||
|
|
||||||
|
Scenario: A converter failure answers 500
|
||||||
|
Given the BodyRenderer fails on its next call
|
||||||
|
When an anonymous client sends GET /v1/article/1
|
||||||
|
Then the response status is 500 and the body is "Internal server error"
|
||||||
|
|
||||||
|
Scenario Outline: Markdown fidelity for Tiptap output
|
||||||
|
Given the stored body is <html>
|
||||||
|
When converted with the format "markdown"
|
||||||
|
Then the body is <markdown>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| html | markdown |
|
||||||
|
| <h1>T</h1><h3>S</h3> | # T\n\n### S |
|
||||||
|
| <p><strong>b</strong> <em>i</em></p> | **b** *i* |
|
||||||
|
| <p><a href="https://example.com/x">link</a></p> | [link](https://example.com/x) |
|
||||||
|
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | - a\n- b |
|
||||||
|
| <ol><li><p>a</p></li><li><p>b</p></li></ol> | 1. a\n2. b |
|
||||||
|
| <ul><li data-indent="2"><p>a</p></li></ul> | - a |
|
||||||
|
| <blockquote><p>q</p></blockquote> | > q |
|
||||||
|
| <pre><code class="language-go">x := 1</code></pre> | ```go\nx := 1\n``` |
|
||||||
|
| <hr> | --- |
|
||||||
|
| <img src="/static/uploadedImages/a.png" alt="A"> |  |
|
||||||
|
| <p>ok</p><script>alert(1)</script><style>p{}</style> | ok |
|
||||||
|
| <p>x<span style="color:red">y</span></p> | xy |
|
||||||
|
| <p>use <code>go test</code></p> | use `go test` |
|
||||||
|
|
||||||
|
Scenario Outline: Json block shapes, one row per block and mark type
|
||||||
|
Given the stored body is <html>
|
||||||
|
When converted with the format "json"
|
||||||
|
Then the body is <json>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| html | json |
|
||||||
|
| <h1>T</h1> | [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
|
||||||
|
| <h6>S</h6> | [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
|
||||||
|
| <p>plain</p> | [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
|
||||||
|
| <p></p> | [{"type":"paragraph","children":[]}] |
|
||||||
|
| <p><strong>b</strong></p> | [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
|
||||||
|
| <p><em>i</em></p> | [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
|
||||||
|
| <p><a href="https://example.com/x">l</a></p> | [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
|
||||||
|
| <p><a href="/a"><strong><em>x</em></strong></a></p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
|
||||||
|
| <p>a<br>b</p> | [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
|
||||||
|
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
|
||||||
|
| <ol><li><p>a</p></li></ol> | [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
|
||||||
|
| <ul><li>bare</li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
|
||||||
|
| <ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul> | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
|
||||||
|
| <ul><li data-indent="2"><p>a</p></li></ul> | the same output as without data-indent |
|
||||||
|
| <blockquote><p>q</p></blockquote> | [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
|
||||||
|
| <pre><code class="language-go">x := 1</code></pre> | [{"type":"code","language":"go","text":"x := 1"}] |
|
||||||
|
| <pre><code>raw</code></pre> | [{"type":"code","language":"","text":"raw"}] |
|
||||||
|
| <hr> | [{"type":"hr"}] |
|
||||||
|
| <img src="/static/uploadedImages/a.png" alt="A"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
|
||||||
|
| <img src="/static/uploadedImages/a.png"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
|
||||||
|
| <p>x<img src="/i.png" alt="I">y</p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
|
||||||
|
| <p>x<span style="color:red">y</span></p> | [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
|
||||||
|
| <p>use <code>go test</code></p> | [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
|
||||||
|
| <p>ok</p><script>alert(1)</script><style>p{}</style> | [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
|
||||||
|
|
||||||
|
Scenario Outline: Text alignment survives the conversion
|
||||||
|
Given the stored body is <html>
|
||||||
|
When converted with the format "markdown"
|
||||||
|
Then the body is <markdown>
|
||||||
|
When converted with the format "json"
|
||||||
|
Then the body is <json>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| html | markdown | json |
|
||||||
|
| <p style="text-align: center">a</p> | <div style="text-align: center">
|
||||||
|
|
||||||
|
a
|
||||||
|
|
||||||
|
</div> | [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||||
|
| <h2 style="text-align:right">T</h2> | <div style="text-align: right">
|
||||||
|
|
||||||
|
## T
|
||||||
|
|
||||||
|
</div> | [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
|
||||||
|
| <p style="text-align: JUSTIFY">a</p> | <div style="text-align: justify">
|
||||||
|
|
||||||
|
a
|
||||||
|
|
||||||
|
</div> | [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||||
|
| <p style="text-align: left">a</p> | a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||||
|
| <p style="text-align: start">a</p> | a | the same output as the left row |
|
||||||
|
| <p style="text-align: center"></p> | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
|
||||||
|
|
||||||
|
Scenario Outline: Plain text that looks like markdown stays plain text
|
||||||
|
Given the stored body is <html>
|
||||||
|
When converted with the format "markdown"
|
||||||
|
Then the body is <markdown>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| html | markdown |
|
||||||
|
| <p># not a heading</p> | \# not a heading |
|
||||||
|
| <p>#tag</p> | #tag |
|
||||||
|
| <p>- not a list</p> | \- not a list |
|
||||||
|
| <p>+ not a list</p> | \+ not a list |
|
||||||
|
| <p>> not a quote</p> | \> not a quote |
|
||||||
|
| <p>1. not a list</p> | 1\. not a list |
|
||||||
|
| <p>2) not a list</p> | 2\) not a list |
|
||||||
|
| <p>---</p> | \--- |
|
||||||
|
| <p>a - b</p> | a - b |
|
||||||
|
| <p>a * b _c_</p> | a \* b \_c\_ |
|
||||||
|
| <p>a<br>- b</p> | a\n\- b |
|
||||||
|
|
||||||
|
Scenario: The editor lets the author align text
|
||||||
|
Given the article editor registers the TextAlign extension for paragraphs and headings
|
||||||
|
Then the left, center, right and justify toolbar buttons are enabled
|
||||||
|
And clicking center writes style="text-align: center;" on the current paragraph or heading
|
||||||
|
And paragraphs and headings that were never aligned carry no style attribute
|
||||||
|
|
||||||
|
Scenario: The SPA asks for html explicitly
|
||||||
|
Given no visual change is expected, so docs/design is not consulted
|
||||||
|
When EditorView loads an article, or ArticleView or HomeView load the list
|
||||||
|
Then each request is /v1/article/{id}?format=html or /v1/article/?format=html
|
||||||
|
And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save
|
||||||
|
And the editor and the image counts receive HTML exactly as before
|
||||||
|
|
||||||
|
Scenario: The documentation reflects the contract
|
||||||
|
Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change
|
||||||
|
And web/src/views/IntegrationView.vue snippets show ?format
|
||||||
|
And docs/releases has a note stating the default flip and that the SPA sends format=html
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# QA procedure: article_body_format
|
||||||
|
|
||||||
|
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. Call `validator.InitValidator()` once.
|
||||||
|
2. Reuse `mockArticleRepo` and `mockUserRepo`. Add a fake `fakeBodyRenderer` with a unique type name. It records each call's format and the html it received. For markdown it returns "MD:"+html. For html it returns the html unchanged. For json it returns `[{"type":"paragraph","children":[{"type":"text","text":"FAKE","marks":[]}]}]`. It has a failure switch.
|
||||||
|
3. Wire the real use cases over the fakes, passing the renderer. Update every existing test that builds these use cases for the new constructor signatures. Build `article.NewHandler(...)`.
|
||||||
|
4. Mount a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article`: `jwtauth.Verifier` on the group, GET `/` and `/{id}` public, and `Authenticator` + `RequireCurrentToken` + `RequireVerifiedUser` for POST, DELETE and PATCH.
|
||||||
|
5. Mint a jwt cookie for user_id 1 with `token.Encode(map[string]any{"user_id": uint32(1)})`. Seed a verified user. Seed articles 1 (online) and 2 (offline).
|
||||||
|
|
||||||
|
## HTTP checks (fake renderer)
|
||||||
|
6. Single read matrix: for each of none, `?format=`, markdown, MarkDown, html, HTML, json, JSON, assert status 200, `body_format`, the renderer's recorded format and the stored HTML passed in. For json, decode `body` and assert it is a JSON array (not a string).
|
||||||
|
7. List with none, `?format=html` and `?format=json`: every element has the matching `body_format`.
|
||||||
|
8. `format=pdf`, `xml` and `md` on single, list and unknown-id paths return 400 with `apperr.ArticleBodyFormatErrorMessage + "\n"`. The renderer and the repo are not called.
|
||||||
|
9. `GET /v1/article/abc?format=pdf` returns 400 with `apperr.IdUint32ErrorMessage + "\n"`.
|
||||||
|
10. `?format=html&format=json` returns html.
|
||||||
|
11. `GET /v1/article/2?format=json` anonymous returns 404 `"Article not found\n"`.
|
||||||
|
12. Write-route matrix with a cookie (POST, PATCH, publish, unpublish, each with none, html and json where listed): assert status, `body_format`, and that the repo holds the HTML that was sent.
|
||||||
|
13. `PATCH /v1/article/1?format=pdf` and `POST /v1/article/?format=pdf` with a cookie return 400. The repo is unchanged and no article is created.
|
||||||
|
14. Authenticated `GET /v1/article/?format=json` includes articles 1 and 2.
|
||||||
|
15. Renderer failure switch, then `GET /v1/article/1` returns 500 `"Internal server error\n"`.
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
16. `ParseBodyFormat`: "", markdown, MARKDOWN, html, Html, json, JSON are accepted (empty returns markdown). pdf, md and " html" return `errors.Is(err, ErrUnsupportedBodyFormat)`.
|
||||||
|
|
||||||
|
## Converter tests (real library, real Tiptap HTML samples)
|
||||||
|
17. `test/markdown_converter_test.go`: every row of the markdown fidelity table, plus a combined document (heading, paragraph with marks, list, image, code block, quote, hr). Assert exact output. For format html, assert the input comes back byte-identical.
|
||||||
|
17b. Alignment and escaping: every row of the alignment and plain-text tables, for markdown and json, in `test/markdown_converter_test.go` and `test/json_blocks_converter_test.go`. `test/body_converter_documents_test.go` covers the combined hostile document (no raw script, style, iframe or span leaks in markdown or json), its block types, and code block text extraction.
|
||||||
|
18. `test/json_blocks_converter_test.go`: every row of the json shapes table. Assert by unmarshalling both sides and comparing with deep equality. This also asserts `marks` is `[]` rather than null or absent, `ordered` is a bool, and code `language` is "" when absent. Also include a combined document with nested list, blockquote and an image inside a paragraph. Assert that script and style content never appears, and that no raw HTML survives in any output for any format except html.
|
||||||
|
19. `test/json_blocks_converter_test.go` also asserts that html "" produces `[]`, and that the output is a non-null JSON array.
|
||||||
|
|
||||||
|
## SPA (Vitest, `task test:web`)
|
||||||
|
20. Mock `/v1/article/12?format=html` and assert `EditorView` requests that URL and loads the HTML body into the draft.
|
||||||
|
21. Assert `useArticles.listArticles` requests `/v1/article/?format=html`.
|
||||||
|
22. Assert the `ArticleView` image count is computed from the HTML.
|
||||||
|
23. Assert `useArticleDraft.save` sends PATCH `/v1/article/{id}?format=html` with the JSON body.
|
||||||
|
24. Reported as NOT RUN if there is no runner. No visual check applies, because no visual change is expected.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
25. Check that a docs/api article read page, the IntegrationView snippets and a docs/releases note exist as described.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
- In the embedded SPA, edit and save an article against a real SQLite file and confirm the stored body is still HTML.
|
||||||
|
- `curl` the public list and a single article with no format, `?format=html` and `?format=json` against a built binary.
|
||||||
|
- No SMTP involvement.
|
||||||
+181
-51
@@ -1,63 +1,193 @@
|
|||||||
Feature: Integration screen
|
Feature: Integration screen (API reference)
|
||||||
|
|
||||||
As a verified RenewCMS owner
|
As a developer building a frontend or an API-key client for a RenewCMS site
|
||||||
I want /integration to show my site's base API URL and ready-to-use endpoint examples
|
I want /integration to be a complete, searchable API reference
|
||||||
So that I, or the developer who builds my public site, can wire it up without reading the
|
So that I can call every route a key may call without reading the source
|
||||||
source
|
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
|
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
|
||||||
inside DashboardShell, per docs/design/routes.md
|
inside DashboardShell, per docs/design/routes.md
|
||||||
And the screen makes no /v1 calls; api: none (static)
|
And the screen makes no /v1 calls; api: none (static)
|
||||||
And the PageHeader shows title "Connect your website", description "A guide for the developer
|
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
|
||||||
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
|
"Everything a developer needs to build on RenewCMS."
|
||||||
actions
|
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
|
||||||
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
|
And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order:
|
||||||
articles but doesn't show them to visitors. Your website fetches published articles from the
|
| group | routes | endpoints |
|
||||||
two addresses below and displays them in its own design."
|
| 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} |
|
||||||
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
|
| 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 |
|
||||||
(icon copy) beside it
|
| 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 |
|
||||||
And two EndpointCard entries follow, in this order:
|
| 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 |
|
| method | path | description |
|
||||||
| GET | /v1/article/ | Lists every online article. |
|
| GET | /v1/ | Hello world. |
|
||||||
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
|
| GET | /v1/auth/registration | Whether registration is open. |
|
||||||
And each EndpointCard's fetch example uses the current origin as the base of its URL:
|
| POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. |
|
||||||
| path | fetch example |
|
| GET | /v1/auth/invite | Check an invite code. |
|
||||||
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
|
| POST | /v1/auth/invite/accept | Accept an invite. |
|
||||||
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
|
| GET | /healthz | Plain text "ok". Outside /v1. |
|
||||||
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
|
And every catalogue endpoint has a non-empty description, method, path, access chip, key chip,
|
||||||
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
|
query-parameter table (or "No query parameters."), request-body table (or "No request body."),
|
||||||
And the curl tab's content is a plain curl command built from the current origin (e.g.
|
fetch, curl and Response examples, and error captions
|
||||||
"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."
|
|
||||||
|
|
||||||
Scenario: Default — the page loads with the app's own origin
|
Scenario: Default, every card collapsed
|
||||||
Given the app is served from origin "{origin}"
|
|
||||||
When the user navigates to /integration
|
When the user navigates to /integration
|
||||||
Then the Base URL row shows exactly "{origin}"
|
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 the Base URL "Copy" button reads "Copy", not "Copied"
|
And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy)
|
||||||
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
|
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
|
Scenario Outline: Method badge and chips per endpoint
|
||||||
Given the user is on /integration, served from origin "{origin}"
|
Then <method> <path> shows the <method> badge, access chip "<access>" and key chip "API key OK"
|
||||||
When the user activates the Base URL row's "Copy" button
|
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
|
Then "{origin}" is written to the clipboard
|
||||||
And the button's icon changes to check and its text changes to "Copied"
|
And the button shows icon check and "Copied", announced to assistive technology
|
||||||
And the change is announced to assistive technology
|
When 2 seconds elapse
|
||||||
When 2 seconds elapse without a further click
|
Then the button reverts to icon copy and "Copy"
|
||||||
Then the button reverts to icon copy and text "Copy"
|
|
||||||
|
|
||||||
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
|
Scenario: Copied, CodeBlock is independent of Base URL
|
||||||
Given the user is on /integration
|
Given a card is expanded
|
||||||
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
|
When the user activates that CodeBlock's "Copy JavaScript example" button
|
||||||
button
|
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
|
||||||
Then that card's own code is written to the clipboard, and only that button reads "Copied"
|
And the Base URL button and other CodeBlocks are unaffected
|
||||||
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button 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
@@ -1,66 +1,98 @@
|
|||||||
# QA: Integration screen
|
# QA: Integration screen (API reference)
|
||||||
|
|
||||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
|
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
|
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
|
||||||
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
|
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
|
||||||
`/v1` mocks are needed; the screen makes no calls.
|
is made.
|
||||||
|
|
||||||
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
|
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
||||||
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
|
1. The catalogue holds exactly the 20 expected key-callable routes from an explicit hand-maintained
|
||||||
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
|
list in the spec (Articles 7, Media 6, Users and invites 6, Settings 1), each once, with no other
|
||||||
"Connect your website" and description "A guide for the developer who builds your public
|
expandable route. Session only has the 13 rows and Public auth flow the 6 rows listed in the
|
||||||
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
|
feature, from the same kind of explicit list.
|
||||||
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
|
2. Every catalogue route has a non-empty `description`. An empty or missing one fails the spec and
|
||||||
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
|
names the route.
|
||||||
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
|
3. Every route has method, path, access chip, key chip, a query-parameter list and a body-field list
|
||||||
the three info notes render in order.
|
(possibly empty), and fetch, curl and Response examples.
|
||||||
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
|
4. The Response sample of each route parses as JSON and uses only keys from a fixture of the real
|
||||||
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
|
fields:
|
||||||
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
|
- article: id, title, body, body_format, is_online, created_at, updated_at;
|
||||||
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
|
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
|
||||||
button reverts to icon copy and text "Copy".
|
created_at, used_in, duplicate;
|
||||||
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
|
- media list: items, next_cursor, counts (all, unused, duplicates);
|
||||||
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
|
- merge: articles_updated, merged;
|
||||||
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
|
- usage: id, title, published;
|
||||||
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
|
- user list item: id, kind, username, display_name, email, role, status, created_at;
|
||||||
Vitest-only; it has no visual-check state of its own (see Visual below).
|
- invite: id, kind, email, status, created_at, expires_at;
|
||||||
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
|
- settings: version.
|
||||||
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
|
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
|
||||||
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
|
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
|
||||||
content is a JSON sample built from the real domain Article fields. Neither tab's content is
|
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
|
||||||
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
|
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)
|
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
|
||||||
|
|
||||||
Reference: `docs/design/screens/integration.md` and
|
Reference: docs/design/screens/integration.md; board "Integration" drawn by
|
||||||
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
|
docs/design/source/IntegrationPage.dc.html. Serve the SPA under `https://cms.renew.example`. Mocks
|
||||||
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
|
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
|
Pre-approved exceptions: the user pre-approved visual exceptions for Session only, Group scrolled
|
||||||
Playwright request routing, so the real `window.location.origin` the running app reads matches the
|
one card expanded, Errors and Default. For each of these four states the coder measures the real
|
||||||
board's sample text exactly; no production test hook or origin stub is needed in app code.
|
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
|
- [ ] Default (exception: owner-only chips and rate-limit line)
|
||||||
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
|
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
|
||||||
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
|
- [ ] Copied: base URL (must pass 10 %)
|
||||||
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
|
- [ ] Copied: CodeBlock (must pass 10 %)
|
||||||
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
|
- [ ] Authentication (must pass 10 %; Note 1 text changed)
|
||||||
`https://cms.renew.example`, served as above.
|
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
|
||||||
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
|
- [ ] Errors (exception: 409 row)
|
||||||
color, matching the board's `copied: true` frame. This state is timer-driven, not
|
- [ ] Navigation (must pass 10 %)
|
||||||
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).
|
|
||||||
|
|
||||||
## 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
|
## Manual
|
||||||
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
|
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
|
||||||
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
|
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
|
||||||
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
|
- 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).
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
Feature: Language switcher conforms to the official design
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the design in docs/design is the visual spec of record for LanguageSwitcher and UserMenu
|
||||||
|
And the interface supports exactly two languages, English and Français
|
||||||
|
And each language is always written in its own name, whatever the interface language
|
||||||
|
And the language rules of features/spa_internationalization.feature still apply
|
||||||
|
And the language control no longer uses a form select anywhere
|
||||||
|
And no language preference is sent to the server
|
||||||
|
|
||||||
|
Scenario Outline: Account menu lists Profile, Theme, Language and Log out in design order
|
||||||
|
Given a signed-in user opens the account menu in the <surface>
|
||||||
|
Then the entries appear in this order
|
||||||
|
| Profile |
|
||||||
|
| separator |
|
||||||
|
| "Theme" section label |
|
||||||
|
| System, Light, Dark |
|
||||||
|
| separator |
|
||||||
|
| "Language" section label |
|
||||||
|
| English, Français |
|
||||||
|
| separator |
|
||||||
|
| Log out |
|
||||||
|
And the "Theme" and "Language" labels are 11 px, weight 500, uppercase, letter-spacing 0.04em and --ui-text-muted
|
||||||
|
And the label padding is <label_padding>
|
||||||
|
And the menu is <size>
|
||||||
|
And every item row is <row_height> high
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| surface | label_padding | size | row_height |
|
||||||
|
| desktop UserMenu popover | 6px 8px 2px | a 224 px popover above the chip | 32 px |
|
||||||
|
| mobile AccountMenuSheet | 8px 16px 2px | a bottom sheet with grab handle | 48 px |
|
||||||
|
|
||||||
|
Scenario Outline: Language rows are a radio group naming each language in its own name
|
||||||
|
Given the interface language is <locale>
|
||||||
|
When the account menu is open in the <surface>
|
||||||
|
Then the "Language" label names a group of two menuitemradio rows
|
||||||
|
And the rows read "English" with lang "en" and "Français" with lang "fr"
|
||||||
|
And the row for <locale_name> has aria-checked "true" and a check icon at the right edge
|
||||||
|
And the other row has aria-checked "false" and no check icon
|
||||||
|
And no Nuxt UI select, combobox or languages icon row is present in the menu
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| surface | locale | locale_name |
|
||||||
|
| desktop UserMenu popover | en | English |
|
||||||
|
| desktop UserMenu popover | fr | Français |
|
||||||
|
| mobile AccountMenuSheet | en | English |
|
||||||
|
| mobile AccountMenuSheet | fr | Français |
|
||||||
|
|
||||||
|
Scenario Outline: Theme rows are radio items that keep the menu open
|
||||||
|
Given the theme preference is <preference>
|
||||||
|
When the account menu is open in the <surface>
|
||||||
|
Then System, Light and Dark are menuitemradio rows in a group named "Theme"
|
||||||
|
And only the <preference> row has aria-checked "true" and a check icon
|
||||||
|
When the user chooses another theme row
|
||||||
|
Then the theme applies at once and the menu stays open
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| surface | preference |
|
||||||
|
| desktop UserMenu popover | light |
|
||||||
|
| mobile AccountMenuSheet | dark |
|
||||||
|
|
||||||
|
Scenario Outline: Choosing a language in the account menu applies it at once and keeps the menu open
|
||||||
|
Given the interface language is en and the user is on /profile with an unsaved display name "Alice B."
|
||||||
|
And the account menu is open in the <surface>
|
||||||
|
When the user chooses "Français"
|
||||||
|
Then all interface text, including the menu labels "Thème" and "Langue", is French
|
||||||
|
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
|
||||||
|
And the Français row is checked and the menu stays open
|
||||||
|
And the route, session, theme and unsaved display name are unchanged
|
||||||
|
And no reload, navigation or /v1 request occurs
|
||||||
|
When the user chooses "English"
|
||||||
|
Then the interface is English, the document lang is "en" and the menu stays open
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| surface |
|
||||||
|
| desktop UserMenu popover |
|
||||||
|
| mobile AccountMenuSheet |
|
||||||
|
|
||||||
|
Scenario Outline: Account menu keyboard and dismissal
|
||||||
|
Given the account menu is open in the <surface>
|
||||||
|
When the user moves with ArrowDown and ArrowUp
|
||||||
|
Then focus visits Profile, the theme rows, the language rows and Log out in visual order and wraps at the ends
|
||||||
|
When the user presses Enter or Space on a language row
|
||||||
|
Then that language is applied and the menu stays open
|
||||||
|
When the user presses Escape or clicks outside
|
||||||
|
Then the menu closes without changing the language
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| surface |
|
||||||
|
| desktop UserMenu popover |
|
||||||
|
| mobile AccountMenuSheet |
|
||||||
|
|
||||||
|
Scenario Outline: Auth footer shows the language chip beside the theme chip
|
||||||
|
Given the user is signed out on <route>
|
||||||
|
And the interface language is en
|
||||||
|
Then the footer outside the card contains the language chip and the System theme chip aligned right with an 8 px gap
|
||||||
|
And any existing footer slot content stays to the left of the chips
|
||||||
|
And the chip shows the lucide languages icon at 14 px followed by "English"
|
||||||
|
And the chip has a 1 px --ui-border-accented border, --ui-text-toned text, aria-haspopup "menu", aria-expanded "false" and aria-label "Language: English"
|
||||||
|
And no language select is rendered in the card or the footer
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route |
|
||||||
|
| /login |
|
||||||
|
| /register |
|
||||||
|
| /register/pending |
|
||||||
|
| /register/validate |
|
||||||
|
| /register/reset/request |
|
||||||
|
| /register/reset/validate |
|
||||||
|
| /invite/accept |
|
||||||
|
|
||||||
|
Scenario Outline: Auth footer is always rendered, on mobile too
|
||||||
|
Given the viewport is <viewport>
|
||||||
|
When an auth screen with no footer slot content renders
|
||||||
|
Then the footer with the language chip and the theme chip is visible
|
||||||
|
And the chip is <chip_height> high
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| viewport | chip_height |
|
||||||
|
| 1440 x 900 | 28 px |
|
||||||
|
| 390 x 844 | 44 px |
|
||||||
|
|
||||||
|
Scenario: Chip reflects the current language
|
||||||
|
Given the stored language is fr
|
||||||
|
When /login renders
|
||||||
|
Then the chip reads "Français" and its aria-label is "Langue : Français"
|
||||||
|
|
||||||
|
Scenario Outline: Auth language popover
|
||||||
|
Given the user is signed out on /login in <locale_name>
|
||||||
|
When the user activates the language chip
|
||||||
|
Then the chip has aria-expanded "true" and --ui-bg-elevated
|
||||||
|
And a popover with role "menu" named "Language" opens above the chip, right-aligned and 36 px above its bottom edge, 168 px wide, --ui-bg, 1 px --ui-border, --rc-shadow-pop and 4 px padding
|
||||||
|
And it holds menuitemradio rows "English" (lang "en") and "Français" (lang "fr") that are 32 px high on desktop and 44 px on mobile
|
||||||
|
And the current language row has aria-checked "true" and a 16 px check icon at the right edge
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| locale_name |
|
||||||
|
| English |
|
||||||
|
| Français |
|
||||||
|
|
||||||
|
Scenario: Choosing a language in the auth popover closes it
|
||||||
|
Given the auth language popover is open on /login with English current
|
||||||
|
When the user chooses "Français"
|
||||||
|
Then the whole screen is French at once, including the card, the footer and the document title
|
||||||
|
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
|
||||||
|
And the popover closes and focus returns to the language chip
|
||||||
|
And the chip reads "Français"
|
||||||
|
And the form values already typed, the route and the theme are unchanged
|
||||||
|
And no /v1 request occurs
|
||||||
|
|
||||||
|
Scenario: Dismiss the auth popover without changing language
|
||||||
|
Given the auth language popover is open
|
||||||
|
When the user presses Escape or clicks outside it
|
||||||
|
Then it closes, aria-expanded is "false" and the language is unchanged
|
||||||
|
And focus returns to the chip on Escape
|
||||||
|
|
||||||
|
Scenario: Auth popover keyboard
|
||||||
|
Given the language chip has focus
|
||||||
|
When the user presses Enter, Space or ArrowDown
|
||||||
|
Then the popover opens with focus on the current language row
|
||||||
|
And ArrowUp and ArrowDown move between the two rows and Enter or Space chooses one
|
||||||
|
|
||||||
|
Scenario: Language persists across public and signed-in screens
|
||||||
|
Given the user chose Français on /login
|
||||||
|
When they sign in, open the account menu and log out
|
||||||
|
Then Français is checked in the account menu and the /login chip reads "Français" again
|
||||||
|
And a storage failure still lets the choice apply for the current session, as in spa_internationalization
|
||||||
|
|
||||||
|
Scenario: Catalog parity for the new strings
|
||||||
|
Then the en and fr catalogs contain "Theme" and "Language: {0}"
|
||||||
|
And "Language: {0}" keeps its {0} parameter in French
|
||||||
|
And the strings "English" and "Français" are not translated
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Language switcher conformance QA
|
||||||
|
|
||||||
|
Accepted by the user on 2026-10-04 together with the slice specification. Decisions folded in: spa_internationalization picker scenarios are amended; theme and language rows are menuitemradio and the account menu and sheet stay open after a choice; the auth popover is specified from docs/design/components.md because the boards draw only the chip.
|
||||||
|
|
||||||
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `/v1` per case and assert that a language change makes no `/v1` request. Reset the stored `renewcms-language` between tests. Do not freeze the system date.
|
||||||
|
|
||||||
|
1. UserMenu (desktop) and AccountMenuSheet (mobile): open the menu and assert the entry order Profile, separator, "Theme" label, System/Light/Dark, separator, "Language" label, English/Français, separator, Log out. Assert the label classes. LanguagePicker and any combobox must be absent.
|
||||||
|
2. Roles and ARIA: theme rows and language rows are menuitemradio with aria-checked on the current one and a check icon. Language rows carry lang "en"/"fr" and native names. With locale fr the names stay "English"/"Français" while "Theme"/"Language" become "Thème"/"Langue".
|
||||||
|
3. Account menu selection: choosing Français in each surface switches the visible copy, sets document lang and storage, keeps the menu open, and leaves route, theme and an unsaved display-name draft on /profile unchanged. Choosing English restores it. Choosing a theme keeps the mobile sheet open.
|
||||||
|
4. Account menu keyboard: arrow order and wrap across all entries, Enter/Space applies and keeps open, Escape and outside click close with no language change.
|
||||||
|
5. AuthShell: the footer is rendered with the language chip next to UColorModeSelect when belowSm is true and false, with no footer slot content, and slot content stays to the left. Assert aria-haspopup, aria-expanded, aria-label "Language: English" / "Langue : Français", the icon, the native name and the absence of a select.
|
||||||
|
6. Auth popover: opens via click, Enter, Space and ArrowDown; role menu named "Language", two menuitemradio rows with lang and checked state; selection applies the language, closes it and returns focus to the chip; Escape and outside click dismiss with no change. Typed form values on /login survive the switch.
|
||||||
|
7. Each auth screen (login, register, pending, verify, forgot-password, reset-password, accept-invite) mounts with the chip present.
|
||||||
|
8. Persistence: stored fr gives chip "Français"; the choice survives log out and re-render of /login; a localStorage write failure still switches for the session.
|
||||||
|
9. Catalog parity: en/fr keys include "Theme" and "Language: {0}" with matching parameters.
|
||||||
|
10. Update web/src/i18n/spa.spec.ts, web/src/i18n/i18n.spec.ts and any AuthShell, UserMenu or AccountMenuSheet spec asserting menuitemcheckbox or the old picker. Remove LanguagePicker and its tests if the component is removed.
|
||||||
|
11. Run task test:web and the type check; inspect the diff for introduced comments.
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- <screen>`, hard gate at 10 %)
|
||||||
|
|
||||||
|
Desktop and mobile, light and dark, reusing the boards' sample data.
|
||||||
|
|
||||||
|
- profile, state "Account menu": board `deskMenu` and `mobMenu` with the Theme and Language labels, Light and English checked.
|
||||||
|
- Every auth screen and state (log-in, register, pending, verify, forgot-password, reset-password, accept-invite): all captures change because of the language chip (`hasFooter: true`), including mobile where the footer is new. Clock-sensitive states stay `skip: 'clock-sensitive'`.
|
||||||
|
- The boards draw no open auth language popover, so it has no visual gate. It is covered by the automated and manual checks.
|
||||||
|
|
||||||
|
## Manual (screenshots cannot cover)
|
||||||
|
|
||||||
|
- Keyboard focus order and visible focus ring in the account menu, the chip and the popover; focus return to the chip.
|
||||||
|
- Hover, press and motion on the rows and the chip.
|
||||||
|
- Popover placement above the chip and containment at 390 px width, including the 36 px offset next to the 44 px mobile chip. Touch targets at 44 px on mobile.
|
||||||
|
- French readability of "Thème", "Langue" and "Langue : Français" in the labels, chip and popover.
|
||||||
|
- Language retained after reload and logout.
|
||||||
@@ -4,8 +4,7 @@ Feature: English and French throughout the SPA
|
|||||||
Given all existing SPA interface text supports English and French
|
Given all existing SPA interface text supports English and French
|
||||||
And English preserves existing copy and French provides equivalent meaning
|
And English preserves existing copy and French provides equivalent meaning
|
||||||
And user content, API data and transactional emails remain unchanged
|
And user content, API data and transactional emails remain unchanged
|
||||||
And visual checks are waived by the user for this slice
|
And the language control follows features/language_switcher_conformance.feature
|
||||||
And the language control uses existing design tokens
|
|
||||||
|
|
||||||
Scenario Outline: Resolve the language before rendering
|
Scenario Outline: Resolve the language before rendering
|
||||||
Given the browser-local stored language is <stored>
|
Given the browser-local stored language is <stored>
|
||||||
@@ -21,12 +20,10 @@ Feature: English and French throughout the SPA
|
|||||||
| unsupported | en |
|
| unsupported | en |
|
||||||
| unreadable | en |
|
| unreadable | en |
|
||||||
|
|
||||||
Scenario Outline: Open the language picker in the profile submenu
|
Scenario Outline: Offer the language choice in the account menu
|
||||||
Given the user opens the <menu>
|
Given the user opens the <menu>
|
||||||
When they activate the localized Language control
|
Then a localized Language section offers English and Français as radio items
|
||||||
Then a compact scrollable picker offers English and Français
|
|
||||||
And the selected language is visibly and accessibly identified
|
And the selected language is visibly and accessibly identified
|
||||||
And the picker uses Nuxt UI and existing design tokens
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
| menu |
|
| menu |
|
||||||
@@ -65,13 +62,12 @@ Feature: English and French throughout the SPA
|
|||||||
And an unavailable French message falls back to English
|
And an unavailable French message falls back to English
|
||||||
And user content, account details, filenames, URLs, API data and emails remain unchanged
|
And user content, account details, filenames, URLs, API data and emails remain unchanged
|
||||||
|
|
||||||
Scenario Outline: Operate the picker accessibly
|
Scenario Outline: Operate the language choice accessibly
|
||||||
Given the picker is open in the <menu>
|
Given the <menu> is open
|
||||||
When the user navigates its options with the keyboard and confirms a choice
|
When the user navigates the language items with the keyboard and confirms a choice
|
||||||
Then the language changes and the picker closes
|
Then the language changes and the menu stays open
|
||||||
And focus returns to its language control
|
When they press Escape or click outside
|
||||||
When they reopen it and press Escape or click outside
|
Then the menu closes without changing language
|
||||||
Then the picker closes without changing language
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
| menu |
|
| menu |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Accepted by the user on 2026-10-03 together with the slice specification.
|
|||||||
## Automated
|
## Automated
|
||||||
|
|
||||||
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
|
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
|
||||||
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized language entry and a compact bounded scrollable picker with English and Français, selected-state semantics, selection, dismissal and focus return. Preserve profile, theme and logout behavior.
|
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized Language section with English and Français radio items, selected-state semantics, selection with the menu staying open, and dismissal. Preserve profile, theme and logout behavior. Details live in features/language_switcher_conformance.qa.md.
|
||||||
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
|
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
|
||||||
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
|
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
|
||||||
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
|
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
|
||||||
@@ -16,6 +16,4 @@ Accepted by the user on 2026-10-03 together with the slice specification.
|
|||||||
|
|
||||||
## Visual and manual
|
## Visual and manual
|
||||||
|
|
||||||
Visual checks are SKIPPED by explicit user waiver for this slice because the designer is unavailable. The user authorized a coherent language control using existing design tokens. Do not edit design sources or claim pixel parity.
|
The language control now has an official design. Its visual and manual checks live in features/language_switcher_conformance.qa.md. For the rest of this slice, confirm French text readability and language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
|
||||||
|
|
||||||
Manual checklist: desktop/mobile and light/dark coherence, viewport containment, constrained scrolling, French text readability, touch and mouse selection, keyboard movement and confirmation, Escape/outside dismissal, focus return and visible focus. Confirm language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
|
|
||||||
@@ -254,7 +254,7 @@ Feature: Updates part 2: the Update button (download, verify, stage)
|
|||||||
Scenario: Settings, owner, Ready to install
|
Scenario: Settings, owner, Ready to install
|
||||||
Given the poll answers state "ready"
|
Given the poll answers state "ready"
|
||||||
Then polling stops
|
Then polling stops
|
||||||
And the block shows icon circle-check (success), "Version 2.3.0-beta is ready to install" /
|
And the block shows icon clock (warning tone) with a "Pending" tag after the title, "Version 2.3.0-beta is ready to install" /
|
||||||
"The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." /
|
"The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." /
|
||||||
"Nothing has changed yet."
|
"Nothing has changed yet."
|
||||||
And the notes button is shown collapsed
|
And the notes button is shown collapsed
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.44.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/image v0.46.0
|
golang.org/x/image v0.46.0
|
||||||
|
golang.org/x/net v0.59.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,14 +78,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -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,32 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnsupportedBodyFormat = errors.New("unsupported body format")
|
||||||
|
|
||||||
|
type BodyFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BodyFormatMarkdown BodyFormat = "markdown"
|
||||||
|
BodyFormatHTML BodyFormat = "html"
|
||||||
|
BodyFormatJSON BodyFormat = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseBodyFormat(value string) (BodyFormat, error) {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "", string(BodyFormatMarkdown):
|
||||||
|
return BodyFormatMarkdown, nil
|
||||||
|
case string(BodyFormatHTML):
|
||||||
|
return BodyFormatHTML, nil
|
||||||
|
case string(BodyFormatJSON):
|
||||||
|
return BodyFormatJSON, nil
|
||||||
|
}
|
||||||
|
return "", ErrUnsupportedBodyFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
type BodyRenderer interface {
|
||||||
|
Render(html string, format BodyFormat) (string, 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,365 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
"golang.org/x/net/html/atom"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
markBold = "bold"
|
||||||
|
markItalic = "italic"
|
||||||
|
markLink = "link"
|
||||||
|
markCode = "code"
|
||||||
|
|
||||||
|
codeLanguagePrefix = "language-"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mark struct {
|
||||||
|
kind string
|
||||||
|
href string
|
||||||
|
}
|
||||||
|
|
||||||
|
type inline struct {
|
||||||
|
text string
|
||||||
|
marks []mark
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindHeading blockKind = iota
|
||||||
|
kindParagraph
|
||||||
|
kindList
|
||||||
|
kindBlockquote
|
||||||
|
kindCode
|
||||||
|
kindRule
|
||||||
|
kindImage
|
||||||
|
)
|
||||||
|
|
||||||
|
type block struct {
|
||||||
|
kind blockKind
|
||||||
|
level int
|
||||||
|
inlines []inline
|
||||||
|
ordered bool
|
||||||
|
items [][]block
|
||||||
|
children []block
|
||||||
|
language string
|
||||||
|
text string
|
||||||
|
src string
|
||||||
|
alt string
|
||||||
|
align string
|
||||||
|
}
|
||||||
|
|
||||||
|
var transparentBlocks = map[string]bool{
|
||||||
|
"div": true, "section": true, "article": true, "header": true, "footer": true,
|
||||||
|
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
|
||||||
|
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
|
||||||
|
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
|
||||||
|
"summary": true, "li": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var skippedElements = map[string]bool{
|
||||||
|
"script": true, "style": true, "template": true, "head": true,
|
||||||
|
"noscript": true, "iframe": true, "object": true, "embed": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
|
||||||
|
|
||||||
|
var allowedLinkSchemes = map[string]bool{
|
||||||
|
"": true, "http": true, "https": true, "mailto": true, "tel": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBlocks(source string) ([]block, error) {
|
||||||
|
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
|
||||||
|
nodes, err := html.ParseFragment(strings.NewReader(source), context)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collector := newCollector(false, paragraphBlock)
|
||||||
|
collector.walkAll(nodes, nil)
|
||||||
|
return collector.finish(false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paragraphBlock(inlines []inline) block {
|
||||||
|
return block{kind: kindParagraph, inlines: inlines}
|
||||||
|
}
|
||||||
|
|
||||||
|
func headingBlock(level int) func([]inline) block {
|
||||||
|
return func(inlines []inline) block {
|
||||||
|
return block{kind: kindHeading, level: level, inlines: inlines}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type collector struct {
|
||||||
|
out []block
|
||||||
|
current []inline
|
||||||
|
keepBlank bool
|
||||||
|
build func([]inline) block
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollector(keepBlank bool, build func([]inline) block) *collector {
|
||||||
|
return &collector{keepBlank: keepBlank, build: build}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
c.walk(node, marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
|
||||||
|
for child := parent.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
c.walk(child, marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) addText(text string, marks []mark) {
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.current = appendInline(c.current, inline{text: text, marks: marks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) flush() {
|
||||||
|
if len(c.current) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.keepBlank || hasVisibleText(c.current) {
|
||||||
|
c.out = append(c.out, c.build(c.current))
|
||||||
|
}
|
||||||
|
c.current = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) finish(emitEmpty bool) []block {
|
||||||
|
hadBlocks := len(c.out) > 0
|
||||||
|
c.flush()
|
||||||
|
if emitEmpty && !hadBlocks && len(c.out) == 0 {
|
||||||
|
c.out = append(c.out, c.build(nil))
|
||||||
|
}
|
||||||
|
return c.out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collector) walk(node *html.Node, marks []mark) {
|
||||||
|
switch node.Type {
|
||||||
|
case html.TextNode:
|
||||||
|
c.addText(node.Data, marks)
|
||||||
|
return
|
||||||
|
case html.ElementNode:
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := node.Data
|
||||||
|
if skippedElements[tag] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tag {
|
||||||
|
case "br":
|
||||||
|
c.addText("\n", marks)
|
||||||
|
case "strong", "b":
|
||||||
|
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
|
||||||
|
case "em", "i":
|
||||||
|
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
|
||||||
|
case "code":
|
||||||
|
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
|
||||||
|
case "a":
|
||||||
|
href := safeHref(attr(node, "href"))
|
||||||
|
if href == "" {
|
||||||
|
c.walkChildren(node, marks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
|
||||||
|
case "img":
|
||||||
|
c.flush()
|
||||||
|
if src := attr(node, "src"); src != "" {
|
||||||
|
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
|
||||||
|
}
|
||||||
|
case "hr":
|
||||||
|
c.flush()
|
||||||
|
c.out = append(c.out, block{kind: kindRule})
|
||||||
|
case "p":
|
||||||
|
c.flush()
|
||||||
|
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
c.flush()
|
||||||
|
level, _ := strconv.Atoi(tag[1:])
|
||||||
|
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
|
||||||
|
case "ul", "ol":
|
||||||
|
c.flush()
|
||||||
|
c.out = append(c.out, parseList(node))
|
||||||
|
case "blockquote":
|
||||||
|
c.flush()
|
||||||
|
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
|
||||||
|
case "pre":
|
||||||
|
c.flush()
|
||||||
|
c.out = append(c.out, parseCodeBlock(node))
|
||||||
|
default:
|
||||||
|
if transparentBlocks[tag] {
|
||||||
|
c.flush()
|
||||||
|
c.walkChildren(node, marks)
|
||||||
|
c.flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.walkChildren(node, marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
|
||||||
|
inner := newCollector(true, build)
|
||||||
|
inner.walkChildren(node, nil)
|
||||||
|
blocks := inner.finish(true)
|
||||||
|
if align := alignFromStyle(attr(node, "style")); align != "" {
|
||||||
|
for index := range blocks {
|
||||||
|
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
|
||||||
|
blocks[index].align = align
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func alignFromStyle(style string) string {
|
||||||
|
for _, declaration := range strings.Split(style, ";") {
|
||||||
|
name, value, found := strings.Cut(declaration, ":")
|
||||||
|
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
|
||||||
|
return align
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContainer(node *html.Node) []block {
|
||||||
|
inner := newCollector(false, paragraphBlock)
|
||||||
|
inner.walkChildren(node, nil)
|
||||||
|
return inner.finish(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseList(node *html.Node) block {
|
||||||
|
list := block{kind: kindList, ordered: node.Data == "ol"}
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
if child.Type == html.ElementNode && child.Data == "li" {
|
||||||
|
list.items = append(list.items, parseContainer(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCodeBlock(node *html.Node) block {
|
||||||
|
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
if child.Type == html.ElementNode && child.Data == "code" {
|
||||||
|
code.language = languageFromClass(attr(child, "class"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
func languageFromClass(class string) string {
|
||||||
|
for _, name := range strings.Fields(class) {
|
||||||
|
if strings.HasPrefix(name, codeLanguagePrefix) {
|
||||||
|
return strings.TrimPrefix(name, codeLanguagePrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func textContent(node *html.Node) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
var visit func(*html.Node)
|
||||||
|
visit = func(n *html.Node) {
|
||||||
|
switch {
|
||||||
|
case n.Type == html.TextNode:
|
||||||
|
builder.WriteString(n.Data)
|
||||||
|
case n.Type == html.ElementNode && skippedElements[n.Data]:
|
||||||
|
return
|
||||||
|
case n.Type == html.ElementNode && n.Data == "br":
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(node)
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func attr(node *html.Node, name string) string {
|
||||||
|
for _, a := range node.Attr {
|
||||||
|
if a.Key == name {
|
||||||
|
return a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeHref(href string) string {
|
||||||
|
trimmed := strings.TrimSpace(href)
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMark(marks []mark, added mark) []mark {
|
||||||
|
for _, existing := range marks {
|
||||||
|
if existing.kind == added.kind {
|
||||||
|
return marks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := make([]mark, 0, len(marks)+1)
|
||||||
|
next = append(next, marks...)
|
||||||
|
return append(next, added)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasVisibleText(inlines []inline) bool {
|
||||||
|
for _, item := range inlines {
|
||||||
|
if strings.TrimSpace(item.text) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameMarks(a, b []mark) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range a {
|
||||||
|
if a[index] != b[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendInline(inlines []inline, next inline) []inline {
|
||||||
|
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
|
||||||
|
inlines[last].text += next.text
|
||||||
|
return inlines
|
||||||
|
}
|
||||||
|
return append(inlines, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutCodeMarks(inlines []inline) []inline {
|
||||||
|
merged := make([]inline, 0, len(inlines))
|
||||||
|
for _, item := range inlines {
|
||||||
|
kept := make([]mark, 0, len(item.marks))
|
||||||
|
for _, m := range item.marks {
|
||||||
|
if m.kind != markCode {
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged = appendInline(merged, inline{text: item.text, marks: kept})
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
domain "RenewCMS/internal/domain/article"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Converter struct{}
|
||||||
|
|
||||||
|
var _ domain.BodyRenderer = &Converter{}
|
||||||
|
|
||||||
|
func NewConverter() *Converter {
|
||||||
|
return &Converter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) {
|
||||||
|
switch format {
|
||||||
|
case domain.BodyFormatHTML:
|
||||||
|
return html, nil
|
||||||
|
case domain.BodyFormatMarkdown:
|
||||||
|
blocks, err := parseBlocks(html)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return renderMarkdown(blocks), nil
|
||||||
|
case domain.BodyFormatJSON:
|
||||||
|
blocks, err := parseBlocks(html)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return renderJSON(blocks)
|
||||||
|
}
|
||||||
|
return "", domain.ErrUnsupportedBodyFormat
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jsonMark struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Href string `json:"href,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonText struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Marks []jsonMark `json:"marks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonHeading struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Align string `json:"align,omitempty"`
|
||||||
|
Children []jsonText `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonParagraph struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Align string `json:"align,omitempty"`
|
||||||
|
Children []jsonText `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonList struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Ordered bool `json:"ordered"`
|
||||||
|
Items [][]any `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonBlockquote struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Children []any `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonCode struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonRule struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonImage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Src string `json:"src"`
|
||||||
|
Alt string `json:"alt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderJSON(blocks []block) (string, error) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
encoder := json.NewEncoder(&buffer)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
if err := encoder.Encode(toJSONBlocks(blocks)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(buffer.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJSONBlocks(blocks []block) []any {
|
||||||
|
result := make([]any, 0, len(blocks))
|
||||||
|
for _, b := range blocks {
|
||||||
|
result = append(result, toJSONBlock(b))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJSONBlock(b block) any {
|
||||||
|
switch b.kind {
|
||||||
|
case kindHeading:
|
||||||
|
return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)}
|
||||||
|
case kindParagraph:
|
||||||
|
return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)}
|
||||||
|
case kindList:
|
||||||
|
items := make([][]any, 0, len(b.items))
|
||||||
|
for _, item := range b.items {
|
||||||
|
items = append(items, toJSONBlocks(item))
|
||||||
|
}
|
||||||
|
return jsonList{Type: "list", Ordered: b.ordered, Items: items}
|
||||||
|
case kindBlockquote:
|
||||||
|
return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)}
|
||||||
|
case kindCode:
|
||||||
|
return jsonCode{Type: "code", Language: b.language, Text: b.text}
|
||||||
|
case kindImage:
|
||||||
|
return jsonImage{Type: "image", Src: b.src, Alt: b.alt}
|
||||||
|
}
|
||||||
|
return jsonRule{Type: "hr"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJSONTexts(inlines []inline) []jsonText {
|
||||||
|
plain := withoutCodeMarks(inlines)
|
||||||
|
texts := make([]jsonText, 0, len(plain))
|
||||||
|
for _, item := range plain {
|
||||||
|
marks := make([]jsonMark, 0, len(item.marks))
|
||||||
|
for _, m := range item.marks {
|
||||||
|
marks = append(marks, jsonMark{Type: m.kind, Href: m.href})
|
||||||
|
}
|
||||||
|
texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks})
|
||||||
|
}
|
||||||
|
return texts
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
blockSeparator = "\n\n"
|
||||||
|
hardBreak = "\\\n"
|
||||||
|
bulletMarker = "- "
|
||||||
|
codeFence = "```"
|
||||||
|
alignOpen = `<div style="text-align: `
|
||||||
|
alignOpenEnd = `">`
|
||||||
|
alignClose = "</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
var textEscaper = strings.NewReplacer(
|
||||||
|
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
|
||||||
|
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
|
||||||
|
|
||||||
|
func renderMarkdown(blocks []block) string {
|
||||||
|
return renderBlocks(blocks, blockSeparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlocks(blocks []block, separator string) string {
|
||||||
|
parts := make([]string, 0, len(blocks))
|
||||||
|
for _, b := range blocks {
|
||||||
|
if rendered := renderBlock(b); rendered != "" {
|
||||||
|
parts = append(parts, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlock(b block) string {
|
||||||
|
rendered := renderPlainBlock(b)
|
||||||
|
if b.align == "" || rendered == "" {
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPlainBlock(b block) string {
|
||||||
|
switch b.kind {
|
||||||
|
case kindHeading:
|
||||||
|
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
|
||||||
|
case kindParagraph:
|
||||||
|
return escapeLineStarts(renderInlines(b.inlines))
|
||||||
|
case kindList:
|
||||||
|
return renderList(b)
|
||||||
|
case kindBlockquote:
|
||||||
|
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
|
||||||
|
case kindCode:
|
||||||
|
return renderCode(b)
|
||||||
|
case kindImage:
|
||||||
|
return " + ")"
|
||||||
|
}
|
||||||
|
return "---"
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLineStarts(text string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for index, line := range lines {
|
||||||
|
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
|
||||||
|
if markerLineStart.MatchString(line) {
|
||||||
|
line = `\` + line
|
||||||
|
}
|
||||||
|
lines[index] = line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCode(b block) string {
|
||||||
|
fence := codeFence
|
||||||
|
for strings.Contains(b.text, fence) {
|
||||||
|
fence += "`"
|
||||||
|
}
|
||||||
|
return fence + b.language + "\n" + b.text + "\n" + fence
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeAlt(alt string) string {
|
||||||
|
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderList(b block) string {
|
||||||
|
lines := make([]string, 0, len(b.items))
|
||||||
|
for index, item := range b.items {
|
||||||
|
marker := bulletMarker
|
||||||
|
if b.ordered {
|
||||||
|
marker = strconv.Itoa(index+1) + ". "
|
||||||
|
}
|
||||||
|
content := renderBlocks(item, itemSeparator(item))
|
||||||
|
lines = append(lines, marker+indentContinuation(content, len(marker)))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemSeparator(item []block) string {
|
||||||
|
textBlocks := 0
|
||||||
|
for _, b := range item {
|
||||||
|
if b.kind != kindList {
|
||||||
|
textBlocks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if textBlocks > 1 {
|
||||||
|
return blockSeparator
|
||||||
|
}
|
||||||
|
return "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func indentContinuation(content string, width int) string {
|
||||||
|
indent := strings.Repeat(" ", width)
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for index := 1; index < len(lines); index++ {
|
||||||
|
if lines[index] != "" {
|
||||||
|
lines[index] = indent + lines[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixLines(content, prefix, emptyPrefix string) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for index, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
lines[index] = emptyPrefix
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[index] = prefix + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderInlines(inlines []inline) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, item := range inlines {
|
||||||
|
builder.WriteString(renderInline(item))
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderInline(item inline) string {
|
||||||
|
isCode := hasMarkKind(item.marks, markCode)
|
||||||
|
lines := strings.Split(item.text, "\n")
|
||||||
|
for index, line := range lines {
|
||||||
|
lines[index] = wrapMarks(item.marks, line, isCode)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, hardBreak)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMarkKind(marks []mark, kind string) bool {
|
||||||
|
for _, m := range marks {
|
||||||
|
if m.kind == kind {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapMarks(marks []mark, text string, isCode bool) string {
|
||||||
|
core := strings.TrimSpace(text)
|
||||||
|
if core == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
start := strings.Index(text, core)
|
||||||
|
leading, trailing := text[:start], text[start+len(core):]
|
||||||
|
var wrapped string
|
||||||
|
if isCode {
|
||||||
|
wrapped = inlineCode(core)
|
||||||
|
} else {
|
||||||
|
wrapped = escapeText(core)
|
||||||
|
}
|
||||||
|
for index := len(marks) - 1; index >= 0; index-- {
|
||||||
|
wrapped = applyMark(marks[index], wrapped)
|
||||||
|
}
|
||||||
|
return leading + wrapped + trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
func inlineCode(text string) string {
|
||||||
|
fence := "`"
|
||||||
|
for strings.Contains(text, fence) {
|
||||||
|
fence += "`"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
|
||||||
|
return fence + " " + text + " " + fence
|
||||||
|
}
|
||||||
|
return fence + text + fence
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMark(m mark, text string) string {
|
||||||
|
switch m.kind {
|
||||||
|
case markBold:
|
||||||
|
return "**" + text + "**"
|
||||||
|
case markItalic:
|
||||||
|
return "*" + text + "*"
|
||||||
|
case markLink:
|
||||||
|
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeText(text string) string {
|
||||||
|
return textEscaper.Replace(text)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,15 +6,17 @@ import (
|
|||||||
|
|
||||||
type CreateArticleUseCase struct {
|
type CreateArticleUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
|
bodyRenderer article.BodyRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateArticleCommand struct {
|
type CreateArticleCommand struct {
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
|
Format article.BodyFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase {
|
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
|
||||||
return &CreateArticleUseCase{articleRepository}
|
return &CreateArticleUseCase{articleRepository, bodyRenderer}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
|
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
|
||||||
@@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
|
|||||||
return article.Article{}, err
|
return article.Article{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return g.articleRepository.Create(article.Article{
|
created, err := g.articleRepository.Create(article.Article{
|
||||||
Title: createArticle.Title,
|
Title: createArticle.Title,
|
||||||
Body: createArticle.Body,
|
Body: createArticle.Body,
|
||||||
IsOnline: false,
|
IsOnline: false,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
return renderArticle(g.bodyRenderer, created, createArticle.Format)
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,14 @@ import "RenewCMS/internal/domain/article"
|
|||||||
|
|
||||||
type GetArticleUseCase struct {
|
type GetArticleUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
|
bodyRenderer article.BodyRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase {
|
func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase {
|
||||||
return &GetArticleUseCase{articleRepository}
|
return &GetArticleUseCase{articleRepository, bodyRenderer}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) {
|
func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
|
||||||
art, err := u.articleRepository.Get(id)
|
art, err := u.articleRepository.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return article.Article{}, err
|
return article.Article{}, err
|
||||||
@@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error)
|
|||||||
return article.Article{}, article.ErrArticleNotFound
|
return article.Article{}, article.ErrArticleNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return art, nil
|
return renderArticle(u.bodyRenderer, art, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) {
|
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
|
||||||
return u.articleRepository.Get(id)
|
art, err := u.articleRepository.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
return renderArticle(u.bodyRenderer, art, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) {
|
func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article"
|
|||||||
|
|
||||||
type ListArticlesUseCase struct {
|
type ListArticlesUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
|
bodyRenderer article.BodyRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase {
|
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
|
||||||
return &ListArticlesUseCase{articleRepository}
|
return &ListArticlesUseCase{articleRepository, bodyRenderer}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) {
|
func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) {
|
||||||
online := true
|
online := true
|
||||||
return u.articleRepository.FindByFilters(article.Filters{
|
articles, err := u.articleRepository.FindByFilters(article.Filters{
|
||||||
IsOnline: &online,
|
IsOnline: &online,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return renderArticles(u.bodyRenderer, articles, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) {
|
func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) {
|
||||||
return u.articleRepository.FindByFilters(article.Filters{})
|
articles, err := u.articleRepository.FindByFilters(article.Filters{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return renderArticles(u.bodyRenderer, articles, format)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package useCases
|
package useCases
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
"RenewCMS/internal/domain/hash"
|
"RenewCMS/internal/domain/hash"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,10 +10,12 @@ import (
|
|||||||
type ResetPasswordUseCase struct {
|
type ResetPasswordUseCase struct {
|
||||||
userRepository user.Repository
|
userRepository user.Repository
|
||||||
hasher hash.Hasher
|
hasher hash.Hasher
|
||||||
|
apiKeys apikey.Repository
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher) *ResetPasswordUseCase {
|
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
|
||||||
return &ResetPasswordUseCase{userRepository, hasher}
|
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
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
|
return user.ErrInvalidPasswordResetCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(dbUser.PasswordResetExpiration) {
|
if r.now().After(dbUser.PasswordResetExpiration) {
|
||||||
return user.ErrPasswordResetCodeExpired
|
return user.ErrPasswordResetCodeExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword)
|
if _, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword); err != nil {
|
||||||
return err
|
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())
|
||||||
|
}
|
||||||
@@ -4,16 +4,25 @@ import "RenewCMS/internal/domain/article"
|
|||||||
|
|
||||||
type UpdateArticleUseCase struct {
|
type UpdateArticleUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
|
bodyRenderer article.BodyRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase {
|
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
|
||||||
return &UpdateArticleUseCase{articleRepository}
|
return &UpdateArticleUseCase{articleRepository, bodyRenderer}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) {
|
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
|
||||||
return g.articleRepository.UpdateBody(id, body)
|
updated, err := g.articleRepository.UpdateBody(id, body)
|
||||||
|
if err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
return renderArticle(g.bodyRenderer, updated, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
|
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||||
return g.articleRepository.UpdateIsOnline(id, isOnline)
|
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
|
||||||
|
if err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
return renderArticle(g.bodyRenderer, updated, format)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import "RenewCMS/internal/domain/article"
|
||||||
|
|
||||||
|
func renderArticle(renderer article.BodyRenderer, art article.Article, format article.BodyFormat) (article.Article, error) {
|
||||||
|
body, err := renderer.Render(art.Body, format)
|
||||||
|
if err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
art.Body = body
|
||||||
|
return art, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderArticles(renderer article.BodyRenderer, articles []article.Article, format article.BodyFormat) ([]article.Article, error) {
|
||||||
|
rendered := make([]article.Article, 0, len(articles))
|
||||||
|
for _, art := range articles {
|
||||||
|
renderedArticle, err := renderArticle(renderer, art, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rendered = append(rendered, renderedArticle)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/article"
|
||||||
|
"RenewCMS/api/middleware"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeBodyRenderer struct {
|
||||||
|
fail bool
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
|
||||||
|
|
||||||
|
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.fail {
|
||||||
|
return "", errors.New("render failed")
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
bodyFormatArticleOneHTML = "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
|
||||||
|
bodyFormatArticleOneMD = "## Plan\n\nReplace the **old** deck."
|
||||||
|
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
articleHandler := article.NewHandler(
|
||||||
|
useCases.NewCreateArticleUseCase(articleRepo, renderer),
|
||||||
|
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||||
|
useCases.NewListArticlesUseCase(articleRepo, renderer),
|
||||||
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
|
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
|
||||||
|
getUserUseCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/v1", func(r chi.Router) {
|
||||||
|
r.Route("/article", func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(token))
|
||||||
|
r.Get("/", articleHandler.ListArticles)
|
||||||
|
r.Get("/{id}", articleHandler.GetArticle)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Authenticator(token))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||||
|
r.Post("/", articleHandler.PostArticle)
|
||||||
|
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||||
|
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
|
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBodyFormatRepo() *mockArticleRepo {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: bodyFormatArticleOneHTML, IsOnline: true}
|
||||||
|
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "<p>Draft</p>", IsOnline: false}
|
||||||
|
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "<p>Second</p>", IsOnline: true}
|
||||||
|
repo.nextID = 3
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
type bodyFormatResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
BodyFormat string `json:"body_format"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBodyFormat(router http.Handler, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBodyFormatArticle(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) bodyFormatResponse {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != expectedStatus {
|
||||||
|
t.Fatalf("expected status %d, got %d, body %q", expectedStatus, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var got bodyFormatResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBodyFormat(t *testing.T, got bodyFormatResponse, format, expectedBody string) {
|
||||||
|
t.Helper()
|
||||||
|
if got.BodyFormat != format {
|
||||||
|
t.Errorf("expected body_format %q, got %q", format, got.BodyFormat)
|
||||||
|
}
|
||||||
|
if format == "json" {
|
||||||
|
assertSameJSON(t, string(got.Body), expectedBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(got.Body, &text); err != nil {
|
||||||
|
t.Fatalf("body is not a JSON string: %s", got.Body)
|
||||||
|
}
|
||||||
|
if text != expectedBody {
|
||||||
|
t.Errorf("expected body %q, got %q", expectedBody, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSameJSON(t *testing.T, actual, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
var actualValue, expectedValue any
|
||||||
|
if err := json.Unmarshal([]byte(actual), &actualValue); err != nil {
|
||||||
|
t.Fatalf("actual is not JSON: %s", actual)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(expected), &expectedValue); err != nil {
|
||||||
|
t.Fatalf("expected is not JSON: %s", expected)
|
||||||
|
}
|
||||||
|
actualBytes, _ := json.Marshal(actualValue)
|
||||||
|
expectedBytes, _ := json.Marshal(expectedValue)
|
||||||
|
if string(actualBytes) != string(expectedBytes) {
|
||||||
|
t.Errorf("expected %s, got %s", expectedBytes, actualBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArticleBodyFormatHttp(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
|
||||||
|
return newBodyFormatRouter(repo, users, renderer, token)
|
||||||
|
}
|
||||||
|
realRouter := func() (*chi.Mux, *mockArticleRepo) {
|
||||||
|
repo := seedBodyFormatRepo()
|
||||||
|
return newRouter(repo, newPublicArticleReadUserRepo(true), markdown.NewConverter()), repo
|
||||||
|
}
|
||||||
|
verifiedCookie := mintPublicArticleReadCookie(t, token, 1)
|
||||||
|
|
||||||
|
t.Run("Single read negotiates the body format", func(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
query, format, body string
|
||||||
|
}{
|
||||||
|
{"", "markdown", bodyFormatArticleOneMD},
|
||||||
|
{"?format=", "markdown", bodyFormatArticleOneMD},
|
||||||
|
{"?format=markdown", "markdown", bodyFormatArticleOneMD},
|
||||||
|
{"?format=MarkDown", "markdown", bodyFormatArticleOneMD},
|
||||||
|
{"?format=html", "html", bodyFormatArticleOneHTML},
|
||||||
|
{"?format=HTML", "html", bodyFormatArticleOneHTML},
|
||||||
|
{"?format=json", "json", bodyFormatArticleOneJSON},
|
||||||
|
{"?format=JSON", "json", bodyFormatArticleOneJSON},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
router, _ := realRouter()
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1"+example.query, "", nil)
|
||||||
|
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
|
||||||
|
assertBodyFormat(t, got, example.format, example.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("List read negotiates every element", func(t *testing.T) {
|
||||||
|
router, _ := realRouter()
|
||||||
|
expectations := []struct {
|
||||||
|
query, format, secondBody string
|
||||||
|
}{
|
||||||
|
{"", "markdown", "Second"},
|
||||||
|
{"?format=html", "html", "<p>Second</p>"},
|
||||||
|
{"?format=json", "json", `[{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]`},
|
||||||
|
}
|
||||||
|
for _, expectation := range expectations {
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
|
||||||
|
var items []bodyFormatResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("failed to decode list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("expected 2 online articles, got %d", len(items))
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.BodyFormat != expectation.format {
|
||||||
|
t.Errorf("expected body_format %q, got %q", expectation.format, item.BodyFormat)
|
||||||
|
}
|
||||||
|
if item.ID == 3 {
|
||||||
|
assertBodyFormat(t, item, expectation.format, expectation.secondBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unsupported format is rejected", func(t *testing.T) {
|
||||||
|
for _, path := range []string{"/v1/article/1?format=pdf", "/v1/article/?format=xml", "/v1/article/9999?format=pdf", "/v1/article/1?format=md"} {
|
||||||
|
renderer := &fakeBodyRenderer{}
|
||||||
|
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), renderer)
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, path, "", nil)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s: expected status 400, got %d", path, rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
|
||||||
|
t.Errorf("%s: unexpected body %q", path, rec.Body.String())
|
||||||
|
}
|
||||||
|
if renderer.calls != 0 {
|
||||||
|
t.Errorf("%s: renderer must not be called", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An invalid id is reported before an invalid format", func(t *testing.T) {
|
||||||
|
router, _ := realRouter()
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/abc?format=pdf", "", nil)
|
||||||
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
|
||||||
|
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The first repeated format parameter wins", func(t *testing.T) {
|
||||||
|
router, _ := realRouter()
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1?format=html&format=json", "", nil)
|
||||||
|
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
|
||||||
|
assertBodyFormat(t, got, "html", bodyFormatArticleOneHTML)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Offline articles stay hidden whatever the format", func(t *testing.T) {
|
||||||
|
unverified := newRouter(seedBodyFormatRepo(), newMockUserRepo(), markdown.NewConverter())
|
||||||
|
rec := serveBodyFormat(unverified, http.MethodGet, "/v1/article/2?format=json", "", nil)
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
|
||||||
|
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Authenticated routes negotiate the response body", func(t *testing.T) {
|
||||||
|
paragraph := func(text string) string {
|
||||||
|
return `[{"type":"paragraph","children":[{"type":"text","text":"` + text + `","marks":[]}]}]`
|
||||||
|
}
|
||||||
|
type expectedBodies map[string]string
|
||||||
|
bodiesA := expectedBodies{"markdown": "A", "html": "<p>A</p>", "json": paragraph("A")}
|
||||||
|
bodiesB := expectedBodies{"markdown": "B", "html": "<p>B</p>", "json": paragraph("B")}
|
||||||
|
bodiesOne := expectedBodies{"markdown": bodyFormatArticleOneMD, "html": bodyFormatArticleOneHTML, "json": bodyFormatArticleOneJSON}
|
||||||
|
examples := []struct {
|
||||||
|
method, path, body string
|
||||||
|
status int
|
||||||
|
format string
|
||||||
|
bodies expectedBodies
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/v1/article/", `{"title":"Deck","body":"<p>A</p>"}`, 201, "markdown", bodiesA},
|
||||||
|
{http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"<p>A</p>"}`, 201, "html", bodiesA},
|
||||||
|
{http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"<p>A</p>"}`, 201, "json", bodiesA},
|
||||||
|
{http.MethodPatch, "/v1/article/1", `{"body":"<p>B</p>"}`, 200, "markdown", bodiesB},
|
||||||
|
{http.MethodPatch, "/v1/article/1?format=json", `{"body":"<p>B</p>"}`, 200, "json", bodiesB},
|
||||||
|
{http.MethodPatch, "/v1/article/1/publish", "", 200, "markdown", bodiesOne},
|
||||||
|
{http.MethodPatch, "/v1/article/1/unpublish?format=html", "", 200, "html", bodiesOne},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
router, repo := realRouter()
|
||||||
|
rec := serveBodyFormat(router, example.method, example.path, example.body, verifiedCookie)
|
||||||
|
got := decodeBodyFormatArticle(t, rec, example.status)
|
||||||
|
assertBodyFormat(t, got, example.format, example.bodies[example.format])
|
||||||
|
stored := repo.articles[got.ID].Body
|
||||||
|
if example.bodies["html"] != stored {
|
||||||
|
t.Errorf("%s %s: stored body must stay HTML %q, got %q", example.method, example.path, example.bodies["html"], stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unsupported format on a write route changes nothing", func(t *testing.T) {
|
||||||
|
requests := []struct {
|
||||||
|
method, path, body string
|
||||||
|
}{
|
||||||
|
{http.MethodPatch, "/v1/article/1?format=pdf", `{"body":"<p>B</p>"}`},
|
||||||
|
{http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"<p>A</p>"}`},
|
||||||
|
{http.MethodPatch, "/v1/article/1/publish?format=pdf", ""},
|
||||||
|
{http.MethodPatch, "/v1/article/1/unpublish?format=pdf", ""},
|
||||||
|
}
|
||||||
|
for _, request := range requests {
|
||||||
|
repo := seedBodyFormatRepo()
|
||||||
|
renderer := &fakeBodyRenderer{}
|
||||||
|
router := newRouter(repo, newPublicArticleReadUserRepo(true), renderer)
|
||||||
|
rec := serveBodyFormat(router, request.method, request.path, request.body, verifiedCookie)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s %s: expected 400, got %d", request.method, request.path, rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
|
||||||
|
t.Errorf("%s %s: unexpected body %q", request.method, request.path, rec.Body.String())
|
||||||
|
}
|
||||||
|
if renderer.calls != 0 {
|
||||||
|
t.Errorf("%s %s: renderer must not be called", request.method, request.path)
|
||||||
|
}
|
||||||
|
if repo.articles[1].Body != bodyFormatArticleOneHTML {
|
||||||
|
t.Errorf("%s %s: stored body changed to %q", request.method, request.path, repo.articles[1].Body)
|
||||||
|
}
|
||||||
|
if !repo.articles[1].IsOnline {
|
||||||
|
t.Errorf("%s %s: is_online changed", request.method, request.path)
|
||||||
|
}
|
||||||
|
if repo.createCalled {
|
||||||
|
t.Errorf("%s %s: no article may be created", request.method, request.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The authenticated list includes offline articles in the negotiated format", func(t *testing.T) {
|
||||||
|
router, _ := realRouter()
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
|
||||||
|
var items []bodyFormatResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("failed to decode list: %v", err)
|
||||||
|
}
|
||||||
|
seen := map[uint32]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
seen[item.ID] = true
|
||||||
|
if item.BodyFormat != "json" {
|
||||||
|
t.Errorf("expected json, got %q", item.BodyFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[1] || !seen[2] {
|
||||||
|
t.Errorf("expected articles 1 and 2, got %v", seen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A converter failure answers 500", func(t *testing.T) {
|
||||||
|
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), &fakeBodyRenderer{fail: true})
|
||||||
|
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1", "", nil)
|
||||||
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
|
||||||
|
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBodyFormat(t *testing.T) {
|
||||||
|
valid := map[string]domainArticle.BodyFormat{
|
||||||
|
"": domainArticle.BodyFormatMarkdown, "markdown": domainArticle.BodyFormatMarkdown, "MARKDOWN": domainArticle.BodyFormatMarkdown,
|
||||||
|
"html": domainArticle.BodyFormatHTML, "Html": domainArticle.BodyFormatHTML, "json": domainArticle.BodyFormatJSON,
|
||||||
|
}
|
||||||
|
for input, expected := range valid {
|
||||||
|
got, err := domainArticle.ParseBodyFormat(input)
|
||||||
|
if err != nil || got != expected {
|
||||||
|
t.Errorf("ParseBodyFormat(%q) = %q, %v", input, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, input := range []string{"pdf", "md", " html"} {
|
||||||
|
if _, err := domainArticle.ParseBodyFormat(input); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
|
||||||
|
t.Errorf("ParseBodyFormat(%q) should be unsupported, got %v", input, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{}
|
|||||||
|
|
||||||
func TestCreateArticle(t *testing.T) {
|
func TestCreateArticle(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
uc := useCases.NewCreateArticleUseCase(repo)
|
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
t.Run("Valid Title", func(t *testing.T) {
|
t.Run("Valid Title", func(t *testing.T) {
|
||||||
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
|
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
|
||||||
@@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) {
|
|||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
|
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
|
||||||
repo.articles[2] = article.Article{ID: 2, IsOnline: false}
|
repo.articles[2] = article.Article{ID: 2, IsOnline: false}
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
t.Run("Article Online", func(t *testing.T) {
|
t.Run("Article Online", func(t *testing.T) {
|
||||||
_, err := uc.GetOnlineArticle(1)
|
_, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected article to be found")
|
t.Errorf("Expected article to be found")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Article Offline", func(t *testing.T) {
|
t.Run("Article Offline", func(t *testing.T) {
|
||||||
_, err := uc.GetOnlineArticle(2)
|
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("Offline article should return not found")
|
t.Errorf("Offline article should return not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(repo),
|
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(repo),
|
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(repo),
|
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(repo),
|
useCases.NewDeleteArticleUseCase(repo),
|
||||||
useCases.NewUpdateArticleUseCase(repo),
|
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
|
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
uc := useCases.NewCreateArticleUseCase(repo)
|
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
title := strings.Repeat("\U0001F600", tc.runeCount)
|
title := strings.Repeat("\U0001F600", tc.runeCount)
|
||||||
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hostileBody = `<h1>Title</h1><p>Intro <strong>bold</strong><script>alert(1)</script></p><style>p{}</style><iframe src="x"></iframe><ul><li><p>one</p><ul><li><p>deep</p></li></ul></li></ul><div><span style="color:red">kept</span></div><img src="/i.png" alt="I"><pre><code>a<script>z</script></code></pre>`
|
||||||
|
|
||||||
|
func TestConverterNeverLeaksRawHtml(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
for _, format := range []domainArticle.BodyFormat{domainArticle.BodyFormatMarkdown, domainArticle.BodyFormatJSON} {
|
||||||
|
got, err := converter.Render(hostileBody, format)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: unexpected error: %v", format, err)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"<script", "<style", "<iframe", "<span", "alert(1)", "p{}"} {
|
||||||
|
if strings.Contains(got, forbidden) {
|
||||||
|
t.Errorf("%s output leaks %q: %s", format, forbidden, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJsonConverterCombinedDocument(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
got, err := converter.Render(hostileBody, domainArticle.BodyFormatJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
var blocks []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(got), &blocks); err != nil {
|
||||||
|
t.Fatalf("body is not a JSON array: %v", err)
|
||||||
|
}
|
||||||
|
var types []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
types = append(types, b["type"].(string))
|
||||||
|
}
|
||||||
|
expected := []string{"heading", "paragraph", "list", "paragraph", "image", "code"}
|
||||||
|
if !reflect.DeepEqual(types, expected) {
|
||||||
|
t.Errorf("expected block types %v, got %v", expected, types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJsonConverterEmptyBodyIsArray(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
for _, source := range []string{"", " ", "<script>x</script>"} {
|
||||||
|
got, err := converter.Render(source, domainArticle.BodyFormatJSON)
|
||||||
|
if err != nil || got != "[]" {
|
||||||
|
t.Errorf("%q: expected [] got %q, %v", source, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownConverterCodeBlockText(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
examples := []struct{ name, html, markdown string }{
|
||||||
|
{"text node kept", "<pre><code>x</code></pre>", "```\nx\n```"},
|
||||||
|
{"skipped element dropped", "<pre><code>a<script>z</script>b</code></pre>", "```\nab\n```"},
|
||||||
|
{"style dropped", "<pre><code>a<style>z</style></code></pre>", "```\na\n```"},
|
||||||
|
{"break becomes newline", "<pre><code>a<br>b</code></pre>", "```\na\nb\n```"},
|
||||||
|
{"nested element text kept", "<pre><code>a<span>b</span>c</code></pre>", "```\nabc\n```"},
|
||||||
|
{"trailing newline trimmed", "<pre><code>a\n</code></pre>", "```\na\n```"},
|
||||||
|
{"fence grows around backticks", "<pre><code>```\nx</code></pre>", "````\n```\nx\n````"},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
t.Run(example.name, func(t *testing.T) {
|
||||||
|
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
|
||||||
|
if err != nil || got != example.markdown {
|
||||||
|
t.Errorf("expected %q, got %q, %v", example.markdown, got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJsonBlocksConverter(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
paragraphA := `{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}`
|
||||||
|
listOfA := `[{"type":"list","ordered":false,"items":[[` + paragraphA + `]]}]`
|
||||||
|
examples := []struct {
|
||||||
|
name, html, json string
|
||||||
|
}{
|
||||||
|
{"h1", "<h1>T</h1>", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`},
|
||||||
|
{"h6", "<h6>S</h6>", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`},
|
||||||
|
{"paragraph", "<p>plain</p>", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`},
|
||||||
|
{"empty paragraph", "<p></p>", `[{"type":"paragraph","children":[]}]`},
|
||||||
|
{"bold", "<p><strong>b</strong></p>", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`},
|
||||||
|
{"italic", "<p><em>i</em></p>", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`},
|
||||||
|
{"link", `<p><a href="https://example.com/x">l</a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`},
|
||||||
|
{"nested marks outermost first", `<p><a href="/a"><strong><em>x</em></strong></a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`},
|
||||||
|
{"line break", "<p>a<br>b</p>", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`},
|
||||||
|
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`},
|
||||||
|
{"ordered list", "<ol><li><p>a</p></li></ol>", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`},
|
||||||
|
{"bare list item", "<ul><li>a</li></ul>", listOfA},
|
||||||
|
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`},
|
||||||
|
{"data-indent ignored", `<ul><li data-indent="2"><p>a</p></li></ul>`, listOfA},
|
||||||
|
{"blockquote", "<blockquote><p>q</p></blockquote>", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`},
|
||||||
|
{"code with language", `<pre><code class="language-go">x := 1</code></pre>`, `[{"type":"code","language":"go","text":"x := 1"}]`},
|
||||||
|
{"code without language", "<pre><code>raw</code></pre>", `[{"type":"code","language":"","text":"raw"}]`},
|
||||||
|
{"rule", "<hr>", `[{"type":"hr"}]`},
|
||||||
|
{"image with alt", `<img src="/static/uploadedImages/a.png" alt="A">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`},
|
||||||
|
{"image without alt", `<img src="/static/uploadedImages/a.png">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`},
|
||||||
|
{"image splits paragraph", `<p>x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}]`},
|
||||||
|
{"span stripped", `<p>x<span style="color:red">y</span></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`},
|
||||||
|
{"inline code degrades", "<p>use <code>go test</code></p>", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`},
|
||||||
|
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`},
|
||||||
|
{"empty html", "", `[]`},
|
||||||
|
{"centered paragraph", `<p style="text-align: center">a</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`},
|
||||||
|
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`},
|
||||||
|
{"justified paragraph", `<p style="color:red; text-align: JUSTIFY">a</p>`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`},
|
||||||
|
{"left alignment omitted", `<p style="text-align: left">a</p>`, `[` + paragraphA + `]`},
|
||||||
|
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","align":"center","children":[{"type":"text","text":"y","marks":[]}]}]`},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
t.Run(example.name, func(t *testing.T) {
|
||||||
|
got, err := converter.Render(example.html, domainArticle.BodyFormatJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != example.json {
|
||||||
|
t.Errorf("expected %s, got %s", example.json, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkdownConverterFidelity(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
examples := []struct {
|
||||||
|
name, html, markdown string
|
||||||
|
}{
|
||||||
|
{"headings", "<h1>T</h1><h3>S</h3>", "# T\n\n### S"},
|
||||||
|
{"bold and italic", "<p><strong>b</strong> <em>i</em></p>", "**b** *i*"},
|
||||||
|
{"link", `<p><a href="https://example.com/x">link</a></p>`, "[link](https://example.com/x)"},
|
||||||
|
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", "- a\n- b"},
|
||||||
|
{"ordered list", "<ol><li><p>a</p></li><li><p>b</p></li></ol>", "1. a\n2. b"},
|
||||||
|
{"data-indent dropped", `<ul><li data-indent="2"><p>a</p></li></ul>`, "- a"},
|
||||||
|
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", "- a\n - n"},
|
||||||
|
{"blockquote", "<blockquote><p>q</p></blockquote>", "> q"},
|
||||||
|
{"code block", `<pre><code class="language-go">x := 1</code></pre>`, "```go\nx := 1\n```"},
|
||||||
|
{"rule", "<hr>", "---"},
|
||||||
|
{"image", `<img src="/static/uploadedImages/a.png" alt="A">`, ""},
|
||||||
|
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", "ok"},
|
||||||
|
{"unsupported span stripped", `<p>x<span style="color:red">y</span></p>`, "xy"},
|
||||||
|
{"inline code", "<p>use <code>go test</code></p>", "use `go test`"},
|
||||||
|
{"empty body", "", ""},
|
||||||
|
{"line break", "<p>a<br>b</p>", "a\\\nb"},
|
||||||
|
{"literal angle bracket is escaped", "<p><script></p>", "\\<script>"},
|
||||||
|
{"javascript link dropped", `<p><a href="javascript:alert(1)">x</a></p>`, "x"},
|
||||||
|
{"bold keeps outer spaces", "<p>a<strong> b </strong>c</p>", "a **b** c"},
|
||||||
|
{"loose list item", "<ul><li><p>a</p><p>b</p></li></ul>", "- a\n\n b"},
|
||||||
|
{"inline code with edge backtick", "<p><code>`x</code></p>", "`` `x ``"},
|
||||||
|
{"code block keeps br and drops script", "<pre><code>a<br>b<script>z</script></code></pre>", "```\na\nb\n```"},
|
||||||
|
{"adjacent same marks merge", "<p><strong>a</strong><strong>b</strong></p>", "**ab**"},
|
||||||
|
{"adjacent different marks stay apart", "<p><strong>a</strong><em>b</em></p>", "**a***b*"},
|
||||||
|
{"two paragraphs", "<p>a</p><p>b</p>", "a\n\nb"},
|
||||||
|
{"centered paragraph", `<p style="text-align: center">hi <strong>b</strong></p>`, "<div style=\"text-align: center\">\n\nhi **b**\n\n</div>"},
|
||||||
|
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, "<div style=\"text-align: right\">\n\n## T\n\n</div>"},
|
||||||
|
{"justified among other styles", `<p style="color:red; TEXT-ALIGN: Justify">a</p>`, "<div style=\"text-align: justify\">\n\na\n\n</div>"},
|
||||||
|
{"tiptap style with trailing semicolon", `<p style="text-align: center;">a</p>`, "<div style=\"text-align: center\">\n\na\n\n</div>"},
|
||||||
|
{"tiptap explicit left", `<p style="text-align: left;">a</p>`, "a"},
|
||||||
|
{"left alignment is the default", `<p style="text-align: left">a</p>`, "a"},
|
||||||
|
{"unknown alignment ignored", `<p style="text-align: start">a</p>`, "a"},
|
||||||
|
{"style without value ignored", `<p style="text-align">a</p>`, "a"},
|
||||||
|
{"empty aligned paragraph", `<p style="text-align: center"></p>`, ""},
|
||||||
|
{"aligned paragraph in list item", `<ul><li><p style="text-align: center">a</p></li></ul>`, "- <div style=\"text-align: center\">\n\n a\n\n </div>"},
|
||||||
|
{"aligned paragraph in blockquote", `<blockquote><p style="text-align: center">q</p></blockquote>`, "> <div style=\"text-align: center\">\n>\n> q\n>\n> </div>"},
|
||||||
|
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, "<div style=\"text-align: center\">\n\nx\n\n</div>\n\n\n\n<div style=\"text-align: center\">\n\ny\n\n</div>"},
|
||||||
|
{"text characters are escaped", "<p>a * b _c_ `d` [e]</p>", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"},
|
||||||
|
{"leading hash is escaped", "<p># not a heading</p>", "\\# not a heading"},
|
||||||
|
{"hash without space is kept", "<p>#tag</p>", "#tag"},
|
||||||
|
{"leading dash is escaped", "<p>- not a list</p>", "\\- not a list"},
|
||||||
|
{"leading plus is escaped", "<p>+ not a list</p>", "\\+ not a list"},
|
||||||
|
{"leading quote marker is escaped", "<p>> not a quote</p>", "\\> not a quote"},
|
||||||
|
{"leading number is escaped", "<p>1. not a list</p>", "1\\. not a list"},
|
||||||
|
{"leading number with parenthesis is escaped", "<p>2) not a list</p>", "2\\) not a list"},
|
||||||
|
{"leading rule is escaped", "<p>---</p>", "\\---"},
|
||||||
|
{"leading equals is escaped", "<p>==</p>", "\\=="},
|
||||||
|
{"inner dash is kept", "<p>a - b</p>", "a - b"},
|
||||||
|
{"marker after a line break is escaped", "<p>a<br>- b</p>", "a\\\n\\- b"},
|
||||||
|
{"combined document", `<h1>Title</h1><p style="text-align: center">Intro <strong>bold</strong> and <em>it</em></p><ul><li><p>one</p></li><li><p>two</p></li></ul><img src="/static/uploadedImages/a.png" alt="A"><pre><code class="language-go">x := 1</code></pre><blockquote><p>quote</p></blockquote><hr>`, "# Title\n\n<div style=\"text-align: center\">\n\nIntro **bold** and *it*\n\n</div>\n\n- one\n- two\n\n\n\n```go\nx := 1\n```\n\n> quote\n\n---"},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
t.Run(example.name, func(t *testing.T) {
|
||||||
|
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != example.markdown {
|
||||||
|
t.Errorf("expected %q, got %q", example.markdown, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownConverterHtmlAndUnknownFormat(t *testing.T) {
|
||||||
|
converter := markdown.NewConverter()
|
||||||
|
html := "<p>a</p><script>x</script>"
|
||||||
|
got, err := converter.Render(html, domainArticle.BodyFormatHTML)
|
||||||
|
if err != nil || got != html {
|
||||||
|
t.Errorf("html must pass through unchanged, got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := converter.Render(html, domainArticle.BodyFormat("pdf")); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
|
||||||
|
t.Errorf("expected ErrUnsupportedBodyFormat, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,10 +52,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
|||||||
|
|
||||||
container := &api.UseCases{
|
container := &api.UseCases{
|
||||||
Token: env.token,
|
Token: env.token,
|
||||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles),
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
|
||||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles),
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
|
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
|
||||||
|
|||||||
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
|
|||||||
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import (
|
|||||||
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRep
|
|||||||
hasher := &stubHasher{}
|
hasher := &stubHasher{}
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -46,7 +47,7 @@ func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseR
|
|||||||
hasher := resetMailFailureHasher{}
|
hasher := resetMailFailureHasher{}
|
||||||
handler := auth.NewPasswordResetHandler(
|
handler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux
|
|||||||
mailRepo := newMockMailRepo()
|
mailRepo := newMockMailRepo()
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -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) {
|
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))
|
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")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
PasswordResetCode: "hashed:reset-code-1",
|
PasswordResetCode: "hashed:reset-code-1",
|
||||||
PasswordResetExpiration: time.Now().Add(time.Hour),
|
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")
|
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) {
|
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))
|
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")
|
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) {
|
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))
|
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")
|
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) {
|
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
|
||||||
repo := newMockUserRepo()
|
repo := newMockUserRepo()
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
|
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
repo := newMockUserRepo()
|
repo := newMockUserRepo()
|
||||||
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
|
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
|
||||||
hasher := &stubHasher{}
|
hasher := &stubHasher{}
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, hasher)
|
uc := useCases.NewResetPasswordUseCase(repo, hasher, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
|
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) {
|
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 := newRepo(time.Now().Add(time.Hour))
|
||||||
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
|
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")
|
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) {
|
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))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
hashErr := errors.New("hash blew up")
|
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")
|
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) {
|
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
repo.fail.failNext("ResetPassword")
|
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")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
|||||||
|
|
||||||
return &api.UseCases{
|
return &api.UseCases{
|
||||||
Token: token,
|
Token: token,
|
||||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
|
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import (
|
|||||||
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
loginHandler := auth.NewLoginHandler(
|
loginHandler := auth.NewLoginHandler(
|
||||||
@@ -43,7 +43,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
|
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
uc := useCases.NewCreateArticleUseCase(repo)
|
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
title := strings.Repeat("a", tc.length)
|
title := strings.Repeat("a", tc.length)
|
||||||
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
||||||
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
|||||||
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
|
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
repo.fail.failNext("Create")
|
repo.fail.failNext("Create")
|
||||||
uc := useCases.NewCreateArticleUseCase(repo)
|
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
|
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetArticle_1", func(t *testing.T) {
|
t.Run("GetArticle_1", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.GetArticle(1)
|
result, err := uc.GetArticle(1, article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -96,8 +96,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetArticle_2", func(t *testing.T) {
|
t.Run("GetArticle_2", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.GetArticle(2)
|
result, err := uc.GetArticle(2, article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -108,8 +108,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetArticle_9", func(t *testing.T) {
|
t.Run("GetArticle_9", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.GetArticle(9)
|
_, err := uc.GetArticle(9, article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,8 +117,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetOnlineArticle_1", func(t *testing.T) {
|
t.Run("GetOnlineArticle_1", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.GetOnlineArticle(1)
|
result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,8 +129,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetOnlineArticle_2", func(t *testing.T) {
|
t.Run("GetOnlineArticle_2", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.GetOnlineArticle(2)
|
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,8 +138,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("GetOnlineArticle_9", func(t *testing.T) {
|
t.Run("GetOnlineArticle_9", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.GetOnlineArticle(9)
|
_, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Backyard_Renovation_Update", func(t *testing.T) {
|
t.Run("Backyard_Renovation_Update", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.GetArticleByName("Backyard Renovation Update")
|
result, err := uc.GetArticleByName("Backyard Renovation Update")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -167,7 +167,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Unknown_Title", func(t *testing.T) {
|
t.Run("Unknown_Title", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewGetArticleUseCase(repo)
|
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.GetArticleByName("Unknown Title")
|
_, err := uc.GetArticleByName("Unknown Title")
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
@@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ListOnlineArticles", func(t *testing.T) {
|
t.Run("ListOnlineArticles", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewListArticlesUseCase(repo)
|
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.ListOnlineArticles()
|
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ListAllArticles", func(t *testing.T) {
|
t.Run("ListAllArticles", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewListArticlesUseCase(repo)
|
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.ListAllArticles()
|
result, err := uc.ListAllArticles(article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
|||||||
|
|
||||||
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
|
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
|
||||||
repo := newMockArticleRepo()
|
repo := newMockArticleRepo()
|
||||||
uc := useCases.NewListArticlesUseCase(repo)
|
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||||
|
|
||||||
result, err := uc.ListOnlineArticles()
|
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("UpdateBody_id1", func(t *testing.T) {
|
t.Run("UpdateBody_id1", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewUpdateArticleUseCase(repo)
|
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.UpdateBody(1, "New body")
|
result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("UpdateBody_id9", func(t *testing.T) {
|
t.Run("UpdateBody_id9", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewUpdateArticleUseCase(repo)
|
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.UpdateBody(9, "New body")
|
_, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
|
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewUpdateArticleUseCase(repo)
|
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
result, err := uc.UpdateIsOnline(1, true)
|
result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
|
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
|
||||||
repo := newRepo()
|
repo := newRepo()
|
||||||
uc := useCases.NewUpdateArticleUseCase(repo)
|
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
|
||||||
_, err := uc.UpdateIsOnline(9, true)
|
_, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
|
||||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
|||||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||||
|
|
||||||
articleHandler := article.NewHandler(
|
articleHandler := article.NewHandler(
|
||||||
useCases.NewCreateArticleUseCase(articleRepo),
|
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||||
getUserUseCase,
|
getUserUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"@tiptap/core": "3.31.3",
|
"@tiptap/core": "3.31.3",
|
||||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||||
"@tiptap/extension-image": "3.31.3",
|
"@tiptap/extension-image": "3.31.3",
|
||||||
|
"@tiptap/extension-text-align": "3.31.3",
|
||||||
"@tiptap/pm": "3.31.3",
|
"@tiptap/pm": "3.31.3",
|
||||||
"@tiptap/vue-3": "3.31.3",
|
"@tiptap/vue-3": "3.31.3",
|
||||||
"@vueuse/core": "14.4.0",
|
"@vueuse/core": "14.4.0",
|
||||||
@@ -425,6 +426,8 @@
|
|||||||
|
|
||||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
|
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="],
|
||||||
|
|
||||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
|
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
|
||||||
|
|
||||||
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
|
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@tiptap/core": "3.31.3",
|
"@tiptap/core": "3.31.3",
|
||||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||||
"@tiptap/extension-image": "3.31.3",
|
"@tiptap/extension-image": "3.31.3",
|
||||||
|
"@tiptap/extension-text-align": "3.31.3",
|
||||||
"@tiptap/pm": "3.31.3",
|
"@tiptap/pm": "3.31.3",
|
||||||
"@tiptap/vue-3": "3.31.3",
|
"@tiptap/vue-3": "3.31.3",
|
||||||
"@vueuse/core": "14.4.0",
|
"@vueuse/core": "14.4.0",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { locale, t } from '@/i18n'
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref, useId } from 'vue'
|
||||||
import type { ColorModePreference } from '@/composables/useColorMode'
|
import type { ColorModePreference } from '@/composables/useColorMode'
|
||||||
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
|
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
|
||||||
import LanguagePicker from '@/components/LanguagePicker.vue'
|
import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
|
||||||
|
|
||||||
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="combobox"]'
|
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'
|
||||||
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
|
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] font-normal text-highlighted'
|
||||||
|
const SECTION_LABEL_CLASS =
|
||||||
|
'px-4 pt-2 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
username: string
|
username: string
|
||||||
@@ -29,6 +31,8 @@ const {
|
|||||||
goToProfile: navigateToProfile,
|
goToProfile: navigateToProfile,
|
||||||
} = useAccountMenu(() => props)
|
} = useAccountMenu(() => props)
|
||||||
const menu = ref<HTMLElement | null>(null)
|
const menu = ref<HTMLElement | null>(null)
|
||||||
|
const themeLabelId = useId()
|
||||||
|
const languageLabelId = useId()
|
||||||
|
|
||||||
function goToProfile() {
|
function goToProfile() {
|
||||||
open.value = false
|
open.value = false
|
||||||
@@ -37,7 +41,6 @@ function goToProfile() {
|
|||||||
|
|
||||||
function chooseColorMode(value: ColorModePreference) {
|
function chooseColorMode(value: ColorModePreference) {
|
||||||
colorMode.preference = value
|
colorMode.preference = value
|
||||||
open.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logOut() {
|
function logOut() {
|
||||||
@@ -46,7 +49,6 @@ function logOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moveFocus(event: KeyboardEvent) {
|
function moveFocus(event: KeyboardEvent) {
|
||||||
if ((event.target as HTMLElement).closest('[role="combobox"]')) return
|
|
||||||
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -96,7 +98,7 @@ function moveFocus(event: KeyboardEvent) {
|
|||||||
:text="initials"
|
:text="initials"
|
||||||
:ui="{
|
:ui="{
|
||||||
root: 'size-9 bg-secondary-300',
|
root: 'size-9 bg-secondary-300',
|
||||||
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
|
fallback: 'text-[0.8125rem] pt-0.5 font-semibold text-primary-900',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
@@ -120,14 +122,14 @@ function moveFocus(event: KeyboardEvent) {
|
|||||||
{{ t('Profile') }}
|
{{ t('Profile') }}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<USeparator class="my-1" />
|
|
||||||
<LanguagePicker mobile />
|
|
||||||
<USeparator class="my-1" />
|
<USeparator class="my-1" />
|
||||||
|
|
||||||
|
<div role="group" :aria-labelledby="themeLabelId">
|
||||||
|
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
|
||||||
<UButton
|
<UButton
|
||||||
v-for="option in colorModeOptions"
|
v-for="option in colorModeOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
role="menuitemcheckbox"
|
role="menuitemradio"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
block
|
block
|
||||||
@@ -141,6 +143,31 @@ function moveFocus(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator class="my-1" />
|
||||||
|
|
||||||
|
<div role="group" :aria-labelledby="languageLabelId">
|
||||||
|
<div :id="languageLabelId" :class="SECTION_LABEL_CLASS">
|
||||||
|
{{ t('Language') }}
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-for="option in languageOptions"
|
||||||
|
:key="option.value"
|
||||||
|
role="menuitemradio"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
block
|
||||||
|
:lang="option.value"
|
||||||
|
:aria-checked="locale === option.value"
|
||||||
|
:trailing-icon="locale === option.value ? 'i-lucide-check' : undefined"
|
||||||
|
:class="ITEM_CLASS"
|
||||||
|
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
|
||||||
|
@click="chooseLanguage(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<USeparator class="my-1" />
|
<USeparator class="my-1" />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user