Compare commits

...
30 Commits
Author SHA1 Message Date
fsylvain da36c78bc0 refac: hardening pass with multiple overhauls 2026-09-29 10:55:23 +02:00
fsylvain b0f7b7892b feat(SPA): profile page, display name and the new account menu 2026-09-29 10:13:09 +02:00
fsylvain 55525c06d6 feat: invitation accept screen and api 2026-09-29 09:21:53 +02:00
fsylvain 310cfd528e chore: new design for invitations accept 2026-09-29 08:41:54 +02:00
fsylvain 6afffc1984 feat(SPA): users page and invite modal 2026-09-29 08:35:14 +02:00
fsylvain acdef18b37 feat(API): user invitations and emails 2026-09-29 07:15:17 +02:00
fsylvain 7aae704d0a feat(API): optional display name on users 2026-09-29 06:52:58 +02:00
fsylvain 792aba433d chore: new design groundwork for next features 2026-09-29 06:32:46 +02:00
fsylvain b29d0ed045 feat: editor image removal QOLs 2026-09-29 02:44:31 +02:00
fsylvain 0fc00ceeff refac: cleaning round for image cropping/resizing 2026-09-28 07:56:13 +02:00
fsylvain e15f444603 feat: image cropping / resizing T0 2026-09-28 07:46:12 +02:00
fsylvain cb05b38f36 feat: password reset full design and flow 2026-09-28 05:06:16 +02:00
fsylvain 938263cd6a feat: image upload on articles 2026-09-28 02:28:20 +02:00
fsylvain d4fcfb2bba fix: multiple fixes all accross the SPA, missing buttons actions, styling etc. 2026-09-27 23:16:53 +02:00
fsylvain ad55137883 feat: integration screen 2026-09-27 18:04:43 +02:00
fsylvain a9a208b80a fix: workflow now ignores time senstive visual checks 2026-09-27 17:15:22 +02:00
fsylvain f6d0d01092 feat: articles list screen 2026-09-27 17:12:23 +02:00
fsylvain ca3fe20519 fix: browser was running two copies of ProseMirror's state module (prosemirror-state) at once 2026-09-27 06:31:45 +02:00
fsylvain c47d33eaa9 feat: article creation modal 2026-09-27 06:26:41 +02:00
fsylvain a4734b2c91 feat: article editor core 2026-09-27 02:11:38 +02:00
fsylvain d493a181e1 fix: visuals reference matching overhaul and better workflow 2026-09-26 21:04:51 +02:00
fsylvain 930b836241 feat: home screen and navigation design 2026-09-26 17:46:42 +02:00
fsylvain 30a4df47b1 feat: new emails design 2026-09-26 05:41:34 +02:00
fsylvain 4e41d60142 feat: register T1 (pending, verify) 2026-09-26 04:56:48 +02:00
fsylvain 3c3f7c6194 feat: register T0 2026-09-26 02:48:16 +02:00
fsylvain a5873f8886 fix: changed frontend workflow and fixed login reference matching for real 2026-09-25 22:31:44 +02:00
fsylvain f1e22f7073 fix: login match design reference 2026-09-25 21:28:12 +02:00
fsylvain 001f9f4774 feat: login page T0 2026-09-25 20:15:29 +02:00
fsylvain 12faab7f92 feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md 2026-09-25 17:33:40 +02:00
fsylvain eed661bb24 feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md 2026-09-25 17:09:34 +02:00
274 changed files with 36264 additions and 307 deletions

No files matched your search

+3 -2
View File
@@ -1,9 +1,10 @@
PUBLIC_URL=http://localhost:5173
JWT_SECRET=change-me-to-a-long-random-string
SMTP_EMAIL=a@b.c
SMTP_PASSWORD=12341234
SMTP_HOST=mail.service.abc
SMTP_PORT=465
SMTP_LOGIN=a@b.c
SMTP_PASSWORD=12341234
SMTP_SENDER=a@b.c
ENVIRONMENT=development
PORT=8080
+7
View File
@@ -66,6 +66,13 @@ tasks:
cmds:
- bun run vitest run
visual:check:
desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual/<screen>/
dir: web
deps: [web:install]
cmds:
- bun visual/check.ts {{.CLI_ARGS}}
lint:
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
cmds:
+22 -2
View File
@@ -3,6 +3,7 @@ package api
import (
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
@@ -11,6 +12,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"os"
"path/filepath"
@@ -33,7 +35,12 @@ type UseCases struct {
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
@@ -43,19 +50,26 @@ type Repositories struct {
ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
MailRepo domainMail.Repository
}
const sqliteBusyTimeoutMs = 5000
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
}
func getDb(dbName string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error())
}
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{})
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.User{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
panic("Failed to migrate database: " + err.Error())
}
@@ -67,6 +81,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
}
}
@@ -88,7 +103,12 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
+102
View File
@@ -0,0 +1,102 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool
}
type AcceptInviteRequest struct {
Code string `json:"code" validate:"required"`
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
}
type inviteEmailResponse struct {
Email string `json:"email"`
}
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{
Token: token,
GetInviteUseCase: getInvite,
AcceptInviteUseCase: acceptInvite,
IsProduction: isProduction,
}
}
func (h *AcceptInviteHandler) GetInvite(w http.ResponseWriter, r *http.Request) {
found, err := h.GetInviteUseCase.GetInvite(r.URL.Query().Get("c"))
if err != nil {
writeAcceptInviteError(w, err)
return
}
body, err := json.Marshal(inviteEmailResponse{Email: found.Email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
var request AcceptInviteRequest
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
}
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(useCases.AcceptInviteCommand{
Code: request.Code,
Username: request.Username,
Password: request.Password,
})
if err != nil {
writeAcceptInviteError(w, err)
return
}
if err := setJwtCookie(w, h.Token, h.IsProduction, createdUser); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
message, _ := json.Marshal(map[string]any{"message": "Invite accepted! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func writeAcceptInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrInviteInvalid):
http.Error(w, apperr.InviteInvalidErrorMessage, http.StatusNotFound)
case errors.Is(err, invite.ErrInviteExpired):
http.Error(w, apperr.InviteExpiredErrorMessage, http.StatusGone)
case errors.Is(err, user.ErrUsernameTaken):
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+8 -4
View File
@@ -52,22 +52,26 @@ func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
})
}
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
http.SetCookie(*w, &http.Cookie{
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Secure: h.IsProduction,
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
return nil
}
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
return setJwtCookie(*w, h.Token, h.IsProduction, u)
}
func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
+2 -1
View File
@@ -3,6 +3,7 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -37,7 +38,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return
}
body, err := json.Marshal(newPublicUser(loadedUser))
body, err := json.Marshal(publicuser.New(loadedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
+2 -22
View File
@@ -3,13 +3,13 @@ package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type VerificationHandler struct {
@@ -22,26 +22,6 @@ type VerifyCredentials struct {
Code string `json:"code" validate:"required"`
}
type PublicUser struct {
ID uint32 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func newPublicUser(u user.User) PublicUser {
return PublicUser{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsVerified: u.IsVerified,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func NewVerificationHandler(verify *useCases.VerifyUserUseCase, cancel *useCases.CancelRegistrationUseCase, isProduction bool) *VerificationHandler {
return &VerificationHandler{
VerifyUseCase: verify,
@@ -84,7 +64,7 @@ func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
return
}
body, err := json.Marshal(newPublicUser(verifiedUser))
body, err := json.Marshal(publicuser.New(verifiedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
+34 -2
View File
@@ -17,13 +17,15 @@ type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
GetUseCase *useCases.GetArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
DeleteUseCase *useCases.DeleteImageUseCase
}
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase, deleteUseCase *useCases.DeleteImageUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
UpdateUseCase: update,
DeleteUseCase: deleteUseCase,
}
}
@@ -78,7 +80,7 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
return
}
responseJson, err := json.Marshal(map[string]any{"location": newImage.Path})
responseJson, err := json.Marshal(map[string]any{"id": newImage.ID, "location": newImage.Path})
if err != nil {
writeInternalServerError(w)
return
@@ -87,3 +89,33 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(responseJson)
}
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
imageId, err := strconv.ParseUint(chi.URLParam(r, "imageId"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
writeArticleError(w, err)
return
}
if err := h.DeleteUseCase.DeleteImage(uint32(imageId)); err != nil {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
+136
View File
@@ -0,0 +1,136 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type InviteHandler struct {
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
}
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"`
}
type inviteResponse struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type inviteListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type userListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler {
return &InviteHandler{
CreateInviteUseCase: createInvite,
ListUsersAndInvitesUseCase: listUsersAndInvites,
}
}
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
var request CreateInviteRequest
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.CreateInviteUseCase.CreateInvite(useCases.CreateInviteCommand{
Email: request.Email,
InvitedBy: userId,
})
if err != nil {
writeCreateInviteError(w, err)
return
}
body, err := json.Marshal(inviteResponse{
ID: created.ID,
Kind: useCases.EntryKindInvite,
Email: created.Email,
Status: string(created.StatusAt(time.Now())),
CreatedAt: created.CreatedAt,
ExpiresAt: created.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 writeCreateInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Status, entry.CreatedAt})
}
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)
}
+70
View File
@@ -0,0 +1,70 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
UpdateProfileUseCase *useCases.UpdateUserProfileUseCase
}
type UpdateProfileRequest struct {
DisplayName *string `json:"display_name" validate:"required"`
}
func NewHandler(updateProfile *useCases.UpdateUserProfileUseCase) *Handler {
return &Handler{
UpdateProfileUseCase: updateProfile,
}
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
var request UpdateProfileRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(request)
if 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
}
updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(userId, *request.DisplayName)
if err != nil {
if errors.Is(err, user.ErrInvalidDisplayName) {
http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest)
return
}
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(publicuser.New(updatedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+8
View File
@@ -4,6 +4,8 @@ const LogsErrorMessage = "Access to the requested resource is forbidden due to i
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
const ArticleNotFoundErrorMessage = "Article not found"
const ImageNotFoundErrorMessage = "Image not found"
const DisplayNameErrorMessage = "Display name must be 60 characters or fewer."
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
@@ -14,3 +16,9 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const UsernameTakenErrorMessage = "That username is taken. Try another."
+28
View File
@@ -0,0 +1,28 @@
package publicuser
import (
"RenewCMS/internal/domain/user"
"time"
)
type PublicUser struct {
ID uint32 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"`
}
func New(u user.User) PublicUser {
return PublicUser{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsVerified: u.IsVerified,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName,
}
}
+14 -1
View File
@@ -1,9 +1,22 @@
package validator
import "github.com/go-playground/validator/v10"
import (
"regexp"
"github.com/go-playground/validator/v10"
)
const inviteEmailTag = "inviteemail"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
var Validate *validator.Validate
func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
}
func isInviteEmail(field validator.FieldLevel) bool {
return inviteEmailPattern.MatchString(field.Field().String())
}
+31 -1
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/image"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/internal/infrastructure/config"
"encoding/json"
@@ -33,7 +34,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase, container.DeleteImageUseCase)
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
@@ -49,6 +50,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage)
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
return r
}
@@ -60,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus)
@@ -75,6 +78,12 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
})
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Get("/invite", acceptInviteHandler.GetInvite)
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
@@ -86,6 +95,26 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
return r
}
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.ListUsersAndInvitesUseCase)
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.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
})
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter()
@@ -98,6 +127,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r
+2 -1
View File
@@ -32,7 +32,8 @@ var possibleEnvFileLocations = []string{".env", "../.env"}
var requiredEnvVars = []string{
"PUBLIC_URL",
"JWT_SECRET",
"SMTP_EMAIL",
"SMTP_LOGIN",
"SMTP_SENDER",
"SMTP_PASSWORD",
"SMTP_HOST",
"SMTP_PORT",
+7
View File
@@ -21,6 +21,13 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
Nothing under `internal/` imports `api/`.
### Delivery packages
| Package | Role |
|---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
## Frontend ↔ backend
```
+5 -4
View File
@@ -12,7 +12,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | |
| `SMTP_HOST` | SMTP server host. | `smtp.example.com` |
| `SMTP_PORT` | SMTP server port. | `465` |
@@ -26,9 +27,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login` or `POST /v1/auth/password-reset` within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the two routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for `POST /v1/auth/login` and `POST /v1/auth/password-reset` rate limiting. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
+2 -1
View File
@@ -47,7 +47,8 @@ PORT=8080
DB_FILE=/var/lib/renewcms/renewcms.db
UPLOAD_DIR=/var/lib/renewcms/uploads
JWT_SECRET=<openssl rand -hex 32>
SMTP_EMAIL=noreply@example.com
SMTP_LOGIN=noreply@example.com
SMTP_SENDER=noreply@example.com
SMTP_PASSWORD=...
SMTP_HOST=smtp.example.com
SMTP_PORT=465
+7 -3
View File
@@ -11,7 +11,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table |
| `screens/<screen>.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts |
| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are |
| `emails.md` | The two email templates and their variables |
| `emails.md` | The three email templates and their variables |
| `source/` | The raw Claude Design export. Open the `.dc.html` boards in a browser for the drawings (they load `support.js` locally and fonts from Google). |
## Screens
@@ -28,12 +28,16 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Home](screens/home.md) | `/home` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` |
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 19 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
| [Users](screens/users.md) | `/users` | 10 | `source/RenewCMS Screens Back-office.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
## Other boards
- `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 markdown here was generated from.
- `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".
- `source/RenewCMS Directions.dc.html`: the three brand directions (history only).
+78 -10
View File
@@ -6,7 +6,12 @@
|---|---|---|---|---|---|
| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes |
| DashboardShell | UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb | none (reads route.meta and the session store) | none | default, navbar-right | All verified routes |
| UserMenu | UDropdownMenu, UButton, UAvatar | none | logout | none | DashboardShell |
| UserMenu | UDropdownMenu, UButton, UAvatar (a bottom sheet below md) | displayName?, username, email | logout | none | DashboardShell |
| UsersTable | UTable | rows: UserRow[], loading | none | none | Users (md and up) |
| UserCardList | custom list | same as UsersTable | none | none | Users (below md) |
| InviteStatusBadge | UBadge | status: 'accepted'\|'pending'\|'expired' | none | none | Users |
| InviteUserModal | UModal, UForm, UFormField, UInput | none | invited(invite) | none | Users |
| ProfileCard | custom card (border, padding, optional footer strip) | title, description | none | default, footer | Profile |
| PageHeader | UPageHeader | title, description? | none | actions, meta | Home, Articles, Integration |
| ArticleStatusBadge | UBadge | online: boolean, size?: 'sm'\|'md', pending?: 'publish'\|'unpublish' | none | none | Home, Articles, Editor |
| ArticleTable | UTabs, UInput, UTable, UDropdownMenu, UPagination | articles: Article[], loading, busyIds: Set<number> | publish, unpublish, delete, copy-url | empty, toolbar-right | Articles (md and up) |
@@ -19,16 +24,16 @@
| SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor |
| PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor |
| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor |
| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register |
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset |
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Register (403) |
| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register, Accept invite |
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
Composables behind these components: `useSession`, `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown).
Composables behind these components: `useSession` (it also carries the display name), `useUsers` (the list and the invite action), `useProfile` (the display name draft and save), `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown).
## Product component specs
@@ -58,7 +63,7 @@ Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , t
### DashboardShell
The sidebar has three destinations, and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
The sidebar has four destinations (Home, Articles, Users, Integration), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
RenewCMS
@@ -92,7 +97,7 @@ Mobile top bar. The menu button opens the sidebar as a left slideover.
Built from UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default ( UNavigationMenu orientation="vertical" :collapsed ) and #footer (UserMenu) slots. Each page is a UDashboardPanel , with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb . On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over.
UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them.
UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. Superseded: see "UserMenu (changed)" under "Added with the Users and Profile screens".
Props, slots No props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb . Slots navbar-right (page actions on mobile) and default . It shows the offline banner when navigator.onLine is false.
@@ -252,7 +257,7 @@ Responsive Below lg , the details panel becomes a right slideover, opened with i
### ImageUploader
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes.
Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB
@@ -262,9 +267,21 @@ Drop to upload
dragging over
north-wall.jpg 62%
Crop and resize north-wall.jpg · 3.9 MB
Uploading 2.4 of 3.9 MB
crop, default: whole image selected, width "1600 px"
Crop and resize north-wall.jpg · 3.9 MB
crop, ratio locked to 16:9
Crop and resize north-wall.jpg · 3.9 MB
crop, crop narrower than the chosen width
north-wall.jpg 43%
Uploading 180 of 420 KB
uploading
@@ -282,6 +299,10 @@ server error
Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') .
Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it.
Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB".
Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
@@ -354,6 +375,53 @@ Built from UError isn't used because it's built for Nuxt's error.vue . Instead t
A11y The error title is the page h1 and gets focus on render. The banner is role="status" , and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip.
## Added with the Users and Profile screens
The Product components board was not regenerated for these. They are written from `source/RenewCMS Screens Back-office.dc.html`, `source/AppFrame.dc.html` and the Prototype. Their exact values come from AppFrame.
### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, Log out (log-out). The current page's item is highlighted with `--ui-bg-elevated`.
- **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.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- **Open question:** the shipped menu also offers System, Light and Dark. The board omits them. See [profile.md](screens/profile.md).
### DashboardShell (changed)
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile".
### UsersTable, UserCardList
Columns Username, Invite status, Created (sorted descending, with an arrow-down icon on the header). Grid `minmax(0,1fr) 160px 160px`, 36 px header on `--ui-bg-elevated`, 44 px rows with a top border. Below md the same rows become cards with a 68 px minimum height: the name line, then the badge and "Created {date}". There are no row actions.
A row's name cell shows the display name first (medium weight, highlighted), the username beside it in muted text, and a mono "you" tag on the current user's row. Invite rows show the email, regular weight, toned. The row of a just-sent invite has `--ui-bg-elevated`.
Skeleton rows appear after 150 ms of loading.
### InviteStatusBadge
A 22 px outline-less badge with a 12 px icon and the word, so status is never shown by color alone.
| Status | Icon | Fill and text |
|---|---|---|
| Accepted | circle-check | `--rc-success-soft`, `--ui-success` |
| Pending | mail | `--rc-warning-soft`, `--ui-warning` |
| Expired | clock-alert | transparent, `--ui-text-muted`, border `--ui-border-accented` |
### InviteUserModal
A UModal with one field, following the NewArticleModal pattern: title "Invite a user", the text "We'll email them a link to choose a username and password. The link expires after 3 days.", an Email field (placeholder "name@example.com", focused on open), Cancel and "Send invite". While sending, the field is disabled, the button reads "Sending…" and the dialog can't be dismissed. A field error shows under the input, a request failure shows an alert above the buttons, and the address always stays in the field. On success it closes and emits the new invite.
### ProfileCard
A bordered card with 20 px padding (16 px on mobile), a semibold 14 px title, a muted description, and its content. An optional footer strip sits below a top border on `--ui-bg-muted`, with the status text on the left and the action on the right. The Display name card uses the footer; Account and Password do not.
The Display name field uses CharacterCounter with a maximum of 60, counted in Unicode characters, in the mono style on the right of the help row. Read-only fields (username, email, password) are disabled inputs on `--ui-bg-elevated` with a lock icon at the right.
## Themed primitives
Extracted from `source/RenewCMS Components Primitives.dc.html`.
+4 -2
View File
@@ -5,10 +5,12 @@ Go `html/template` files, ready to replace `internal/infrastructure/mailer/templ
- `source/emails/verification.html` uses `{{.Host}}` and `{{.VerificationCode}}`, linking to `{{.Host}}/register/validate?c={{.VerificationCode}}`.
- `source/emails/password-reset.html` uses `{{.Host}}`, `{{.VerificationCode}}` and `{{.Email}}`, linking to `{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}`.
- `source/emails/invitation.html` uses `{{.Host}}`, `{{.VerificationCode}}`, `{{.Email}}` and `{{.InvitedBy}}`, linking to `{{.Host}}/invite/accept?c={{.VerificationCode}}`. `{{.Email}}` is the invited address, shown in the body only. `{{.InvitedBy}}` is the inviter's display name, or their username when they have none. The subject is "{{.InvitedBy}} invited you to RenewCMS". The invite expires in 3 days and works once.
Previews: `source/RenewCMS Emails.dc.html`.
## Transactional emails
Two Go html/template files: emails/verification.html and emails/password-reset.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
Suggested subjects are "Verify your RenewCMS account" and "Reset your RenewCMS password". html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
+3
View File
@@ -16,6 +16,9 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · POST /{id}/image · DELETE /{id} |
| `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `any` applies to redirects and the catch-all.
+114
View File
@@ -0,0 +1,114 @@
# Accept invite
- **Route:** `/invite/accept?c=CODE`
- **API:** GET /v1/auth/invite?c=CODE (pending) · POST /v1/auth/invite/accept (pending)
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Accept invite" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint, with no confirm field. On success the person is logged in and sent to /home with a toast. The account is already verified, because the invite went to their email, so it skips the pending and verify screens.
Neither endpoint exists yet:
- `GET /v1/auth/invite?c=CODE` returns `{ email }`, or 404 or 410.
- `POST /v1/auth/invite/accept` takes `{ code, username, password }`. It returns 200 with the session cookie, 409 for a taken username, or 404 or 410 as above.
Registration stays closed to everyone else: [register.md](register.md) "Registration closed" still applies to `/register`. The board does not draw the case of a logged-in person opening the link; it is not specified.
## States
### Checking the link
**Trigger:** On load, while the invite is looked up.
- Full-card spinner: "Checking your invite…"
### Default
**Trigger:** The code is valid and unused.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; counter "0 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: password with show/hide; checklist "At least 8 characters" (unmet)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Validation errors
**Trigger:** Checked on blur and on submit, using the same rules as Register. Nothing is sent.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "ma"; counter "2 / 20" in error; **error "Use 3 to 20 characters."**
- Field `Password`: value "lime"; password with show/hide; checklist "At least 8 characters" (err)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Filled, valid
**Trigger:** Every rule is met and the checklist item turns green.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; password shown; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Submitting
**Trigger:** Fields and button are disabled.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Creating account…", loading
- Footer: Already have an account? [Log in]
### Username taken (409)
**Trigger:** Another user has that username. The value is kept and the field gets focus.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "marta"; counter "5 / 20"; focused; **error "That username is taken. Try another."**
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Invalid or used link (404)
**Trigger:** The code is wrong, was already accepted, or is missing from the URL. No form is shown.
- ResultPanel (error, link-2-off): **This invite link doesn't work**; text "It may be incomplete, or it may already have been used. If you already set up your account, log in instead."; note "Open the link from your email again, or copy the full address into your browser."; actions "Go to log in" (primary)
### Expired link (410)
**Trigger:** The invite is older than 3 days.
- ResultPanel (error, clock): **This invite has expired**; text "Invites work once, for 3 days. Ask the person who invited you to send a new one."; actions "Go to log in" (secondary)
### Server error
**Trigger:** A 500 or no response. The values are kept.
- Alert (error, circle-x): **Couldn't create the account** "Something went wrong on the server. Try again in a moment."
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Success
**Trigger:** 200 sets the session cookie → /home after 800 ms, with a toast. No verification step.
- ResultPanel (success, circle-check): **Your account is ready**; text "Taking you to your dashboard…"
- Toast (circle-check): **Welcome to RenewCMS** "You're logged in as lea."
+39 -2
View File
@@ -83,13 +83,50 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop and resize
**Trigger:** A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "Original", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop to a ratio
**Trigger:** 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "16:9", width "1600 px"
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: crop narrower than the width
**Trigger:** The crop is smaller than the chosen width, so it keeps its own size.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "1:1", width "1600 px"
- note: "The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
- cancel "Choose another file"
- confirm "Upload image"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Upload: in progress
**Trigger:** A valid file is uploading. Closing the dialog cancels the upload.
**Trigger:** Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Add an image**
- upload state: state "uploading"
- upload state: state "uploading" ("Uploading 180 of 420 KB")
- cancel "Cancel upload"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
+1 -1
View File
@@ -4,7 +4,7 @@
- **API:** GET /v1/article/
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Home" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor.
The greeting reads "Good morning, {name}", where the name is the display name when the user has saved one and the username otherwise. The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor.
## States
+177
View File
@@ -0,0 +1,177 @@
# Profile
- **Route:** `/profile`
- **API:** GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Profile" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters.
Once saved it replaces the username everywhere a person is named:
- the sidebar chip and the account menu (name, and the avatar initials)
- the Home greeting ("Good morning, Marta Alves")
- the Users list, with the username beside it
- the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS"
Saving it empty goes back to the username. Logging in still uses the username.
The reset link uses the same flow as "Forgot password" and ends on the auth board's "Choose a new password" screen ([reset-password.md](reset-password.md)).
## The page
Three cards under the title "Profile" and the subtitle "Your account on this RenewCMS workspace.", 720 px wide at most.
| Card | Content |
|---|---|
| Display name | Title "Display name". Text "Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username." One input, label "Display name (optional)", placeholder "e.g. Marta Alves", with a live counter "n / 60" in mono on the right of the help row. A footer strip holds a status line on the left and the Save button on the right. |
| Account | Title "Account". Text "Set when your account was created. You log in with your username, even after adding a display name." Two disabled inputs with a lock icon: Username and Email (two columns on desktop, one on mobile). |
| Password | Title "Password". Text "To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour." A disabled input showing "••••••••••••" beside the button "Send reset link" (mail icon). A note appears under them once a request has been made. |
**Footer status line** (12 px): "Unsaved changes" in the warning color while dirty and not saving. Otherwise "You appear as “Marta Alves”." when a display name is saved, or "You appear as “marta”, your username." when it isn't.
**Save button:** full opacity when the value changed and is valid, or while saving. 40 % opacity and inert otherwise. Enter in the field saves.
**Counting:** characters are counted as Unicode characters, not bytes or UTF-16 units. The counter turns red over 60.
**Leaving the page** with unsaved changes asks for confirmation.
**Mobile:** cards use 16 px padding, inputs are 44 px, the two account inputs stack, and the password input and its button stack.
## The account menu
Opened from the account chip at the bottom of the sidebar (chevrons-up-down icon; `aria-haspopup="menu"`, `aria-expanded`). The chip takes the sand fill with ink text while the menu is open, and while `/profile` is the current page.
Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the account row in the navigation drawer opens it as a bottom sheet with a grab handle. Escape or a click outside closes both.
| Part | Content |
|---|---|
| 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` |
| Separator | |
| 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.
The board's menu has no System, Light or Dark items, while the shipped UserMenu has them. This is an open question, see the notes below.
## States
### Account menu
**Trigger:** The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.
- Breadcrumbs: Home (mobile title "Home")
- Page behind the menu: Home, default
- Menu open, as described above
### Loading
**Trigger:** Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away.
- Breadcrumbs: Profile (mobile title "Profile")
- Three skeleton cards (one input, two inputs, one input)
### Default, no display name
**Trigger:** The usual first visit. The username is used everywhere.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: empty, placeholder "e.g. Marta Alves", counter "0 / 60"
- Footer: "You appear as “marta”, your username."
- Save: 40 % opacity
### Editing
**Trigger:** Typing in the field. Save turns on, and leaving the page asks for confirmation.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves"; focused; counter "11 / 60"
- Footer: "Unsaved changes" (warning)
- Save: enabled
### Too long
**Trigger:** Over 60 characters. Checked as you type, and Save stays off.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves, Head of Renovations, Brussels and Wallonia office"; focused; counter red
- **Error "Use 60 characters or fewer."**
- Footer: "Unsaved changes" (warning)
- Save: 40 % opacity
### Saving
**Trigger:** Save, or Enter in the field. The field is disabled.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves", disabled, dimmed
- Save: "Saving…" with a loader
### Saved
**Trigger:** 200: the sidebar and account menu switch to the display name at once, and a toast confirms.
- Breadcrumbs: Profile (mobile title "Profile")
- Sidebar chip: "Marta Alves", initials MA
- Display name: "Marta Alves"; counter "11 / 60"
- Footer: "You appear as “Marta Alves”."
- Toast (success, circle-check): **Display name saved** "You now appear as Marta Alves across RenewCMS and in emails."
### Save failed
**Trigger:** 500 or network failure. The typed value is kept.
- Breadcrumbs: Profile (mobile title "Profile")
- Alert (error) in the Display name card: "Couldn't save your display name. Your text is still here. Try again in a moment."
- Display name: "Marta Alves"
- Footer: "Unsaved changes" (warning)
- Save: enabled
### Reset: sending
**Trigger:** "Send reset link". No confirmation, since it only sends an email.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Sending…" with a loader
- Sidebar chip shows the display name "Marta Alves"
### Reset: sent
**Trigger:** 200. The message stays until the page is left. "Send again" sends a new link and the old one stops working.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send again" (mail)
- Note (success, mail-check): **Reset link sent** "Check marta@renew.example. The link works once, within 1 hour."
### Reset: too many requests (429)
**Trigger:** Rate limited. The button is disabled for 5 minutes.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send reset link", 40 % opacity, disabled
- Note (warning, clock-alert): **Too many requests** "You've asked for several links recently. Wait a few minutes, then try again."
### Reset: failed
**Trigger:** 500 or network failure. The button can be pressed again.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send reset link"
- Note (error, circle-x): **Couldn't send the link** "Check your connection and try again."
### Display names in the Users list
**Trigger:** Accounts with a display name show it first, with the username beside it in grey. Accounts without one show the username. This state needs the Users page ([users.md](users.md)).
- Breadcrumbs: Users (mobile title "Users")
- Signed-in user's display name: "Marta Alves" (sidebar chip shows it too)
- Row `marta` (you): "Marta Alves", `marta` beside it in muted text, "you" tag, Accepted
- Row `ines`: "Inès Duarte", `ines` beside it in muted text, Accepted
- Row `jonas`: `jonas` alone, Accepted
## Notes for implementation
- `PATCH /v1/user/me` is marked pending by the design: it does not exist yet. `GET /v1/auth/me` has to start returning the display name.
- The Password card sends the reset email to the account's own address through the existing `POST /v1/auth/password-reset`. The link ends on the reset confirm screen, and a successful reset invalidates the current session like any other reset.
- "Disabled for 5 minutes" is the design's number. The real window comes from the `Retry-After` header, as on the login and reset screens.
- Open question: the shipped UserMenu offers System, Light and Dark, and the redesigned menu does not. Decide whether the theme choice stays in the menu, moves to the Profile page, or is dropped, before building the menu.
+124
View File
@@ -0,0 +1,124 @@
# Users
- **Route:** `/users`
- **API:** GET /v1/user/ · POST /v1/user/invite
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Users" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
One shared workspace: every user sees the same articles and this same list. Rows have no actions yet, so nobody can be removed, promoted or have an invite revoked from here. Pending and expired invites show the email address, because a username only exists once the invite is accepted.
An invite is **Pending** until its link is used, **Expired** after 3 days, and **Accepted** once the account is created. The list is sorted by created date, newest first.
## The list
| Column | Content |
|---|---|
| Username | An accepted user: the display name if there is one (medium weight, highlighted) with the username beside it in muted text; otherwise the username alone. A pending or expired invite: the email address, regular weight, toned. The current user's row carries a muted mono "you" tag. |
| Invite status | A badge: Accepted (circle-check, success), Pending (mail, warning), Expired (clock-alert, outline, muted). Shown by icon and word as well as color. |
| Created | The date as `27 Sep 2026`, or "Today" for the current day. For an invite it is the day the invite was sent, for a user the day the account was created. |
Desktop is a table: 36 px header, 44 px rows, columns `minmax(0,1fr) 160px 160px`. Below md it becomes a card list (68 px minimum row height): the name line, then the badge and "Created {date}". The page header is a title "Users" with the subtitle "Everyone who can sign in and work on your articles." and a primary "Invite user" button (user-plus icon) on its right. On mobile the button becomes a full-width 48 px button pinned to the bottom of the screen.
The admin who registered first is always listed, so the list is never empty.
## States
### Default
**Trigger:** The list loaded, newest first.
- Breadcrumbs: Users (mobile title "Users")
- Rows: `sam.oliveira@renew.example` Pending, 27 Sep 2026 · `p.huber@gmail.com` Expired, 3 Aug 2026 · `ines` (display name "Inès Duarte") Accepted, 18 Jun 2026 · `jonas` Accepted, 2 Apr 2026 · `marta` (you) Accepted, 12 Mar 2026
### Loading
**Trigger:** The first load, or a retry. Skeletons appear after 150 ms.
- Breadcrumbs: Users (mobile title "Users")
- Five skeleton rows
### Only you
**Trigger:** Nobody has been invited yet. The admin who registered is always listed.
- Breadcrumbs: Users (mobile title "Users")
- One row: `marta` (you) Accepted
- Hint under the list: "Invite someone so they can sign in with their own account instead of sharing yours."
### Error
**Trigger:** A 500 or network failure on the list request.
- Breadcrumbs: Users (mobile title "Users")
- Alert (error): **Couldn't load users** "Check your connection and try again."
- Action "Try again" (rotate-cw)
- No table or cards
### Invite: default
**Trigger:** "Invite user" in the page header, or the bottom button on mobile.
- Breadcrumbs: Users (mobile title "Users")
- Modal: **Invite a user**
- text "We'll email them a link to choose a username and password. The link expires after 3 days."
- field `Email`: placeholder "name@example.com"; focused
- close button
- cancel "Cancel"
- confirm "Send invite"
### Invite: validation
**Trigger:** On submit or blur. Nothing is sent.
- Breadcrumbs: Users (mobile title "Users")
- Modal: **Invite a user** (same text)
- field `Email`: value "sam.oliveira@renew"; **error "Enter a full email address, like name@example.com."**
- cancel "Cancel"
- confirm "Send invite"
### Invite: sending
**Trigger:** Request in flight. The field is disabled and the dialog can't be dismissed.
- Breadcrumbs: Users (mobile title "Users")
- Modal: **Invite a user** (same text)
- field `Email`: value "lea.martin@renew.example"; disabled
- cancel "Cancel"
- confirm "Sending…", loading, not dismissible
### Invite: already a user (409)
**Trigger:** The email belongs to an existing user. For a live pending invite the message reads "There's already a pending invite for this address." An expired invite can be sent again.
- Breadcrumbs: Users (mobile title "Users")
- Modal: **Invite a user** (same text)
- field `Email`: value "jonas@renew.example"; **error "This address already has an account."**
- cancel "Cancel"
- confirm "Send invite"
### Invite: failed
**Trigger:** 500, network failure, or the email couldn't be sent. The address stays in the field.
- Breadcrumbs: Users (mobile title "Users")
- Modal: **Invite a user** (same text)
- field `Email`: value "lea.martin@renew.example"
- Alert: "Couldn't send the invite. Try again in a moment."
- cancel "Cancel"
- confirm "Send invite"
### Invite sent
**Trigger:** 201: the dialog closes, the new invite appears at the top as Pending (highlighted row), and a toast confirms.
- Breadcrumbs: Users (mobile title "Users")
- New first row: `lea.martin@renew.example` Pending, created "Today", row background `--ui-bg-elevated`
- Toast (success, circle-check): **Invite sent** "lea.martin@renew.example has 3 days to accept."
The "Today" label makes this state clock-sensitive, so `task visual:check` skips it.
## Notes for implementation
- An accepted user is shown by username and display name only, never by email.
- How display names look in this list is the state "Display names in the Users list" of [profile.md](profile.md). The board draws it in the Profile group, and `task visual:check` finds states by group.
- 429 on the invite request is not drawn. Decide it in the spec before coding.
- The email the invite sends is specified in [emails.md](../emails.md). The screen the link opens is in [accept-invite.md](accept-invite.md).
+145 -8
View File
@@ -16,8 +16,9 @@
</sc-for>
<div style="flex:1"></div>
<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="logout" title="Log out" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">MA</span><span style="flex:1;color:var(--ui-text-highlighted)">marta</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>
<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 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>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
@@ -41,7 +42,7 @@
<sc-if value="{{ isHome }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:{{ gap }};max-width:1040px">
<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="font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Good morning, marta</span><span style="color:var(--ui-text-muted);font-size:14px">{{ s.home.sub }}</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
<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="font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Good morning, {{ who }}</span><span style="color:var(--ui-text-muted);font-size:14px">{{ s.home.sub }}</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
<sc-if value="{{ s.home.error }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load your articles</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span style="align-self:flex-start;height:28px;padding:0 10px;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;font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ s.home.stats }}">
<div style="display:grid;grid-template-columns:{{ cols3 }};gap:12px">
@@ -128,7 +129,7 @@
<span data-act="preview" style="display:flex;align-items:center;gap:8px;white-space:nowrap;flex-shrink:0;color:var(--ui-text-toned)"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ pvJust }};background:{{ pvBg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span>Preview</span>
<span data-act="save" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ saveOp }}"><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/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
<span data-act="publish" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat"></span>{{ pub.t }}</span>
<span data-act="overflow" style="width:32px;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
</sc-if>
</sc-if>
</div>
@@ -195,6 +196,66 @@
</div>
</sc-if>
<sc-if value="{{ isUsers }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
<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:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Users</span><span style="color:var(--ui-text-muted)">Everyone who can sign in and work on your articles.</span></div><sc-if value="{{ desk }}"><span data-act="invite" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat"></span>Invite user</span></sc-if></div>
<sc-if value="{{ usError }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load users</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span style="align-self:flex-start;height:{{ btnH }};padding:0 10px;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;font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat"></span>Try again</span></div></div></sc-if>
<sc-if value="{{ usTable }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:minmax(0,1fr) 160px 160px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Username</span><span>Invite status</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Created<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span></div>
<sc-for list="{{ usRows }}" as="r" hint-placeholder-count="5">
<div style="display:grid;grid-template-columns:minmax(0,1fr) 160px 160px;height:44px;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:30%;background:var(--ui-bg-accented)"></span><span style="width:76px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.name }}"><span style="display:flex;align-items:center;gap:8px;min-width:0;padding-right:12px"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:{{ r.fw }};color:{{ r.fg }}">{{ r.name }}</span><sc-if value="{{ r.sub }}"><span style="flex-shrink:0;color:var(--ui-text-muted)">{{ r.sub }}</span></sc-if><sc-if value="{{ r.you }}"><span style="flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">you</span></sc-if></span><span><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:{{ r.b.bg }};color:{{ r.b.fg }};border:1px solid {{ r.b.bd }};font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat"></span>{{ r.b.t }}</span></span><span style="color:var(--ui-text-muted)">{{ r.c }}</span></sc-if>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ usCards }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
<sc-for list="{{ usRows }}" as="r" hint-placeholder-count="5">
<div style="display:flex;flex-direction:column;justify-content:center;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;background:{{ r.bg }}">
<sc-if value="{{ r.skel }}"><span style="width:55%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></sc-if>
<sc-if value="{{ r.name }}"><span style="display:flex;align-items:center;gap:8px;min-width:0"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px;line-height:21px;font-weight:{{ r.fw }};color:{{ r.fg }}">{{ r.name }}</span><sc-if value="{{ r.sub }}"><span style="flex-shrink:0;color:var(--ui-text-muted)">{{ r.sub }}</span></sc-if><sc-if value="{{ r.you }}"><span style="flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">you</span></sc-if></span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:{{ r.b.bg }};color:{{ r.b.fg }};border:1px solid {{ r.b.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ r.b.icon }}.svg') center / contain no-repeat"></span>{{ r.b.t }}</span><span>Created {{ r.c }}</span></div></sc-if>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ usOnly }}"><span style="color:var(--ui-text-muted);text-wrap:pretty">Invite someone so they can sign in with their own account instead of sharing yours.</span></sc-if>
</div>
<sc-if value="{{ mob }}"><sc-if value="{{ listFab }}"><div data-act="invite" style="position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)"><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/user-plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/user-plus.svg') center / contain no-repeat"></span>Invite user</div></sc-if></sc-if>
</sc-if>
<sc-if value="{{ isProfile }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:20px;max-width:720px;overflow:hidden">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Profile</span><span style="color:var(--ui-text-muted)">Your account on this RenewCMS workspace.</span></div>
<sc-if value="{{ pfLoading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:120px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:90px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><span style="width:100px;height:14px;background:var(--ui-bg-accented)"></span><span style="height:40px;background:var(--ui-bg-accented)"></span></div></div></sc-if>
<sc-if value="{{ pfReady }}">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Display name</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.</span></div>
<sc-if value="{{ pf.notice }}"><div role="alert" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned)">{{ pf.notice }}</span></div></sc-if>
<div style="display:flex;flex-direction:column;gap:6px">
<label style="font-weight:500;color:var(--ui-text-highlighted)">Display name <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></label>
<div style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;border:1px solid {{ dn.bd }};box-shadow:{{ dn.sh }};background:{{ dn.bg }};color:{{ dn.fg }};font-size:{{ inF }};overflow:hidden;white-space:nowrap"><span style="overflow:hidden;text-overflow:ellipsis">{{ dn.shown }}</span><sc-if value="{{ dn.caret }}"><span style="flex-shrink:0;display:inline-block;width:1px;height:18px;background:var(--ui-text-highlighted);margin-left:1px"></span></sc-if></div>
<div style="display:flex;gap:12px;align-items:flex-start"><span style="flex:1;display:flex;gap:6px;align-items:center;color:var(--ui-error);font-size:12px"><sc-if value="{{ dn.err }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-alert.svg') center / contain no-repeat"></span>{{ dn.err }}</sc-if></span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ dn.countFg }}">{{ dn.count }}</span></div>
</div>
</div>
<div style="padding:12px {{ cardPadX }};border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);display:flex;align-items:center;gap:12px"><span style="flex:1;min-width:0;color:{{ dn.footFg }};font-size:12px;line-height:18px">{{ dn.foot }}</span><span data-act="pf-save" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;flex-shrink:0;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ dnSave.op }}"><sc-if value="{{ dnSave.loading }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span></sc-if>{{ dnSave.t }}</span></div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-dimmed)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Email</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta@renew.example</span><span style="display:flex;color:var(--ui-text-dimmed)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div>
</div>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Password</span><span style="color:var(--ui-text-muted);text-wrap:pretty">To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.</span></div>
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-dimmed)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
<sc-if value="{{ rsNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:{{ rsNote.bg }}"><span style="display:flex;padding-top:2px;color:{{ rsNote.fg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsNote.icon }}.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:{{ rsNote.fg }}">{{ rsNote.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ rsNote.text }}</span></div></div></sc-if>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ isError }}">
<div style="padding:{{ errPad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.err.code }}</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.err.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ s.err.text }}</span><div style="display:flex;gap:8px;flex-wrap:wrap"><span data-act="err-primary" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">{{ s.err.primary }}</span><sc-if value="{{ s.err.secondary }}"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ s.err.secondary }}</span></sc-if></div></div>
</sc-if>
@@ -202,6 +263,8 @@
</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><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.modal }}">
<div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ modalAlign }};justify-content:center;padding:{{ modalPad }}">
<div role="dialog" aria-modal="true" style="width:{{ modalW }};background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column">
@@ -214,6 +277,41 @@
<sc-if value="{{ s.modal.upload }}">
<div data-act="upload-pick" style="height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;border:{{ up.bd }};background:{{ up.bg }};color:{{ up.fg }}"><span aria-hidden="true" style="display:inline-block;width:24px;height:24px;background-color:{{ up.ic }};-webkit-mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat"></span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ up.title }}</span><sc-if value="{{ up.progress }}"><div style="width:80%;height:4px;background:var(--ui-bg-accented)"><div style="width:{{ up.progress }};height:4px;background:var(--ui-primary)"></div></div></sc-if><span style="color:var(--ui-text-toned);font-size:12px;max-width:300px">{{ up.text }}</span></div>
</sc-if>
<sc-if value="{{ s.modal.crop }}">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="height:{{ cr.stageH }};padding:16px;background:var(--ui-bg-elevated);display:flex;align-items:center;justify-content:center">
<div role="img" aria-label="north-wall.jpg, 4032 × 3024 px" style="position:relative;height:100%;max-width:100%;aspect-ratio:4/3;overflow:hidden;background:#d8d0c3">
<div style="position:absolute;left:0;right:0;bottom:0;height:32%;background:#8f8373"></div>
<div style="position:absolute;left:56%;top:14%;width:22%;height:42%;background:#f1ece3;border:6px solid #c4b9a8"></div>
<div tabindex="0" aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it." style="position:absolute;left:{{ cr.l }};top:{{ cr.t }};width:{{ cr.w }};height:{{ cr.h }};box-shadow:0 0 0 2000px rgb(10 10 10 / 0.55);outline:1px solid #ffffff;cursor:move">
<div style="position:absolute;left:33.333%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:66.666%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:33.333%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;top:66.666%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
<div style="position:absolute;left:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;right:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;left:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<div style="position:absolute;right:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
<span style="position:absolute;left:8px;bottom:8px;padding:1px 6px;background:rgb(17 17 17 / 0.8);color:#ffffff;font:400 11px/18px 'IBM Plex Mono',monospace;white-space:nowrap">{{ cr.dims }}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:4px"><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">Aspect ratio</span><span data-act="crop-rotate" style="height:{{ btnH }};padding:0 8px;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:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat"></span>Rotate</span><span data-act="crop-reset" style="height:{{ btnH }};padding:0 8px;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:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat"></span>Reset</span></div>
<div role="radiogroup" aria-label="Aspect ratio" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.ratios }}" as="p" hint-placeholder-count="5"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Width</span>
<div role="radiogroup" aria-label="Width" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
<sc-for list="{{ cr.sizes }}" as="p" hint-placeholder-count="4"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
</div>
<span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ cr.out }}</span>
</div>
<sc-if value="{{ cr.note }}"><div role="status" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft)"><span style="display:flex;padding-top:2px;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="color:var(--ui-text-toned);text-wrap:pretty">{{ cr.note }}</span></div></sc-if>
</div>
</sc-if>
</div>
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
@@ -238,7 +336,7 @@ class Component extends DCLogic {
const s = this.props.s || {}
const m = !!this.props.mobile
const k = s.kind || 'home'
const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['integration', 'plug', 'Integration']]
const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration']]
const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 500 : 400 }))
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {}
@@ -264,12 +362,46 @@ class Component extends DCLogic {
const mFields = (md.fields || []).map(f => { const has = f.value; return { ...f, shown: has || f.ph || '', fg: md.loading ? 'var(--ui-text-dimmed)' : has ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: f.err ? 'var(--ui-error)' : f.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: f.err ? 'inset 0 0 0 1px var(--ui-error)' : f.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: md.loading ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', h: f.area ? (m ? '120px' : '96px') : (m ? '44px' : '40px'), p: f.area ? '8px 10px' : '0 10px', ai: f.area ? 'flex-start' : 'center', countFg: f.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)' } })
const U = md.upload || {}
const upMap = {
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 2.4 of 3.9 MB', progress: '62%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 180 of 420 KB', progress: '43%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
rejected: { icon: 'file-x', ic: 'var(--ui-error)', title: 'north-wall.tif is 14.2 MB', text: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' },
idle: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'Drop an image here, or choose a file', text: 'PNG, JPEG, WEBP, SVG · max 10 MB', bd: '1px dashed var(--ui-border-accented)', bg: 'var(--ui-bg)' },
error: { icon: 'circle-x', ic: 'var(--ui-error)', title: 'Upload failed', text: 'The server didn\'t accept the file. Your article hasn\'t changed.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' }
}
const up = upMap[U.state || 'idle'] || upMap.idle
let cr = {}
if (md.crop) {
const C = md.crop, IW = 4032, IH = 3024
const ri = C.ratio || 0, si = C.size ?? 1
const presets = [[0, 0, 100, 100], [6, 8, 88, 78], [0, 12.5, 100, 75], [0, 0, 100, 100], [12.5, 0, 75, 100]]
const r = C.rect || presets[ri]
const cw = Math.round(IW * r[2] / 100), ch = Math.round(IH * r[3] / 100)
const target = [cw, 1600, 1200, 800][si]
const ow = Math.min(target, cw), oh = Math.round(ch * ow / cw)
const kb = Math.round(ow * oh * 0.22 / 1000)
const pill = (pre, cur) => (t, i) => ({ t, on: i === cur, act: pre + i, bg: i === cur ? 'var(--ui-primary)' : 'transparent', fg: i === cur ? 'var(--ui-bg)' : 'var(--ui-text-toned)' })
cr = {
l: r[0] + '%', t: r[1] + '%', w: r[2] + '%', h: r[3] + '%', dims: fmt(cw) + ' × ' + fmt(ch),
ratios: ['Original', 'Free', '16:9', '4:3', '1:1'].map(pill('crop-r-', ri)),
sizes: ['Original', '1600 px', '1200 px', '800 px'].map(pill('crop-s-', si)),
out: 'Uploads as ' + fmt(ow) + ' × ' + fmt(oh) + ' px · about ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' MB' : kb + ' KB'),
note: target > cw ? 'The crop is ' + cw + ' px wide, so it\'s uploaded at that size. Enlarging it would make it blurry.' : '',
stageH: m ? '240px' : '340px', pillFlex: m ? '1 1 0' : '0 0 auto', groupW: m ? '100%' : 'max-content'
}
}
const US = s.users || {}
const badges = { accepted: { t: 'Accepted', icon: 'circle-check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'var(--rc-success-soft)' }, pending: { t: 'Pending', icon: 'mail', bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', bd: 'var(--rc-warning-soft)' }, expired: { t: 'Expired', icon: 'clock-alert', bg: 'transparent', fg: 'var(--ui-text-muted)', bd: 'var(--ui-border-accented)' } }
const usRows = (US.loading ? Array.from({ length: 5 }, () => ({ skel: true })) : (US.rows || [])).map(r => r.skel ? r : ({ ...r, name: (r.you && s.display) || r.display || r.u || r.email, sub: r.u && ((r.you && s.display) || r.display) ? r.u : '', fw: r.u ? 500 : 400, fg: r.u ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', b: badges[r.st] || badges.pending, bg: r.hl ? 'var(--ui-bg-elevated)' : 'transparent' }))
const who = s.display || 'marta'
const ini = s.display ? s.display.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase() : 'MA'
const pf = s.pf || {}
const dnVal = pf.value ?? s.display ?? ''
const dnLen = [...dnVal].length
const dn = { shown: dnVal || (pf.focus ? '' : 'e.g. Marta Alves'), caret: !!pf.focus, fg: pf.saving ? 'var(--ui-text-dimmed)' : dnVal ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: pf.err ? 'var(--ui-error)' : pf.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: pf.err ? 'inset 0 0 0 1px var(--ui-error)' : pf.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: pf.saving ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', count: dnLen + ' / 60', countFg: dnLen > 60 ? 'var(--ui-error)' : 'var(--ui-text-muted)', err: pf.err || '', foot: pf.dirty && !pf.saving ? 'Unsaved changes' : 'You appear as \u201c' + who + '\u201d' + (s.display ? '.' : ', your username.'), footFg: pf.dirty && !pf.saving ? 'var(--ui-warning)' : 'var(--ui-text-muted)' }
const dnSave = { t: pf.saving ? 'Saving…' : 'Save', loading: !!pf.saving, op: pf.saving || (pf.dirty && !pf.err) ? '1' : '0.4' }
const rs = pf.reset || 'idle'
const rsMap = { sent: { bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', icon: 'mail-check', title: 'Reset link sent', text: 'Check marta@renew.example. The link works once, within 1 hour.' }, limited: { bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', icon: 'clock-alert', title: 'Too many requests', text: 'You\'ve asked for several links recently. Wait a few minutes, then try again.' }, error: { bg: 'var(--rc-error-soft)', fg: 'var(--ui-error)', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' } }
const rsNote = rsMap[rs] || null
const rsBtn = { t: rs === 'sending' ? 'Sending…' : rs === 'sent' ? 'Send again' : 'Send reset link', icon: rs === 'sending' ? 'loader-circle' : 'mail', op: rs === 'limited' ? '0.4' : '1' }
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
const endpoints = [
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
@@ -281,11 +413,16 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation'
return {
s, nav, crumbs, edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
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)',
isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
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,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu',
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error',
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', isUsers: k === 'users',
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
pad: m ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
tbDir: m ? 'column' : 'row', tbGap: m ? '12px' : '16px', tbBorder: m ? '0' : '1px solid var(--ui-border)', tabBorder: m ? '1px solid var(--ui-border)' : '0', tabH: m ? '44px' : '36px', tabF: m ? '14px' : '13px',
@@ -302,7 +439,7 @@ class Component extends DCLogic {
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
errPad: m ? '40px 20px' : '64px 48px',
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : '140px 16px 16px', modalW: m ? '100%' : (md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
}
@@ -216,7 +216,7 @@
<span aria-live="polite" style="display:flex;align-items:center;gap:6px;color:var(--ui-warning)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat"></span>Unsaved changes</span>
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Save and publish</span>
<span style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
<span role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-error);color:var(--ui-error);font-weight:500;cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
</div>
<div role="toolbar" aria-label="Formatting" style="padding:6px 16px;display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);overflow-x:auto;background:var(--ui-bg)">
<sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="24">
+7 -5
View File
@@ -16,8 +16,8 @@
<div data-screen-label="Emails" style="padding:64px;display:flex;flex-direction:column;gap:56px;width:max-content;min-width:100%">
<div style="display:flex;flex-direction:column;gap:10px;max-width:900px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:#111">Transactional emails</h1>
<p style="margin:0;font-size:16px;line-height:26px;color:#444;text-wrap:pretty">Two Go <code>html/template</code> files: <a href="emails/verification.html">emails/verification.html</a> and <a href="emails/password-reset.html">emails/password-reset.html</a>. They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and <code>prefers-color-scheme</code> overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.</p>
<p style="margin:0;font-size:14px;line-height:22px;color:#666;text-wrap:pretty">Suggested subjects are "Verify your RenewCMS account" and "Reset your RenewCMS password". <code>html/template</code> escapes the <code>.Email</code> value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.</p>
<p style="margin:0;font-size:16px;line-height:26px;color:#444;text-wrap:pretty">Three Go <code>html/template</code> files: <a href="emails/verification.html">emails/verification.html</a>, <a href="emails/password-reset.html">emails/password-reset.html</a> and <a href="emails/invitation.html">emails/invitation.html</a>. They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and <code>prefers-color-scheme</code> overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.</p>
<p style="margin:0;font-size:14px;line-height:22px;color:#666;text-wrap:pretty">Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "&lt;username&gt; invited you to RenewCMS", using <code>.InvitedBy</code>. <code>html/template</code> escapes the <code>.Email</code> value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.</p>
</div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="2">
<section style="display:flex;flex-direction:column;gap:20px">
@@ -33,13 +33,14 @@
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { v: '', r: '' }
state = { v: '', r: '', i: '' }
componentDidMount() {
fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v }))
fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r }))
fetch('emails/invitation.html').then(x => x.text()).then(i => this.setState({ i }))
}
fill(t) {
return t.split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example')
return t.split('{{.InvitedBy}}').join('marta').split('{{.Email}} It').join('lea.martin@renew.example. It').split('sent to {{.Email}}').join('sent to lea.martin@renew.example').split('{{.Host}}').join('https://cms.renew.example').split('{{.VerificationCode}}').join('7f3c9a2e4b1d').split('{{.Email}}').join('marta%40renew.example').replace('uses marta%40renew.example', 'uses marta@renew.example')
}
frames(t) {
if (!t) return []
@@ -50,7 +51,8 @@ class Component extends DCLogic {
renderVals() {
return { rows: [
{ title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) },
{ title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) }
{ title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) },
{ title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) }
] }
}
}
+12 -5
View File
@@ -79,13 +79,14 @@ class Component extends DCLogic {
{ t: 'Foundations', d: 'Tokens, contrast, type, motion, icons', href: 'RenewCMS Foundations.dc.html' },
{ t: 'Primitives', d: 'Themed Nuxt UI components', href: 'RenewCMS Components Primitives.dc.html' },
{ t: 'Product components', d: '17 RenewCMS components', href: 'RenewCMS Components Product.dc.html' },
{ t: 'Auth screens', d: 'Six public routes, every state', href: 'RenewCMS Screens Auth.dc.html' },
{ t: 'Auth screens', d: 'Seven public routes, every state', href: 'RenewCMS Screens Auth.dc.html' },
{ t: 'Back-office screens', d: 'Verified routes and global states', href: 'RenewCMS Screens Back-office.dc.html' },
{ t: 'Emails', d: 'Previews of both templates', href: 'RenewCMS Emails.dc.html' },
{ t: 'Emails', d: 'Previews of the three templates', href: 'RenewCMS Emails.dc.html' },
{ t: 'tokens/renewcms.css', d: '@theme, :root, .dark, .article-content', href: 'tokens/renewcms.css' },
{ t: 'tokens/ui.config.ts', d: 'Nuxt UI colors, icons, overrides', href: 'tokens/ui.config.ts' },
{ t: 'tokens/vite.config.ts', d: 'Vite plugin setup', href: 'tokens/vite.config.ts' },
{ t: 'emails/verification.html', d: 'Go html/template', href: 'emails/verification.html' },
{ t: 'emails/invitation.html', d: 'Go html/template', href: 'emails/invitation.html' },
{ t: 'emails/password-reset.html', d: 'Go html/template', href: 'emails/password-reset.html' },
{ t: 'brand/favicon.svg', d: '16 px grid mark, dark-aware', href: 'brand/favicon.svg' }
]
@@ -95,6 +96,7 @@ class Component extends DCLogic {
['/register', 'AuthShell', 'public', 'RegisterPage', 'POST /v1/auth/register'],
['/register/pending', 'AuthShell', 'session', 'PendingPage', 'DELETE /v1/auth/register · POST /v1/auth/logout'],
['/register/validate', 'AuthShell', 'public', 'VerifyPage', 'POST /v1/auth/verify (+ POST /login inline on 401)'],
['/invite/accept', 'AuthShell', 'public', 'AcceptInvitePage', 'GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept*'],
['/register/reset/request', 'AuthShell', 'public', 'ResetRequestPage', 'POST /v1/auth/password-reset'],
['/register/reset/validate', 'AuthShell', 'public', 'ResetConfirmPage', 'POST /v1/auth/password-reset/confirm'],
['/home', 'DashboardShell', 'verified', 'HomePage', 'GET /v1/article/'],
@@ -120,9 +122,9 @@ class Component extends DCLogic {
['SaveStatus', 'custom (span + UButton)', "state: 'clean'|'dirty'|'saving'|'saved'|'error'|'offline', savedAt?: Date", 'retry', 'none', 'Editor'],
['PublishControl', 'UButton', 'online: boolean, dirty: boolean, pending?: string, disabled?', 'publish, unpublish', 'none', 'Editor'],
['ArticleDetails', 'UCard (desktop) / USlideover (mobile)', 'article: Article', 'copy-url', 'none', 'Editor'],
['CharacterCounter', 'custom', 'value: number, max: number, min?: number, warnAt?: number', 'none', 'none', 'NewArticleModal, Editor, Register'],
['PasswordStrengthHint', 'custom (in UFormField #help)', 'rules: { label, met }[], showErrors: boolean', 'none', 'none', 'Register, Reset'],
['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Register (403)'],
['CharacterCounter', 'custom', 'value: number, max: number, min?: number, warnAt?: number', 'none', 'none', 'NewArticleModal, Editor, Register, Accept invite'],
['PasswordStrengthHint', 'custom (in UFormField #help)', 'rules: { label, met }[], showErrors: boolean', 'none', 'none', 'Register, Reset, Accept invite'],
['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Accept invite, Register (403)'],
['CodeBlock', 'custom (shiki) + UButton', 'code: string, lang: string, label?', 'none', 'none', 'Integration'],
['EndpointCard', 'UCard, UBadge, UTabs', "method: 'GET', path, description, examples: { fetch, curl, response }", 'none', 'none', 'Integration'],
['ErrorView', 'custom + UButton', "kind: 'not-found'|'error'", 'retry', 'none', 'Not found, error boundary'],
@@ -136,6 +138,7 @@ class Component extends DCLogic {
M('Register', '/register', [['Default', 'Open instance'], ['Validation errors', 'Client rules fail'], ['Submitting', 'In flight'], ['Registration closed', '403'], ['Server error', '500 or network']]),
M('Pending', '/register/pending', [['Default', 'After registering (email known)'], ['Email unknown', 'After logging in unverified'], ['Confirm cancel', 'Cancel registration clicked'], ['Cancelling', 'DELETE in flight'], ['Cancelled', '200: /register with toast'], ['Already verified', '403: /home with toast']]),
M('Verify', '/register/validate?c=', [['Verifying', 'On load'], ['Success', '200: /home with toast'], ['Invalid or expired', '403'], ['No session', '401: inline log in, then retry'], ['Missing code', 'No c parameter']]),
M('Accept invite', '/invite/accept?c=', [['Checking the link', 'On load: GET invite*'], ['Default', 'Valid, unused code; email read-only'], ['Validation errors', 'Register rules fail'], ['Submitting', 'In flight'], ['Username taken', '409'], ['Invalid or used', '404 or no c parameter'], ['Expired', '410: ask the person who invited you'], ['Server error', '500 or network'], ['Success', '200: logged in, /home with toast']]),
M('Forgot password', '/register/reset/request', [['Default', 'Page load'], ['Validation', 'Invalid email'], ['Submitting', 'In flight'], ['Sent', 'Any 200 (neutral copy)'], ['Too many attempts', '429*'], ['Server error', '500 or network']]),
M('Reset password', '/register/reset/validate', [['Default', 'c and email present'], ['Too short', 'Under 8 characters'], ['Mismatch', 'Confirm field differs'], ['Submitting', 'In flight'], ['Success', '200: /login?reason=password-changed'], ['Invalid or expired', '403'], ['Missing parameters', 'No c or no email']]),
M('Home', '/home', [['Loading', 'List in flight (after 150 ms)'], ['Default', 'List loaded'], ['Empty', 'Zero articles'], ['Error', '500 or network']]),
@@ -153,6 +156,9 @@ class Component extends DCLogic {
['403', 'Any back-office call', 'Unverified: goes to /register/pending', 'Redirect'],
['403', 'Verify', '"This verification link doesn\'t work." Offers Cancel registration and start over', 'ResultPanel'],
['403', 'Cancel registration', '"Your account is already verified." Goes to /home', 'Toast'],
['404', 'Accept invite', '"This invite link doesn\'t work." Offers Go to log in', 'ResultPanel'],
['409', 'Accept invite', '"That username is taken. Try another."', 'Field'],
['410', 'Accept invite', '"This invite has expired. Ask the person who invited you to send a new one."', 'ResultPanel'],
['403', 'Reset confirm', '"This reset link has expired." Offers Request a new link', 'ResultPanel'],
['404', 'Editor load', '"This article doesn\'t exist." Offers All articles', 'ErrorView'],
['404', 'Row or editor action', '"That article no longer exists." The row is removed', 'Toast'],
@@ -167,6 +173,7 @@ class Component extends DCLogic {
const pending = [
{ a: 'GET /v1/auth/me → User | 401', d: 'The session check at boot. The HttpOnly cookie can\'t be read from JS. Until this exists, the guard can call GET /v1/article/ and infer: 401 means logged out, but verified and unverified users can\'t be told apart.' },
{ a: 'GET /v1/auth/registration → { open }', d: 'Shows "Create the owner account" on /login. Without it, the link is hidden and /register is reachable by URL only.' },
{ a: 'GET /v1/auth/invite?c=CODE → { email } | 404 | 410', d: 'Looks up the invite on load so the email can be shown and dead links are caught before the form. POST /v1/auth/invite/accept { code, username, password } returns 200 with the session cookie, 409 for a taken username, 404 or 410 as above. Neither exists yet.' },
{ a: '429 + Retry-After on /login, /password-reset', d: 'Drives the countdown state. The UI falls back to 60 s if the header is missing.' },
{ a: 'Serving /static/uploadedImages/*', d: 'The editor and preview are designed as if images load. Until they do, img tags show a broken image.' },
{ a: 'Not supported (disabled or omitted)', d: 'Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the password while logged in, editing the profile or email, listing or deleting individual images, multiple users.' }
+47 -5
View File
@@ -86,20 +86,32 @@ class Component extends DCLogic {
{ id: 6, t: 'Our first passive-house retrofit', on: true, c: '3 Apr 2026', u: '2 Jul 2026', imgs: 0 }
]
}
people() {
return [
{ email: 'sam.oliveira@renew.example', st: 'pending', c: '27 Sep 2026' },
{ email: 'p.huber@gmail.com', st: 'expired', c: '3 Aug 2026' },
{ u: 'ines', st: 'accepted', c: '18 Jun 2026' },
{ u: 'jonas', st: 'accepted', c: '2 Apr 2026' },
{ u: 'marta', st: 'accepted', c: '12 Mar 2026', you: true }
]
}
flowDefs = [
{ key: 'first', name: 'First run', desc: 'Register, verify by email, write and publish.', steps: [['register', 'Create the owner account'], ['pending', 'Check your email'], ['verified', 'Verify from the email'], ['firstArticle', 'Write the first article'], ['published', 'Publish it']], hints: { register: 'Click "Create account".', pending: 'Use "Open the verification email" below.', verified: 'Click "Verify my account" in the email.', firstArticle: 'Click "New article", then "Create draft".', published: 'Click "Publish" in the editor header.' } },
{ key: 'edit', name: 'Edit an article', desc: 'Log in, upload an image, save, unpublish.', steps: [['login', 'Log in'], ['openArticle', 'Open an article'], ['upload', 'Upload an image'], ['saved', 'Save'], ['unpublished', 'Unpublish']], hints: { login: 'Click "Log in".', openArticle: 'Go to Articles and click an online article.', upload: 'Click the image button at the end of the toolbar, then the drop area.', saved: 'Click "Save".', unpublished: 'Click "Unpublish".' } },
{ key: 'reset', name: 'Forgot password', desc: 'Request a link, reset, log in.', steps: [['forgot', 'Ask for a reset link'], ['email', 'Open the email'], ['changed', 'Choose a new password'], ['login', 'Log in']], hints: { forgot: 'Click "Forgot password?", then "Send reset link".', email: 'Use "Open the reset email" below, then click its button.', changed: 'Click "Change password".', login: 'Click "Log in".' } },
{ key: 'delete', name: 'Delete an article', desc: 'From the row menu, with confirmation.', steps: [['menu', 'Open the row menu'], ['confirm', 'Confirm the delete'], ['deleted', 'Article deleted']], hints: { menu: 'Click the ••• button on any row. On mobile, tap ⋮.', confirm: 'Choose "Delete…", then "Delete article".', deleted: '' } },
{ key: 'invite', name: 'Invite a user', desc: 'Give someone their own login instead of sharing yours.', steps: [['users', 'Go to Users'], ['form', 'Open the invite form'], ['taken', 'Try an address that\'s taken'], ['sent', 'Send the invite']], hints: { users: 'Click "Users" in the sidebar. On mobile, open the menu first.', form: 'Click "Invite user".', taken: 'Click "Send invite". The form fills with an address that already has an account.', sent: 'Click "Send invite" again. This time it uses a new address.' } },
{ key: 'expiry', name: 'Session expiry mid-edit', desc: 'Your unsaved work survives a new login.', steps: [['edited', 'Make a change'], ['expire', 'Session expires'], ['relogin', 'Log in again'], ['restored', 'Back in the editor, changes kept']], hints: { edited: 'Click the article text to make a change.', expire: 'Use "Simulate session expiry" below.', relogin: 'Click "Log in".', restored: '' } }
]
start(key) {
const articles = this.base()
const common = { flow: key, done: [], modal: null, menu: null, busy: null, toasts: [], auth: {}, mnav: false, expired: false, copied: false, email: null, ed: null, cur: null }
const users = key === 'first' ? this.people().slice(-1) : this.people()
const common = { users, flow: key, done: [], modal: null, menu: null, busy: null, toasts: [], auth: {}, mnav: false, expired: false, copied: false, email: null, ed: null, cur: null }
if (key === 'first') return { ...common, route: 'register', regOpen: true, articles: [] }
if (key === 'edit') return { ...common, route: 'login', regOpen: false, articles }
if (key === 'reset') return { ...common, route: 'login', regOpen: false, articles }
if (key === 'delete') return { ...common, route: 'articles', regOpen: false, articles }
if (key === 'invite') return { ...common, route: 'home', regOpen: false, articles }
return { ...common, route: 'editor', regOpen: false, articles, cur: 1, ed: { status: 'online', save: 'saved', image: false, preview: false } }
}
state = { device: 'desktop', mode: this.props.theme ?? 'light', hs: false, zoom: 1, emails: {}, ...this.start('first') }
@@ -174,8 +186,11 @@ class Component extends DCLogic {
}
act(a) {
const s = this.state
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
if (s.userMenu) this.setState({ userMenu: false })
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
if (a === 'mnav-close') return this.setState({ mnav: false })
if (a.startsWith('nav-')) return this.go(a.slice(4))
if (a.startsWith('nav-')) { const to = a.slice(4); if (to === 'users') this.mark('users'); return this.go(to) }
if (a === 'logout') { this.clearTimers(); this.setState({ route: 'login', auth: {}, mnav: false, menu: null, modal: null }); return this.toast('info', 'info', 'You\'re logged out', 'Log in again to keep working.') }
if (s.modal) return this.modalAct(a)
const r = s.route
@@ -221,6 +236,7 @@ class Component extends DCLogic {
}
return
}
if (a === 'invite') { this.mark('form'); return this.setState({ modal: { type: 'invite', tries: 0 }, menu: null }) }
if (a === 'new' || a === 'empty' || (a === 'm-action' && r === 'home')) return this.setState({ modal: { type: 'new' }, menu: null })
if (a === 'm-lead' && r !== 'editor') return this.setState({ mnav: true })
if (a.startsWith('open-')) return this.openEditor(Number(a.slice(5)))
@@ -279,6 +295,21 @@ class Component extends DCLogic {
}
return
}
if (m.type === 'invite') {
if (a === 'modal-cancel' || a === 'modal-close') return close()
if (a === 'modal-confirm') {
const taken = !m.tries
const email = taken ? 'jonas@renew.example' : 'lea.martin@renew.example'
this.setState({ modal: { ...m, email, err: null, loading: true } })
this.later(() => {
if (taken) { this.setState({ modal: { ...m, email, tries: 1, loading: false, err: 'This address already has an account.' } }); return this.mark('taken') }
this.setState(st => ({ modal: null, users: [{ email, st: 'pending', c: 'Today', hl: true }, ...st.users.map(u => ({ ...u, hl: false }))] }))
this.mark('sent')
this.toast('success', 'circle-check', 'Invite sent', email + ' has 3 days to accept.')
}, 900)
}
return
}
if (m.type === 'delete') {
if (a === 'modal-cancel' || a === 'modal-close') return close()
if (a === 'modal-confirm') {
@@ -294,7 +325,14 @@ class Component extends DCLogic {
return
}
if (m.type === 'upload') {
if (m.state === 'crop' && typeof a === 'string') {
if (a === 'modal-cancel') return this.setState({ modal: { ...m, state: 'idle' } })
if (a.startsWith('crop-r-')) return this.setState({ modal: { ...m, ratio: +a.slice(7) } })
if (a.startsWith('crop-s-')) return this.setState({ modal: { ...m, size: +a.slice(7) } })
if (a === 'crop-reset') return this.setState({ modal: { ...m, ratio: 0, size: 1 } })
}
if (a === 'modal-cancel' || a === 'modal-close') return close()
if (a === 'upload-pick' && m.state === 'idle') return this.setState({ modal: { ...m, state: 'crop', ratio: 0, size: 1 } })
if ((a === 'upload-pick' || a === 'modal-confirm') && m.state !== 'uploading') {
this.setState({ modal: { ...m, state: 'uploading' } })
this.later(() => {
@@ -331,10 +369,12 @@ class Component extends DCLogic {
const T = (name, crumb) => ({ nav: name, crumbs: crumb })
if (s.route === 'home') return { kind: 'home', ...T('home', ['Home']), mTitle: 'Home', mAction: 'plus', home: arts.length ? { sub: 'Here\'s where things stand on your site.', stats: [{ n: String(counts[0]), l: 'articles' }, { n: String(counts[1]), l: 'online' }, { n: String(counts[2]), l: 'drafts' }], recent: arts.slice(0, 5).map(a => ({ ...a, draft: !a.on })) } : { sub: 'Welcome to RenewCMS.', empty: true }, modal: this.modalS() }
if (s.route === 'articles') return { kind: 'articles', ...T('articles', ['Articles']), mTitle: 'Articles', list: { counts, rows: arts.map(a => ({ ...a, menu: s.menu === a.id, busy: s.busy === a.id ? (a.on ? 'Unpublishing…' : 'Publishing…') : null })), empty: arts.length ? null : { icon: 'file-text', title: 'No articles yet', text: 'Write your first one. It stays a draft until you publish it.', action: 'New article', primary: true } }, modal: this.modalS() }
if (s.route === 'users') return { kind: 'users', ...T('users', ['Users']), mTitle: 'Users', users: { rows: s.users, only: s.users.length === 1 }, modal: this.modalS() }
if (s.route === 'profile') return { kind: 'profile', ...T('profile', ['Profile']), mTitle: 'Profile', pf: { reset: s.pfReset } }
if (s.route === 'integration') return { kind: 'integration', ...T('integration', ['Integration']), mTitle: 'Integration', copied: s.copied }
if (s.route === 'editor') {
const a = this.art(s.cur) || {}, e = s.ed || {}
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'trash-2', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
}
return { kind: 'home', home: { sub: '' } }
}
@@ -342,11 +382,13 @@ class Component extends DCLogic {
const m = this.state.modal
if (!m) return null
if (m.type === 'new') return { title: 'New article', desc: 'It\'s saved as a draft. Titles can\'t be changed later, so check it now.', close: true, cancel: 'Cancel', confirm: m.loading ? 'Creating…' : 'Create draft', loading: m.loading, wide: true, fields: [{ label: 'Title', value: 'Choosing tiles for a small bathroom', count: '35 / 200', help: '3 to 200 characters.' }, { label: 'First paragraph', value: 'Small rooms show every grout line, so we start with the floor and work up.', area: true, help: 'You can format it and add images in the editor.' }] }
if (m.type === 'invite') return { title: 'Invite a user', desc: 'We\'ll email them a link to choose a username and password. The link expires after 3 days.', close: true, cancel: 'Cancel', confirm: m.loading ? 'Sending…' : 'Send invite', loading: m.loading, fields: [m.email ? { label: 'Email', value: m.email, err: m.err } : { label: 'Email', ph: 'name@example.com', focus: true }] }
if (m.type === 'delete') {
const a = this.art(m.id) || { t: '', imgs: 0 }
const imgs = a.imgs === 0 ? '' : a.imgs === 1 ? ' and its image' : ' and its ' + a.imgs + ' images'
return { title: 'Delete this article?', desc: '"' + a.t + '"' + imgs + ' will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: m.loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading: m.loading }
}
if (m.type === 'upload' && m.state === 'crop') return { title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop: { ratio: m.ratio, size: m.size }, cancel: 'Choose another file', confirm: 'Upload image' }
if (m.type === 'upload') return { title: 'Add an image', close: true, upload: { state: m.state }, cancel: m.state === 'uploading' ? 'Cancel upload' : 'Cancel' }
if (m.type === 'leave') { const a = this.art(this.state.cur) || {}; return { title: 'Leave without saving?', desc: 'Your changes to "' + a.t + '" haven\'t been saved.', cancel: 'Discard changes', confirm: 'Keep editing' } }
return null
@@ -379,14 +421,14 @@ class Component extends DCLogic {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = s.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
const [dw, dh] = mobile ? [390, 844] : [1440, 900]
const navKey = s.route === 'editor' ? 'articles' : s.route
const mnavItems = [['nav-home', 'house', 'Home', 'home'], ['nav-articles', 'file-text', 'Articles', 'articles'], ['nav-integration', 'plug', 'Integration', 'integration'], ['logout', 'log-out', 'Log out', '']].map(([act, icon, t, k]) => ({ act, icon, t, bg: k && k === navKey ? 'var(--rc-sand)' : 'transparent', fg: k && k === navKey ? '#111111' : 'var(--ui-text-toned)' }))
const mnavItems = [['nav-home', 'house', 'Home', 'home'], ['nav-articles', 'file-text', 'Articles', 'articles'], ['nav-users', 'users', 'Users', 'users'], ['nav-integration', 'plug', 'Integration', 'integration'], ['logout', 'log-out', 'Log out', '']].map(([act, icon, t, k]) => ({ act, icon, t, bg: k && k === navKey ? 'var(--rc-sand)' : 'transparent', fg: k && k === navKey ? '#111111' : 'var(--ui-text-toned)' }))
const toasts = s.toasts.map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
return {
rootRef: this.rootRef, stageRef: this.stageRef,
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 }),
mobile, isAuth, isApp, isEmail,
authS: isAuth ? this.authS() : {}, appS: isApp ? this.appS() : {},
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu } : {},
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',
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
@@ -18,7 +18,7 @@
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: public and auth</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the six public routes, at 1440 px (shown at 50%) and 390 px (shown at 75%). Use the switch on the right to see all of them in dark mode. Final copy is written into each state. The back-office screens and global states are on <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>, and the emails are on <a href="RenewCMS Emails.dc.html">the email board</a>.</p>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the seven public routes, at 1440 px (shown at 50%) and 390 px (shown at 75%). Use the switch on the right to see all of them in dark mode. Final copy is written into each state. The back-office screens and global states are on <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>, and the emails are on <a href="RenewCMS Emails.dc.html">the email board</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
@@ -112,6 +112,8 @@ class Component extends DCLogic {
const loginF = (u, p) => [U(u), P({ link: 'Forgot password?', ...p })]
const reg = { title: 'Create the owner account', desc: 'This creates the single admin account for this site. Once it\'s verified, registration closes.', submit: { t: 'Create account' }, footer: { pre: 'Already set up?', link: 'Log in' } }
const regF = (u, e, p) => [U({ ph: 'e.g. marta', count: ((u && u.value) || '').length + ' / 20', help: 'Letters and numbers, 3 to 20 characters. You\'ll use it to log in.', ...u }), E({ help: 'We\'ll send a verification link here.', ...e }), P({ hints: pwHint('unmet'), ...p })]
const inv = { title: 'Accept your invite', desc: 'Choose a username and password to finish setting up your account.', submit: { t: 'Accept invite' }, footer: { pre: 'Already have an account?', link: 'Log in' } }
const invF = (u, p) => [E({ value: 'lea.martin@renew.example', ro: true, help: 'From your invitation.' }), U({ ph: 'e.g. lea', count: ((u && u.value) || '').length + ' / 20', help: 'Letters and numbers, 3 to 20 characters. You\'ll use it to log in.', ...u }), P({ hints: pwHint('unmet'), ...p })]
const pendingResult = email => ({ tone: 'success', icon: 'mail-check', title: 'Check your email', text: email ? 'We sent a verification link to ' + email + '. It expires in 2 hours.' : 'We sent a verification link to the email you registered with. It expires in 2 hours.', note: 'Opened it on another device? That works too; you\'ll log in there first. Can\'t find it? Check your spam folder. If the link has expired, cancel your registration and start again.', actions: [{ t: 'Log out', secondary: true }, { t: 'Cancel registration', secondary: true }] })
const cancelDialog = (loading) => ({ title: 'Cancel your registration?', text: 'Your account will be deleted. You can register again with the same or a different email.', confirm: loading ? 'Cancelling…' : 'Cancel registration', cancel: 'Keep my account' })
const reqBase = { title: 'Reset your password', desc: 'Enter the email for your account and we\'ll send you a link to choose a new password.', submit: { t: 'Send reset link' }, below: 'Back to log in' }
@@ -154,6 +156,18 @@ class Component extends DCLogic {
{ name: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' },
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } }
] },
{ label: 'Accept invite', title: 'Accept invite', route: '/invite/accept?c=CODE', api: 'GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending)', note: 'Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.', states: [
{ name: 'Checking the link', trigger: 'On load, while the invite is looked up.', spinner: 'Checking your invite…' },
{ ...inv, name: 'Default', trigger: 'The code is valid and unused.', fields: invF({}, {}) },
{ ...inv, name: 'Validation errors', trigger: 'Checked on blur and on submit, using the same rules as Register. Nothing is sent.', fields: invF({ value: 'ma', err: 'Use 3 to 20 characters.', countErr: true, help: '' }, { value: 'lime', hints: pwHint('err') }) },
{ ...inv, name: 'Filled, valid', trigger: 'Every rule is met and the checklist item turns green.', fields: invF({ value: 'lea' }, { value: 'limewash2026', shown: true, hints: pwHint('met') }) },
{ ...inv, name: 'Submitting', trigger: 'Fields and button are disabled.', fields: invF({ value: 'lea' }, { value: 'limewash2026', hints: pwHint('met') }), submit: { t: 'Creating account…', loading: true } },
{ ...inv, name: 'Username taken (409)', trigger: 'Another user has that username. The value is kept and the field gets focus.', fields: invF({ value: 'marta', err: 'That username is taken. Try another.', focus: true, help: '' }, { value: 'limewash2026', hints: pwHint('met') }) },
{ name: 'Invalid or used link (404)', trigger: 'The code is wrong, was already accepted, or is missing from the URL. No form is shown.', result: { tone: 'error', icon: 'link-2-off', title: 'This invite link doesn\'t work', text: 'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.', note: 'Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in' }] } },
{ name: 'Expired link (410)', trigger: 'The invite is older than 3 days.', result: { tone: 'error', icon: 'clock', title: 'This invite has expired', text: 'Invites work once, for 3 days. Ask the person who invited you to send a new one.', actions: [{ t: 'Go to log in', secondary: true }] } },
{ ...inv, name: 'Server error', trigger: 'A 500 or no response. The values are kept.', notice: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t create the account', text: 'Something went wrong on the server. Try again in a moment.' }, fields: invF({ value: 'lea' }, { value: 'limewash2026', hints: pwHint('met') }) },
{ name: 'Success', trigger: '200 sets the session cookie → /home after 800 ms, with a toast. No verification step.', result: { tone: 'success', icon: 'circle-check', title: 'Your account is ready', text: 'Taking you to your dashboard…', actions: [] }, toast: toast('success', 'circle-check', 'Welcome to RenewCMS', 'You\'re logged in as lea.') }
] },
{ label: 'Forgot password', title: 'Forgot password', route: '/register/reset/request', api: 'POST /v1/auth/password-reset', note: 'The sent state reads the same whether or not the email exists, so the page doesn\'t reveal which emails have accounts.', states: [
{ ...reqBase, name: 'Default', trigger: 'From "Forgot password?" on log in.', fields: [E()] },
{ ...reqBase, name: 'Validation error', trigger: 'Invalid email on submit. Nothing is sent.', fields: [E({ value: 'marta@renew', err: 'Enter a full email address, like name@example.com.' })] },
@@ -93,7 +93,20 @@ class Component extends DCLogic {
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }, { label: 'First paragraph', value: body, area: true, ...(extra.b || {}) }]
const ed = { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis' }
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } })
const cropM = crop => ({ title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop, cancel: 'Choose another file', confirm: 'Upload image' })
const del = (loading, err) => ({ title: 'Delete this article?', desc: '"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading, notice: err })
const people = [
{ email: 'sam.oliveira@renew.example', st: 'pending', c: '27 Sep 2026' },
{ email: 'p.huber@gmail.com', st: 'expired', c: '3 Aug 2026' },
{ u: 'ines', display: 'Inès Duarte', st: 'accepted', c: '18 Jun 2026' },
{ u: 'jonas', st: 'accepted', c: '2 Apr 2026' },
{ u: 'marta', st: 'accepted', c: '12 Mar 2026', you: true }
]
const U = (o = {}) => ({ kind: 'users', nav: 'users', crumbs: ['Users'], mTitle: 'Users', ...o, users: { rows: people, ...(o.users || {}) } })
const inv = (o = {}) => ({ title: 'Invite a user', desc: 'We\'ll email them a link to choose a username and password. The link expires after 3 days.', close: true, cancel: 'Cancel', confirm: 'Send invite', ...o, fields: o.fields || [{ label: 'Email', ph: 'name@example.com', focus: true }] })
const invF = (value, extra = {}) => [{ label: 'Email', value, ...extra }]
const P = (pf = {}, o = {}) => withM({ kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', ...o, pf })
const dname = 'Marta Alves'
return [
{ label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [
withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true })
@@ -137,7 +150,10 @@ class Component extends DCLogic {
E({ save: 'dirty', len: 10214 }, { name: 'Too long', trigger: 'Over 10 000. Save and Publish are disabled until it\'s shorter.' }),
E({ preview: true }, { name: 'Preview', trigger: 'The Preview switch. Shows the title and body in the content scale, the way a public site might.' }),
E({ save: 'dirty' }, { name: 'Upload: choose', trigger: 'The image button in the toolbar, or a file dropped on the editor.', modal: { title: 'Add an image', close: true, upload: { state: 'idle' }, cancel: 'Cancel' } }),
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'A valid file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
E({ save: 'dirty' }, { name: 'Upload: crop and resize', trigger: 'A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.', modal: cropM({ ratio: 0, size: 1 }) }),
E({ save: 'dirty' }, { name: 'Upload: crop to a ratio', trigger: '16:9 chosen. The frame keeps the ratio while it\'s dragged or resized. Arrow keys move it; Shift and arrow keys resize it.', modal: cropM({ ratio: 2, size: 1 }) }),
E({ save: 'dirty' }, { name: 'Upload: crop narrower than the width', trigger: 'The crop is smaller than the chosen width, so it keeps its own size.', modal: cropM({ ratio: 4, size: 1, rect: [42, 34, 15.873, 21.164] }) }),
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
E({ save: 'dirty' }, { name: 'Upload: rejected', trigger: 'Checked in the browser, so nothing is sent.', modal: { title: 'Add an image', close: true, upload: { state: 'rejected' }, cancel: 'Cancel', confirm: 'Choose another file' } }),
E({ save: 'dirty', image: true }, { name: 'Upload: inserted', trigger: '201: the image goes in at the cursor, and the article now has unsaved changes.', toasts: [T('success', 'circle-check', 'Image added', 'Save to keep it in the article.')] }),
E({ save: 'saving', pending: 'Saving and publishing…' }, { name: 'Publishing', trigger: '"Save and publish": the PATCH body, then the publish request.' }),
@@ -152,6 +168,33 @@ class Component extends DCLogic {
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
] },
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite', note: 'One shared workspace: every user sees the same articles and this same list. Rows have no actions yet. Pending and expired invites show the email address, because a username only exists once the invite is accepted. 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({ users: { loading: true } }), name: 'Loading', trigger: 'The first load, or a retry. Skeletons appear after 150 ms.' }),
withM({ ...U({ users: { rows: people.slice(-1), only: true } }), name: 'Only you', trigger: 'Nobody has been invited yet. The admin who registered is always listed.' }),
withM({ ...U({ users: { error: true } }), name: 'Error', trigger: 'A 500 or network failure on the list request.' }),
withM({ ...U({ modal: inv() }), name: 'Invite: default', trigger: '"Invite user" in the page header, or the bottom button on mobile.' }),
withM({ ...U({ modal: inv({ fields: invF('sam.oliveira@renew', { err: 'Enter a full email address, like name@example.com.' }) }) }), name: 'Invite: validation', trigger: 'On submit or blur. Nothing is sent.' }),
withM({ ...U({ modal: inv({ loading: true, confirm: 'Sending…', fields: invF('lea.martin@renew.example') }) }), name: 'Invite: sending', trigger: 'Request in flight. The field is disabled and the dialog can\'t be dismissed.' }),
withM({ ...U({ modal: inv({ fields: invF('jonas@renew.example', { err: 'This address already has an account.' }) }) }), name: 'Invite: already a user (409)', trigger: 'The email belongs to an existing user. For a live pending invite the message reads "There\'s already a pending invite for this address." An expired invite can be sent again.' }),
withM({ ...U({ modal: inv({ notice: 'Couldn\'t send the invite. Try again in a moment.', fields: invF('lea.martin@renew.example') }) }), name: 'Invite: failed', trigger: '500, network failure, or the email couldn\'t be sent. The address stays in the field.' }),
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', 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: 'Profile', title: 'Profile', route: '/profile', api: 'GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset', note: 'Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Once saved it replaces the username everywhere a person is named: the sidebar, the account menu, the Home greeting, the Users list (with the username beside it) and the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS". Saving it empty goes back to the username. The reset link uses the same flow as "Forgot password" and ends on the auth board\'s "Choose a new password" screen.', states: [
withM({ ...home, home: { sub: 'Here\'s where things stand on your site.', stats, recent }, userMenu: true, name: 'Account menu', trigger: 'The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.' }),
P({ loading: true }, { name: 'Loading', trigger: 'Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away.' }),
P({}, { name: 'Default, no display name', trigger: 'The usual first visit. The username is used everywhere.' }),
P({ value: dname, focus: true, dirty: true }, { name: 'Editing', trigger: 'Typing in the field. Save turns on, and leaving the page asks for confirmation.' }),
P({ value: 'Marta Alves, Head of Renovations, Brussels and Wallonia office', focus: true, dirty: true, err: 'Use 60 characters or fewer.' }, { name: 'Too long', trigger: 'Over 60 characters. Checked as you type, and Save stays off.' }),
P({ value: dname, dirty: true, saving: true }, { name: 'Saving', trigger: 'Save, or Enter in the field. The field is disabled.' }),
P({}, { display: dname, name: 'Saved', trigger: '200: the sidebar and account menu switch to the display name at once, and a toast confirms.', toasts: [T('success', 'circle-check', 'Display name saved', 'You now appear as Marta Alves across RenewCMS and in emails.')] }),
P({ value: dname, dirty: true, notice: 'Couldn\'t save your display name. Your text is still here. Try again in a moment.' }, { name: 'Save failed', trigger: '500 or network failure. The typed value is kept.' }),
P({ reset: 'sending' }, { display: dname, name: 'Reset: sending', trigger: '"Send reset link". No confirmation, since it only sends an email.' }),
P({ reset: 'sent' }, { display: dname, name: 'Reset: sent', trigger: '200. The message stays until the page is left. "Send again" sends a new link and the old one stops working.' }),
P({ reset: 'limited' }, { display: dname, name: 'Reset: too many requests (429)', trigger: 'Rate limited. The button is disabled for 5 minutes.' }),
P({ reset: 'error' }, { display: dname, name: 'Reset: failed', trigger: '500 or network failure. The button can be pressed again.' }),
withM({ ...U(), display: dname, name: 'Display names in the Users list', trigger: 'Accounts with a display name show it first, with the username beside it in grey. Accounts without one show the username.' })
] },
{ label: 'Global', title: 'Global states', route: 'any route', api: '', note: 'A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.', states: [
withM({ kind: 'error', nav: '', crumbs: ['Not found'], mTitle: 'Not found', name: '404 inside the shell', trigger: 'An unknown route while logged in. Logged out, the same content appears inside AuthShell.', err: { code: '404', title: 'Page not found', text: 'The link may be mistyped, or the page may have moved.', primary: 'Go to Home', secondary: 'All articles' } }),
withM({ kind: 'error', nav: 'home', crumbs: ['Home'], mTitle: 'Home', name: 'Generic error', trigger: 'An unexpected error while rendering a route.', err: { code: 'Error', title: 'Something went wrong', text: 'This page couldn\'t load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.', primary: 'Try again', secondary: 'Go to Home' } }),
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>You're invited to RenewCMS</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{.InvitedBy}} invited you to work on articles in RenewCMS. The invite expires in 3 days.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">You're invited to RenewCMS</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">{{.InvitedBy}} invited you to write and publish articles in RenewCMS at {{.Host}}. Accept to choose a username and password. You'll sign in with your own account.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/invite/accept?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Accept invite</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This invite was sent to {{.Email}}. It expires in 3 days and works once.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/invite/accept?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">Not expecting this? Ignore this email and no account will be created. If the invite has expired, ask {{.InvitedBy}} to send a new one.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+42 -3
View File
@@ -74,7 +74,7 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Editor | `/article/:id/edit` | Near the limit | At least 9 000 HTML characters | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Too long | Over 10 000: Save and Publish disabled | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Preview | Switch on | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Choose, crop and resize, uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Publishing, unpublishing | PATCH publish or unpublish | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Leave guard | Navigating away while dirty | [editor](screens/editor.md) |
| Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) |
@@ -82,6 +82,27 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Editor | `/article/:id/edit` | Session expired | 401: draft kept in sessionStorage, then /login | [editor](screens/editor.md) |
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
| Users | `/users` | Error | 500 or network | [users](screens/users.md) |
| Users | `/users` | Invite: default, validation, sending | Invite modal opened, invalid email, POST in flight | [users](screens/users.md) |
| Users | `/users` | Invite: already a user | 409 | [users](screens/users.md) |
| Users | `/users` | Invite: failed | 500, network or mail failure | [users](screens/users.md) |
| Users | `/users` | Invite sent | 201: dialog closes, Pending row on top, toast | [users](screens/users.md) |
| Profile | `/profile` | Account menu | Account chip clicked | [profile](screens/profile.md) |
| Profile | `/profile` | Loading | Opened by URL before /auth/me returns | [profile](screens/profile.md) |
| Profile | `/profile` | Default, no display name | First visit | [profile](screens/profile.md) |
| Profile | `/profile` | Editing, too long | Typing; over 60 characters | [profile](screens/profile.md) |
| Profile | `/profile` | Saving, saved, save failed | PATCH in flight, 200, 500 or network | [profile](screens/profile.md) |
| Profile | `/profile` | Reset: sending, sent, 429, failed | POST /v1/auth/password-reset | [profile](screens/profile.md) |
| Profile | `/profile` | Display names in the Users list | Accounts with a display name | [profile](screens/profile.md) |
| Accept invite | `/invite/accept?c=` | Checking the link | On load: GET invite | [accept-invite](screens/accept-invite.md) |
| Accept invite | `/invite/accept?c=` | Default, validation errors, filled, submitting | Valid, unused code; email read-only | [accept-invite](screens/accept-invite.md) |
| Accept invite | `/invite/accept?c=` | Username taken | 409 | [accept-invite](screens/accept-invite.md) |
| Accept invite | `/invite/accept?c=` | Invalid or used, expired | 404 or no c parameter, 410 | [accept-invite](screens/accept-invite.md) |
| Accept invite | `/invite/accept?c=` | Server error | 500 or network | [accept-invite](screens/accept-invite.md) |
| Accept invite | `/invite/accept?c=` | Success | 200: logged in, /home with toast | [accept-invite](screens/accept-invite.md) |
## HTTP status to copy
@@ -98,18 +119,36 @@ Response bodies are plain text and are never shown. `useApi` throws an `ApiError
| 403 | Verify | "This verification link doesn't work." Offers Cancel registration and start over | ResultPanel |
| 403 | Cancel registration | "Your account is already verified." Goes to /home | Toast |
| 403 | Reset confirm | "This reset link has expired." Offers Request a new link | ResultPanel |
| 409 | Invite | "This address already has an account." or, for a live invite, "There's already a pending invite for this address." | Field error |
| 404 | Accept invite | "This invite link doesn't work." Offers Go to log in | ResultPanel |
| 409 | Accept invite | "That username is taken. Try another." | Field |
| 410 | Accept invite | "This invite has expired. Ask the person who invited you to send a new one." | ResultPanel |
| 404 | Editor load | "This article doesn't exist." Offers All articles | ErrorView |
| 404 | Row or editor action | "That article no longer exists." The row is removed | Toast |
| 404 | Unknown route | "Page not found" | ErrorView |
| 429 | Profile, reset link | "Too many requests. You've asked for several links recently. Wait a few minutes, then try again." Button disabled | Note |
| 429 | Log in, reset request | "Too many attempts. For your security, wait a moment before trying again." With a countdown | Alert + disabled button |
| 500 | Save | "Save failed" + Retry. Changes are kept | SaveStatus |
| 500 | Publish / unpublish | "Couldn't publish" / "Couldn't unpublish" + Retry | Toast |
| 500 | Load users | "Couldn't load users. Check your connection and try again." + Try again | Alert |
| 500 | Invite | "Couldn't send the invite. Try again in a moment." The address stays | Alert in dialog |
| 500 | Save display name | "Couldn't save your display name. Your text is still here. Try again in a moment." | Alert in card |
| 500 | Send reset link (profile) | "Couldn't send the link. Check your connection and try again." | Note |
| 500 | Delete | "Couldn't delete the article, so it's still there. Try again in a moment." | Inline in dialog |
| 500 | Upload | "Upload failed. The server didn't accept the file. Your article hasn't changed." | ImageUploader |
| network | Anywhere | "Couldn't reach RenewCMS. Check your connection and try again." Offline: banner | Alert / toast / banner |
## API the design marked pending
All four now exist: `GET /v1/auth/me`, `GET /v1/auth/registration`, `429` with `Retry-After` on `/v1/auth/login` and `/v1/auth/password-reset`, and `/static/uploadedImages/*`. Still not supported, so shown disabled or omitted:
Existing: `GET /v1/auth/me`, `GET /v1/auth/registration`, `429` with `Retry-After` on `/v1/auth/login` and `/v1/auth/password-reset`, and `/static/uploadedImages/*`.
Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the password while logged in, editing the profile or email, listing or deleting individual images, multiple users.
Added by the Users and Profile screens, none of which exist yet:
- `GET /v1/user/`: the users and their invites, newest first.
- `POST /v1/user/invite`: sends an invitation email. 201, 409 for an existing account or a live pending invite.
- `PATCH /v1/user/me`: saves the display name. `GET /v1/auth/me` returns it.
- `GET /v1/auth/invite?c=CODE`: looks up the invite on load, returns `{ email }`, 404 or 410. `POST /v1/auth/invite/accept` takes `{ code, username, password }` and returns 200 with the session cookie, 409 for a taken username, 404 or 410.
Still not supported, so shown disabled or omitted:
Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the email or username, listing or deleting individual images, removing users, revoking invites and any kind of role. Changing the password while logged in works only through the emailed reset link on the Profile page.
+19 -1
View File
@@ -57,6 +57,8 @@ task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
task visual:check -- log-in # screenshot design vs SPA for a screen → tmp/visual/log-in/
task lint # gofmt check, go vet, eslint --fix, vue-tsc
task fmt # gofmt -w + prettier
task run # build the production binary and run it (SPA embedded) on :8080
@@ -67,7 +69,23 @@ task clean # remove bin/, tmp/ and the built SPA
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
- The frontend has no test runner yet. `task lint:web` runs eslint and a type check.
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
## Visual check against the design
`task visual:check -- <screen> [state]` compares the SPA with the Claude Design boards in [`docs/design/source`](design/source). The screen is a file name from [`docs/design/screens`](design/screens) (`log-in`, `home`, …), and the state is one of its headings.
- It builds the SPA into `tmp/visual/dist` and serves both the build and the boards locally, so no API or dev server is needed.
- For every state it screenshots:
- the board's frame for that state;
- the SPA in the same state, at 1440×900 and 390×844, in light and dark.
- It saves `design-*.png`, `app-*.png` and `diff-*.png` (differing pixels in red) to `tmp/visual/<screen>/<state>/`, and a summary to `tmp/visual/<screen>/report.md`.
- [`web/visual/states.ts`](../web/visual/states.ts) says how to reach each state in the SPA: the route, the mocked `/v1` responses (keyed `'METHOD /v1/path'`) and any user steps. A state without an entry is captured on the design side only, and marked "design only" in the report.
- Narrow a run with `--viewport=desktop|mobile` and `--theme=light|dark`.
- Lucide icons are served from the local `@iconify-json/lucide` set, for both the boards and the SPA. api.iconify.design rate-limits repeated runs.
- The first time on a machine, run `bunx playwright install chromium` in `web/`.
The pixel percentage only points at where to look, because font anti-aliasing always differs a little. Judge by comparing the image pairs.
## Data
+159
View File
@@ -0,0 +1,159 @@
Feature: An invited person accepts their invitation and gets a verified account
As a person who received an invitation email
I want to open the link, see which address it was sent to, choose a username and password,
and land logged in
So that I get an account with exactly the same access as everyone else, while registration
stays closed to anyone who was not invited
Background:
Given GET /v1/auth/invite and POST /v1/auth/invite/accept are mounted in NewAuthRouter and are
public: no jwt cookie is required and an existing jwt cookie is ignored
And both are rate-limited per client IP with the auth rate limiter (AUTH_RATE_LIMIT_MAX_ATTEMPTS,
AUTH_RATE_LIMIT_WINDOW_SECONDS), counted independently of /login, /password-reset and
POST /v1/user/invite
And an invite is looked up by comparing the raw code against the stored hashed code with the
same hash.Hasher used to create it, and the raw code is never stored
And an invite is usable only when accepted_at is empty and expires_at is in the future
And a code that matches no invite, matches an accepted invite, or is missing or empty answers
404 with body apperr.InviteInvalidErrorMessage "This invite link doesn't work."
And a code that matches an unaccepted invite whose expires_at is in the past answers 410 with
body apperr.InviteExpiredErrorMessage "This invite has expired."
And the POST body has the validate tags code required, username required,min=3,max=20 and
password required,min=8, checked with validator.Validate.Struct before any lookup, and a
failure answers 400 with the validator's text; an unparseable body answers 400 with
apperr.BodyErrorMessage
And error responses are plain text from http.Error, success responses are JSON
And accepting creates a user with the invite's email (already lower case), the chosen username,
the hashed password, is_verified true, an empty verification code, and an empty display name
And the created user has the same access as any other verified user: no roles, no ownership
And the jwt cookie is set exactly as registration sets it: HttpOnly, path "/", 24 hours, with
claims user_id and token_version, and Secure in production
Scenario: The invited email is read from a valid code
Given a pending invite for "lea.martin@renew.example" whose raw code is "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
When the client sends GET /v1/auth/invite?c=3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42
Then the response status is 200
And the response body is a JSON object with exactly the key email, with value "lea.martin@renew.example"
And the response has Cache-Control "no-store"
And the invite is unchanged
Scenario Outline: A link that does not work is reported without saying why
Given a pending invite for "lea.martin@renew.example", an accepted invite for "sam@renew.example"
and an expired invite for "p.huber@gmail.com", each with its own raw code
When the client sends GET /v1/auth/invite<query>
Then the response status is <status>
And the response body is "<body>"
Examples:
| query | status | body |
| ?c=not-a-known-code | 404 | This invite link doesn't work. |
| ?c=<accepted invite code> | 404 | This invite link doesn't work. |
| ?c= | 404 | This invite link doesn't work. |
| | 404 | This invite link doesn't work. |
| ?c=<expired invite code> | 410 | This invite has expired. |
Scenario: Accepting creates a verified user, logs them in and closes the invite
Given a verified user "marta" and a pending invite for "Lea.Martin@Renew.example" created at
invitation time, with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
When the client sends POST /v1/auth/invite/accept with body
{"code": "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42", "username": "lea", "password": "limewash2026"}
Then the response status is 200
And the response body is a JSON object {"message": "Invite accepted! HTTPonly jwt cookie created"}
And a jwt cookie is set, and decoding it gives the new user's id and token_version
And the user repository holds a user "lea" with email "lea.martin@renew.example", is_verified
true, an empty verification_code and a stored password that is a hash, not "limewash2026"
And the invite now has accepted_at set and its status is "accepted"
And no email was sent
Scenario: The new account works like any other account
Given the invite from the previous scenario was accepted and the jwt cookie was kept
When the client sends GET /v1/auth/me and GET /v1/user/ with that cookie
Then both responses are 200
And the GET /v1/user/ list shows user "lea" with status "accepted" and no invite for
"lea.martin@renew.example"
Scenario: An accepted code cannot be used again
Given an invite that was just accepted by "lea"
When the client sends POST /v1/auth/invite/accept with the same code, username "lea2" and
password "limewash2026"
Then the response status is 404
And the response body is "This invite link doesn't work."
And no user "lea2" exists and no jwt cookie is set
When the client sends GET /v1/auth/invite with the same code
Then the response status is 404
Scenario Outline: A code that cannot be used creates nothing
Given <state>
When the client sends POST /v1/auth/invite/accept with a body using <code> and username "lea" and password "limewash2026"
Then the response status is <status>
And the response body is "<body>"
And no user was created, no jwt cookie is set and no invite changed
Examples:
| state | code | status | body |
| no invite has that code | an unknown one | 404 | This invite link doesn't work. |
| an invite that expired an hour ago | its code | 410 | This invite has expired. |
Scenario: A taken username is refused and the invite stays usable
Given a verified user "marta" and a pending invite for "lea.martin@renew.example"
When the client sends POST /v1/auth/invite/accept with username "marta" and password "limewash2026"
Then the response status is 409
And the response body is "That username is taken. Try another."
And no jwt cookie is set and the invite is still pending
When the client resends with username "lea"
Then the response status is 200
Scenario: An address that got an account in the meantime is refused
Given a pending invite for "lea.martin@renew.example" and an unverified user "leam" with email "Lea.Martin@Renew.example"
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
Then the response status is 409
And the response body is exactly apperr.EmailHasAccountErrorMessage
And no user "lea" was created, no jwt cookie is set and the invite is still pending
Scenario Outline: An invalid body is rejected before the code is looked up
Given a pending invite with a known code
When the client sends POST /v1/auth/invite/accept with body <body>
Then the response status is 400
And no user was created and the invite is still pending
Examples:
| body |
| {"code": "<code>", "username": "le", "password": "limewash2026"} |
| {"code": "<code>", "username": "abcdefghijklmnopqrstu", "password": "limewash2026"} |
| {"code": "<code>", "username": "lea", "password": "lime"} |
| {"code": "<code>", "password": "limewash2026"} |
| {"username": "lea", "password": "limewash2026"} |
| not json |
Scenario: An existing jwt cookie is ignored and replaced
Given a valid jwt cookie for verified user "marta" and a pending invite for "lea.martin@renew.example"
When the client sends POST /v1/auth/invite/accept with that cookie and username "lea", password "limewash2026"
Then the response status is 200
And the Set-Cookie jwt carries the new user's id, not marta's
Scenario: A failure while accepting rolls back and leaves the invite pending and no user behind
Given a pending invite and the accept operation of the invite repository set to fail
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
Then the response status is 500
And the user insert and the invite update are rolled back together
And no user "lea" exists, no jwt cookie is set and the invite is still pending
Scenario: The accept endpoints are rate-limited
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
When the client at IP "203.0.113.30" sends 2 requests to POST /v1/auth/invite/accept and then a 3rd
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
And GET /v1/auth/registration from the same client is not rejected with 429
Scenario: Registration stays closed for everyone else
Given a verified user and an accepted invite
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
email "sam@renew.example"
Then the response status is 403
And the response body is exactly apperr.RegistrationClosedErrorMessage
And GET /v1/auth/registration still reports {"open": false}
Scenario: The invite response never leaks the code or other invite data
Given a pending invite
When the client sends GET /v1/auth/invite with its code
Then the body contains no key named code, hashed_code, invited_by, expires_at, id or accepted_at
+38
View File
@@ -0,0 +1,38 @@
# QA procedure: accept invitation (API)
Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo` (extend it with an accept method and a `failSwitch` entry for it; type names stay unique in the package).
## Setup shared by every case
1. `validator.InitValidator()` once.
2. Use cases: the accept use case (constructor takes only domain interfaces: user repo, invite repo, hasher; final names are the coder's), a get-invite use case for GET, plus `CreateUser`, `GetUser`, `ListUsersAndInvites` for the regression cases.
3. Handler built with `NewHandler(...)` over the use cases, mounted on a fresh `chi.NewRouter()` mirroring `NewAuthRouter`: `GET /invite` and `POST /invite/accept` inside a group with `TrustedProxyRealIP(nil)` + `NewRateLimiter(...)`; `/register`, `/registration` and (with `jwtauth.Verifier`, `Authenticator`, `RequireCurrentToken`) `/me` for regression. `jwtauth.New("HS256", []byte("test-secret"), nil)`. `httptest` only.
4. Seed invites via `mockInviteRepo` with `HashedCode == "hashed:"+rawCode` (stubHasher); times relative to `time.Now()`, 5 s tolerance.
## GET /v1/auth/invite
5. Valid code -> 200; decode `map[string]json.RawMessage`: keys exactly `email`; value the stored lower-case email; `Cache-Control: no-store`; invite unchanged. Raw body contains none of `code`, `hashed_code`, `invited_by`, `expires_at`, `id`, `accepted_at`.
6. Table: unknown code, accepted invite code, `?c=`, no query -> 404 with body exactly `apperr.InviteInvalidErrorMessage`; expired code -> 410 with `apperr.InviteExpiredErrorMessage`.
## POST /v1/auth/invite/accept
7. Happy path: body `{code, username:"lea", password:"limewash2026"}` -> 200, `{"message":…}`. `Set-Cookie` `jwt` HttpOnly, Path "/", decode via `jwtauth` to get `user_id` and `token_version` of the created user. `userRepo`: user "lea", `Email=="lea.martin@renew.example"`, `IsVerified`, empty `VerificationCode`, password `"hashed:limewash2026"`. Invite `AcceptedAt` not nil. `SentEmails` empty.
8. Follow-up with the cookie: `GET /me` -> 200; list use case shows user "lea" and no invite for the address.
9. Repeat POST with the same code -> 404 `InviteInvalid`, no user "lea2", no `Set-Cookie`; GET same code -> 404.
10. Table for 404 (unknown code) and 410 (expired an hour ago): exact bodies, no user, no cookie, invites unchanged.
11. Username "marta" taken -> 409, body `apperr.UsernameTakenErrorMessage` ("That username is taken. Try another."), no cookie, invite still pending; retry with "lea" -> 200.
12. Unverified user with email `Lea.Martin@Renew.example` present -> 409, body `apperr.EmailHasAccountErrorMessage`, no user created, invite pending, no cookie.
13. 400 table (username 2 chars, 21 chars, password 4 chars, no username, no code, `not json`): status 400, user repo and invite unchanged. Code lookup is not reached (assert the invite repo saw no accept call).
14. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's `user_id` is the new user's.
15. Invite repo `AcceptWithUser` set to fail (`failSwitch`) -> 500, no user "lea" remains (the failed accept created none, and the use case never calls user repo `Delete`), invite pending, no cookie. The real rollback is covered against SQLite in `test/accept_invitation_atomic_test.go`.
16. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.30:1234`, three POSTs -> third is 429 with body `apperr.TooManyRequestsErrorMessage` and `Retry-After`; `GET /registration` afterwards is not 429.
## Regression
17. Verified user present: `POST /register` -> 403 body `apperr.RegistrationClosedErrorMessage`, no user created; `GET /registration` -> `{"open":false}` after an invite was accepted. Empty user repo -> `POST /register` still 200 with a `verification` mail.
## Use case level (no HTTP)
18. Accept use case: unknown or empty code -> `errors.Is(err, invite.ErrInviteNotFound)`; expired -> an expired sentinel; taken username -> a username-taken sentinel; email owned -> `invite.ErrEmailHasAccount` (sentinel names are the coder's). Domain sentinels asserted with `errors.Is`.
## Open questions (no design state, not invented)
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
- 429 on POST /v1/auth/invite/accept: the SPA shows the design's server-error alert ("Couldn't create the account").
## Manual verification
- Fresh SQLite file: invite acceptance persists across restart, `accepted_at` stored, unique username constraint holds; send a real invitation and open its link end to end (real SMTP, real embedded SPA).
+105
View File
@@ -0,0 +1,105 @@
Feature: Accept invite screen
As a person who opened the link in the invitation email
I want /invite/accept?c=CODE to show me the invited address and let me choose a username and
password
So that I finish setting up my account and arrive logged in
Background:
Given /invite/accept is a public route (meta.auth "public") rendered as AcceptInvitePage inside
AuthShell, per docs/design/routes.md
And on mount AcceptInvitePage reads the "c" query parameter once: if it is absent or empty no
request is made and the "Invalid or used link (404)" state renders
And otherwise it calls GET /v1/auth/invite?c=CODE and shows "Checking the link" until it resolves
And the Username and Password rules are Register's, including PasswordStrengthHint, with no
confirm field; validation runs on blur and on submit and nothing is sent when it fails
And a submit that passes calls POST /v1/auth/invite/accept with { code: c, username, password }
And a logged-in visitor sees the same screen: the route stays public and nothing redirects
And the board is docs/design/source/RenewCMS Screens Auth.dc.html, group "Accept invite"; the
board frame decides layout and sample data (lea.martin@renew.example, "lea", "limewash2026",
"marta")
Scenario: Checking the link
When the user opens /invite/accept?c=abc123 and GET /v1/auth/invite has not resolved
Then a full-card spinner reads "Checking your invite…"
Scenario: Default
Given GET /v1/auth/invite?c=abc123 resolves 200 with {"email": "lea.martin@renew.example"}
Then the heading is "Accept your invite"
And the description is "Choose a username and password to finish setting up your account."
And the Email field has placeholder "you@example.com", shows "lea.martin@renew.example", is
read-only, with help "From your invitation."
And the Username field has placeholder "e.g. lea", counter "0 / 20" and help "Letters and numbers,
3 to 20 characters. You'll use it to log in."
And the Password field is a password input with show/hide and checklist "At least 8 characters" unmet
And the submit button reads "Accept invite"
And the footer reads "Already have an account?" with a "Log in" link to /login
Scenario: Validation errors
Given the form is shown, Username is "ma" and Password is "lime"
When the user blurs both fields and submits
Then POST /v1/auth/invite/accept is never called
And the Username shows counter "2 / 20" in error and the error "Use 3 to 20 characters."
And the checklist item "At least 8 characters" is in its error state
Scenario: Filled, valid
Given Username is "lea" and Password is "limewash2026" with the password shown
Then the counter reads "3 / 20", the checklist item "At least 8 characters" is met and no error shows
Scenario: Submitting
When the user submits valid values and POST /v1/auth/invite/accept has not resolved
Then the fields and button are disabled
And the submit button reads "Creating account…" and is loading
Scenario: Username taken (409)
When POST /v1/auth/invite/accept resolves 409 with body "That username is taken. Try another."
Then the Username keeps "marta", shows counter "5 / 20", is focused and shows the error
"That username is taken. Try another."
And the Password keeps its value, the form re-enables and the button reads "Accept invite"
Scenario Outline: The link does not work
When <trigger>
Then no form is shown
And a ResultPanel (tone error, icon i-lucide-link-2-off) reads "This invite link doesn't work"
with text "It may be incomplete, or it may already have been used. If you already set up your
account, log in instead." and note "Open the link from your email again, or copy the full
address into your browser."
And its only action is a primary "Go to log in" that navigates to /login
Examples:
| trigger |
| the user opens /invite/accept with no c parameter (no request is made) |
| GET /v1/auth/invite?c=abc123 resolves 404 |
| the submit resolves 404, replacing the form |
| the submit resolves 409 with apperr.EmailHasAccountErrorMessage |
Scenario Outline: Expired link (410)
When <trigger>
Then no form is shown
And a ResultPanel (tone error, icon i-lucide-clock) reads "This invite has expired" with text
"Invites work once, for 3 days. Ask the person who invited you to send a new one."
And its only action is a secondary "Go to log in" that navigates to /login
Examples:
| trigger |
| GET /v1/auth/invite?c=abc123 resolves 410 |
| the submit resolves 410, replacing the form |
Scenario Outline: Server error
When the submit <failure>
Then an error Alert (icon i-lucide-circle-x) reads "Couldn't create the account" with "Something
went wrong on the server. Try again in a moment." above the heading
And the form is still shown with Email, Username "lea" and Password "limewash2026" kept, re-enabled
Examples:
| failure |
| resolves 500 |
| resolves 429 |
| never reaches the server |
Scenario: Success
When POST /v1/auth/invite/accept resolves 200 with the jwt cookie
Then a ResultPanel (tone success, icon i-lucide-circle-check) reads "Your account is ready" with
text "Taking you to your dashboard…"
And a Toast (icon i-lucide-circle-check) reads "Welcome to RenewCMS" with "You're logged in as lea."
And after 800 ms the app navigates to /home with no verification step
+26
View File
@@ -0,0 +1,26 @@
# QA: Accept invite screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` for GET `/v1/auth/invite` and POST `/v1/auth/invite/accept` per case; mount AcceptInvitePage inside AuthShell with a test router at `/invite/accept?c=abc123`. Use fake timers for the 800 ms redirect only; never freeze the date.
1. Checking the link: GET unresolved -> "Checking your invite…" spinner.
2. Default: GET 200 `{email:"lea.martin@renew.example"}` -> heading, description, read-only Email with help, Username "0 / 20" and its help, Password checklist unmet, button "Accept invite", footer "Log in" -> /login.
3. Validation errors: Username "ma", Password "lime", blur + submit -> no POST, "Use 3 to 20 characters.", counter "2 / 20" error, checklist error.
4. Filled, valid: "lea" / "limewash2026" shown -> "3 / 20", checklist met.
5. Submitting: POST unresolved -> fields disabled, "Creating account…" loading.
6. Username taken: POST 409 body "That username is taken. Try another." -> value "marta" kept, "5 / 20", focus on Username, error copy, password kept, form re-enabled.
7. Link does not work (table): no `c` (no request at all), GET 404, POST 404, POST 409 with `This address already has an account.` -> ResultPanel link-2-off with the title, text, note and one primary "Go to log in"; click navigates to /login.
8. Expired (table): GET 410, POST 410 -> ResultPanel clock, title, text, one secondary "Go to log in".
9. Server error (table): POST 500, POST 429, rejected fetch -> Alert "Couldn't create the account" + description, form kept.
10. Success: POST 200 -> ResultPanel "Your account is ready", Toast "Welcome to RenewCMS" / "You're logged in as lea."; advance 800 ms -> router path is /home.
11. Logged-in visitor: session store holds a verified user; mounting shows the Default state, no redirect.
## Open questions (no design state, not invented)
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
## Visual (`task visual:check -- accept-invite`, desktop and mobile, light and dark; hard gate 10 %)
Sample data from the board frame: email `lea.martin@renew.example`; username `lea` (taken state: `marta`); password `limewash2026`; validation state username `ma`, password `lime`. States: Checking the link, Default, Validation errors, Filled valid, Submitting, Username taken (409), Invalid or used link (404), Expired link (410), Server error, Success. `skip: clock-sensitive`: none (expiry is driven by the 410 status, not the clock). Layout placement comes from the board frame markup, not from components.md prose.
## Manual checks (not covered by screenshots)
- Keyboard: Tab order Username -> Password -> show/hide -> Accept invite -> Log in; Username is focused after a 409; the ResultPanel title takes focus on 404/410.
- Password show/hide and hover states; the 800 ms hand-off; the Toast disappearing.
- Real e2e: open the emailed link against `task dev`.
@@ -0,0 +1,121 @@
Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling
As the maintainer of RenewCMS
I want useApi to expose one generic authenticated request method that every future screen call
site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason
and clears the session, turns a 403 from a known-unverified session into a redirect to
/register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown,
and marks the app offline on a network failure
So that later screen slices (login, articles, editor, ...) get consistent global session and
connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc
Background:
Given useApi exports a generic request(path, options) method, used by getMe and by every future
endpoint call, that always sends credentials: 'include' and parses a JSON response body on
success
And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429
response also carries retryAfter, the integer number of seconds parsed from the response's
Retry-After header, or undefined if the header is absent or not a plain integer
And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never
reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers
can tell "the server answered with an error" apart from "the request never got an answer" with
instanceof
And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and
every component observes the same value: a NetworkError sets it true; it is also true whenever
navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while
navigator.onLine is true sets it back to false
And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one
call still throws a plain ApiError with status 401, exactly as before, and does not trigger the
redirect below, because this call only ever runs as part of session.check() during router
navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already
redirects to /login?redirect=<target>&reason=expired for a "no session" boot result
And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401,
before the ApiError reaches the caller, useApi calls the session store's clear() action (session
= null, status stays 'ready') and navigates the router to
/login?redirect=<currentPath>&reason=expired, where <currentPath> is
router.currentRoute.value.fullPath at the moment the 401 is received
And on a 403, useApi reads the session store: if it holds a session whose verified is false, this
is the global "unverified account" case from docs/design/routes.md and states.md, so useApi
navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403,
and the session store is left untouched, since it already correctly records verified: false; any
other 403 (no session, or a session whose verified is true) is not the global case: useApi does
nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret
(wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset
link, and registration-closed are all screen-level, out of scope here)
And the session store gains a clear() action (session = null, status = 'ready', independent of
check()/retry()) used by the global 401 handling above
Scenario: A successful authenticated request returns the parsed body
Given a mocked fetch that resolves 200 with JSON body {"id": 5}
When useApi().request('/v1/article/5') is called
Then the returned value is {"id": 5}
And isOffline is false
Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session
Given the session store holds session {"verified": true} and status "ready"
And the current route is /article/12/edit
And a mocked fetch that resolves 401
When useApi().request('/v1/article/12') is called
Then the call rejects with an ApiError whose status is 401
And the session store's session becomes null and its status stays "ready"
And the router navigates to "/login?redirect=/article/12/edit&reason=expired"
Scenario: The boot call to getMe does not trigger the global 401 redirect
Given the session store holds session null and status "checking"
And a mocked fetch that resolves 401
When useApi().getMe() is called directly, outside of router navigation
Then the call rejects with an ApiError whose status is 401
And the router does not navigate anywhere
Scenario: A 403 while the session is known to be unverified redirects to the pending page
Given the session store holds session {"verified": false} and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/article/') is called
Then the call rejects with an ApiError whose status is 403
And the router navigates to "/register/pending"
And the session store's session is still {"verified": false}
Scenario Outline: A 403 that is not the global unverified case passes through untouched
Given the session store holds session <session> and status "ready"
And a mocked fetch that resolves 403
When useApi().request('/v1/some/endpoint') is called
Then the call rejects with an ApiError whose status is 403
And the router does not navigate anywhere
And the session store is unchanged
Examples:
| session |
| null |
| {"verified": true} |
Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds
Given a mocked fetch that resolves 429 with a Retry-After header of "30"
When useApi().request('/v1/auth/login', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30
Scenario: A 429 with no Retry-After header exposes an undefined retryAfter
Given a mocked fetch that resolves 429 with no Retry-After header
When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined
Scenario: A network failure throws a NetworkError and marks the app offline
Given navigator.onLine is true
And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is
unreachable
When useApi().request('/v1/article/') is called
Then the call rejects with a NetworkError, not an ApiError
And isOffline becomes true
Scenario: A later successful request clears the offline flag
Given isOffline is true from a previous network failure
And navigator.onLine is true
And a mocked fetch that resolves 200 with JSON body {}
When useApi().request('/v1/article/') is called
Then isOffline becomes false
Scenario: The browser's own online/offline events also drive the shared flag
Given isOffline is false
When the browser fires a "offline" event on window
Then isOffline becomes true
When the browser fires a "online" event on window
Then isOffline becomes false
@@ -0,0 +1,76 @@
# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling
This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no
Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing
`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that
the existing router guard suite still passes unmodified.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` /
`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so
`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no
httptest server, no live SMTP.
1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving
`{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call
`useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called
with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`.
2. **401 mid-session redirects and clears the session** — seed the session store with
`session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit`
first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call
`useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose
`status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert
`router.currentRoute.value.fullPath` is
`/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object).
3. **getMe's own 401 does not trigger the global redirect** — seed
`session = null, status = 'checking'`; mock fetch resolving status 401; record
`router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects
with `ApiError` status 401; assert the router's current route is unchanged from before the call.
4. **403 with a known-unverified session redirects to /register/pending** — seed
`session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call
`useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert
`router.currentRoute.value.path === '/register/pending'`; assert
`session.session` is still `{ verified: false }`.
5. **403 with no session, or a verified session, passes through untouched** — two cases,
`session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving
status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the
promise rejects with `ApiError` status 403; assert the router's current route did not change;
assert the session store's state object is unchanged (`toEqual` the pre-call snapshot).
6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving
`{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call
`useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError`
with `status === 429` and `retryAfter === 30`.
7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header
set; assert `retryAfter === undefined`.
8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set
`Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to
`mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`;
assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline`
is `true` after the rejection settles.
9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the
state from test 8, or set it directly if the module exposes a test seam), keep
`navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`;
assert `isOffline` becomes `false` after the call resolves.
10. **window online/offline events drive isOffline directly** — dispatch
`window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch
`window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline
tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on
first `useApi()` call or app mount), call `useApi()` once before dispatching the events.
11. **Regression: existing getMe/session/router-guard suites stay green** — run
`web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files)
unmodified alongside the new tests in the same `task test:web` run; assert no existing test's
assertions changed and all still pass. This is not a new scenario to author, just a check that the
new request()/isOffline/clear() additions don't regress boot, session caching or the
public/session/verified/any redirect matrix already covered by
`features/nuxt_ui_shell_foundation.feature`.
## Manual verification
- **Real network offline test** — disable the machine's network adapter (or use browser devtools'
"Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm
`isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a
rejected `fetch`, never a real dropped connection.
- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown
display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and
password-reset screen slices and is out of scope here.
+259
View File
@@ -0,0 +1,259 @@
Feature: Articles list screen
As a verified RenewCMS owner
I want /article to list every article with its status, let me search and filter it, and let me
publish, unpublish or delete a row without leaving the page
So that I can manage my whole set of articles from one place, with the list always reflecting
what the server actually holds
Background:
Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside
DashboardShell, per docs/design/routes.md
And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all
articles, online and draft, because the request carries the jwt cookie), and every row
action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or
DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to
them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters
(which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one
ArticleRepository.Get already has, so every article in the list response carries its real
Images array instead of coming back empty; no domain or use-case change is needed, since
article.Article.Images already exists
And the PageHeader shows breadcrumb "Articles" (mobile title "Articles")
And search and the active tab live in the URL query string (?q=, ?status=), so the filtered
view can be linked and survives a reload; the list is always sorted by last updated, newest
first, with no interactive sort control in this slice; search is case-insensitive, ignores
accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online,
Drafts) are always computed from the full list fetched by GET /v1/article/, so they never
change while the user is only searching
And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md),
both fed by the same articles array, loading flag and per-row busy state, and both routed
through the same row actions
And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online
or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date
beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute)
And clicking anywhere on a row except its menu button navigates to /article/:id/edit
And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named
"Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever
applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in
brick red, always last
And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on
success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title
"Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard
write itself fails, it raises an error toast instead (icon circle-x, id
"article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line
And row actions are never optimistic: a row only changes once the server responds; while a
publish or unpublish request for a row is in flight, that row alone is disabled and shows a
busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive
And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated
date in place and raises a success toast (icon circle-check, id
"article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id
toast convention): title "Article published" text "\"{title}\" is now online." for a
publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish
And a 500 or network failure on a publish or unpublish leaves that row exactly as it was
before the request and raises an error toast (icon circle-x, duration 0 so it stays until
dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"):
title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing
changed.", with an action "Retry" that re-sends the same request
And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete
dialog for that row, removes the row from the list, recomputes the tab counts from the
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside
the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.",
re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is
left untouched
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor
ends up
And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"),
id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter
checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original
floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit"
(online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts
Scenario: Default — the list loads sorted by last updated, newest first
Given GET /v1/article/ resolves 200 with the 6-article fixture above
When the user navigates to /article
Then the breadcrumb reads "Articles" (mobile title "Articles")
And the tabs read "All 6" "Online 4" "Drafts 2"
And the rows appear in exactly the fixture's order, each with its title link, its
ArticleStatusBadge and its updated date
And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated,
newest first"
Scenario: Loading — skeleton rows while the first request, or a retry, is in flight
Given GET /v1/article/ has not yet resolved
When the user navigates to /article
Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true"
When GET /v1/article/ then resolves 200 with the 6-article fixture
Then the skeleton rows are replaced by the Default state's rows
Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…
Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft,
updated "5 days ago")
When the user activates that row's ellipsis button
Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing
Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red
And every other row is unaffected
Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu
Then "{origin}/v1/article/4" is written to the clipboard
And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link
copied", text "The API URL for \"Winter checklist for timber windows\" is on your
clipboard."
Scenario: Copy API URL fails — the clipboard write itself is rejected
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails
Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't
copy the link", with no text line
Scenario: Empty — zero articles
Given GET /v1/article/ resolves 200 with zero articles
When the user navigates to /article
Then the tabs read "All 0" "Online 0" "Drafts 0"
And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles
yet", text "Write your first one. It stays a draft until you publish it.", and a primary
action "New article"
When the user activates "New article"
Then NewArticleModal opens
Scenario: No results — a search matches nothing, but the tab counts still show the full list
Given the Default state has loaded
When the user types "tiles" into the search field
Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon
search, title "No articles match \"tiles\"", text "Check the spelling, or search for a
shorter part of the title.", and an action "Clear search"
And the tabs still read "All 6" "Online 4" "Drafts 2"
When the user activates "Clear search"
Then the search field is cleared, the Default state's rows reappear, and focus returns to the
search field
Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly
Given GET /v1/article/ resolves 200 with <fixture>
When the user navigates to /article and selects the "<tab>" tab
Then the tabs read "<counts>"
And an EmptyState replaces the list: title "<title>" text "<text>" action "<action>"
Examples:
| fixture | tab | counts | title | text | action |
| 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article |
| 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts |
Scenario: Action in progress — publishing a row disables only that row
Given the Default state has loaded
When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu
Then PATCH /v1/article/4/publish is sent
And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows
stay interactive
Scenario: Published — a 200 updates the row in place and raises a toast
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just
now"
Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy
And a success toast appears: icon circle-check, id "article-published-4", title "Article
published", text "\"Winter checklist for timber windows\" is now online."
Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish <failure>
Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-publish-failed-4", title "Couldn't publish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/4/publish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu, and the request is in flight
When PATCH /v1/article/3/unpublish <failure>
Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/3/unpublish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast
Given the Default state has loaded
When the user <trigger> for id 2 "Choosing lime plaster for old stone walls"
And the request resolves 404
Then id 2's row is removed from the list (and any open menu or dialog for it closes)
And the tabs read "All 5" "Online 4" "Drafts 1"
And an info toast appears: icon info, id "article-not-found-2", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Examples:
| trigger |
| chooses "Publish" from its row menu |
| confirms "Delete article" in its ConfirmDialog |
Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast
Given the Default state has loaded
When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu
And the request resolves 404
Then id 3's row is removed from the list (and any open menu closes)
And the tabs read "All 5" "Online 3" "Drafts 2"
And an info toast appears: icon info, id "article-not-found-3", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Scenario: Delete confirm — opens the dialog with the row's title and image count
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete
article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows"
When the user activates "Delete article"
Then DELETE /v1/article/4 is sent
And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…"
with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it
Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 500
Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still
there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled
And id 4's row is untouched in the list behind the dialog
Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 204
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
+176
View File
@@ -0,0 +1,176 @@
# QA: Articles list screen
## Implementation note for coder (informational, not a QA assertion)
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
delete dialog's count. This is a persistence-layer change asserted by the existing
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
`article.Article.Images` already exists.
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
"article-link-copy-failed-{id}".
## HTTP-level (Go, `test/`, `task test`)
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
`validator.InitValidator()` first.
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
covered under Manual verification below.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
as listed), unless a scenario's Examples row substitutes a smaller fixture.
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
hidden caption reads "Articles, sorted by last updated, newest first".
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
state's rows replace the skeletons.
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
separator then "Delete…"; assert no other row is affected.
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
URL" from id 4's menu; assert the clipboard write was called with
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
URL for \"Winter checklist for timber windows\" is on your clipboard."
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
circle-x, title "Couldn't copy the link", and no description text.
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
action "New article"); click it; assert NewArticleModal opens.
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
click "Clear search"; assert the Default rows reappear and the search field has focus.
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
check below).
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
"Publishing…" and the other 5 rows stay enabled.
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
was raised with id "article-published-4", icon circle-check, title "Article published", text
"\"Winter checklist for timber windows\" is now online."
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
is never touched before the (failed) response, so there is nothing to revert; assert a toast
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
called again.
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
assert the same PATCH is called again.
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
somewhere else, so we removed it from the list."
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
"It was deleted somewhere else, so we removed it from the list."
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
with title "Delete this article?", text "\"Winter checklist for timber windows\" and its 3
images will be deleted. This can't be undone.", "Cancel" and "Delete article".
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
dialog.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
file).
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
columns (confirm against the board frame directly whether a Created column is also shown —
components.md's prose and the rendered board table disagree on this).
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
dropdown placement and the brick-red Delete… treatment.
- [ ] Loading: 5 skeleton rows shaped like the real layout.
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
prose extraction read for this slice).
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
"aria-busy".
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
app against this spec rather than a board frame.
- [ ] Row vanished: the info toast and the row's removal match the board.
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
including the inline alert's placement in the failed state.
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
move and activate); each row's title link is reachable and Enter opens the editor; each
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
on the next row (or the page heading if none).
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
reduced motion.
## Manual verification
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
handlers or use cases.
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
articles with none, against a running server, since the HTTP-level Go test above only proves the
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
it correctly.
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
an article's actual attached images end to end (upload images, then delete), since Vitest only
ever supplies a mocked count.
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.
@@ -0,0 +1,94 @@
Feature: AuthShell and log-in screen conform to the design spec
As the maintainer of RenewCMS
I want AuthShell and LoginView built from the exact primitives docs/design/components.md
specifies — AppLogo, UPageCard's own title/description styling, the muted page background,
UForm with "Forgot password?" in the Password field's #hint slot, and the neutral-by-default,
red-on-error input border
So that the log-in screen (and every other AuthShell-based screen) matches the visual spec of
record instead of ad hoc markup that happens to look similar
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And AuthShell composes a UPageCard variant="outline" as the centered card, with AppLogo
rendered as the card's first element — left-aligned above the title/description block,
sharing the card's own 24px gap — not as a separate element centered outside/above the
card, per docs/design/components.md's AuthShell section and
docs/design/source/AuthCard.dc.html
And AppLogo is a reusable component (props include size) rendering the brand mark from
docs/design/source/brand/favicon.svg as inline SVG, not an <img>
And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route
changes can move focus to it, per AuthShell's a11y spec), and its description renders
through UPageCard's description prop — neither is freeform markup in the default slot
And from the sm breakpoint up, the centered <main> sits on the muted page background
(--ui-bg-muted), the same canvas the rest of the app uses; below sm it sits on --ui-bg
instead, per docs/design/components.md's AuthShell "Responsive" line
And LoginView's form is a UForm wrapping the Username and Password UFormFields and the
submit button
And the Password UFormField's "Forgot password?" link renders through the field's #hint
slot, in the position the Auth board draws it — right-aligned on the label row — per
docs/design/source/RenewCMS Screens Auth.dc.html, group "Log in"
And every UInput on the page uses the neutral control-edge border (--ui-border-accented) by
default, and turns to the error edge (--ui-error) only when its field has an error, per
docs/design/components.md's Input section (":highlight bound to the field error") and
foundations.md's Borders and elevation
Scenario: AppLogo renders the brand mark as inline SVG, and theme-switches
Given a public screen renders inside AuthShell
When AuthShell mounts
Then an AppLogo renders as UPageCard's first element, inside the card, left-aligned, sharing
the card's 24px gap with the title/description block below it — not centered above the card
And the logo is inline SVG sourced from docs/design/source/brand/favicon.svg, at 32px, not as
an <img> tag
And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG)
And the logo is not wrapped in a link, since there is nothing public to go back to
And the black square has no edge stroke in light mode or in dark mode, per
docs/design/source/AuthCard.dc.html's renderVals(), which computes the logo's edge as the
literal, non-theme-branched value "transparent"
Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling
Given AuthShell renders with title "Log in" and description
"Welcome back. Log in to manage your articles."
When AuthShell mounts
Then the card is a UPageCard with variant="outline"
And UPageCard's title slot renders an h1 with tabindex="-1" containing "Log in"
And UPageCard's description prop is "Welcome back. Log in to manage your articles."
And neither the title nor the description is rendered as separate freeform markup inside
UPageCard's default slot
Scenario: The page sits on the muted background from sm up, and on the default background below sm
Given AuthShell renders any public screen at a viewport of sm or wider
When AuthShell mounts
Then the centered <main> uses the muted background token (--ui-bg-muted)
And the card keeps its border, is capped at 400px, and its top sits 12vh from the top
Scenario: Below sm, AuthShell drops the muted background, the card border and the card padding
Given AuthShell renders any public screen at a viewport below sm
When AuthShell mounts
Then the centered <main> uses the default background token (--ui-bg), not the muted one
And the card has no border and fills the screen with 20px padding
And the fields and buttons on the page render at the xl size
Scenario: The login form is a UForm
Given the user is on /login
When the login form renders
Then the form is a UForm, not a plain HTML <form>, wrapping the Username UFormField, the
Password UFormField and the submit button
Scenario: "Forgot password?" renders in the Password field's hint slot
Given the user is on /login
When the login form renders
Then the Password UFormField's "Forgot password?" link is rendered through the field's
#hint slot
And the Password field's label reads only "Password", with no link inside it
Scenario: Inputs are neutral by default and turn to the error edge only when their field has an error
Given the user is on /login with no validation errors yet
When the Username and Password UInput fields render
Then both use the neutral control-edge border (--ui-border-accented), with :highlight false
When the user submits with Username "ma" and Password empty (the Validation errors state,
per docs/design/screens/log-in.md)
Then the Username UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Use at least 3 characters."
And the Password UInput's edge turns to the error color (--ui-error), with :highlight true,
alongside the message "Enter your password."
@@ -0,0 +1,88 @@
# QA: AuthShell and log-in screen conform to the design spec
## Automated (Vitest + Vue Test Utils, `task test:web`)
No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView directly (LoginView
already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup,
reused here only as scaffolding, not as the thing under test).
1. **AppLogo renders inline SVG with no edge in either mode** — mount AuthShell with title
"Log in"; assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element,
left-aligned (not centered) above the title/description block, sharing the card's 24px gap, at
32px, with `role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer
square has no stroke in light mode; switch to dark mode (toggle the `.dark` class the way
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert the outer square still has no
stroke, per `docs/design/source/AuthCard.dc.html`'s literal, non-theme-branched
`edge: 'transparent'`.
2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and
description "Welcome back. Log in to manage your articles."; assert
`wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the
description text "Welcome back. Log in to manage your articles." renders via UPageCard's
`description` prop (not as a sibling `<p>` hand-authored in AuthShell's template). This
replaces the older assertion in `AuthShell.spec.ts` that only checked for any `<h1>`; keep that
assertion passing (h1 text still equals the title) while adding the tabindex check.
3. **Muted background at sm and up** — mount AuthShell at a `>= 640px` viewport (mock
`useBreakpoints`/matchMedia the way other shell tests do); assert `<main>`'s class list
includes the muted background utility (e.g. `bg-muted`); assert the card renders with a
border and is capped at 400px width, offset 12vh from the top.
4. **Default background, borderless card, 20px padding, xl controls below sm** — mount AuthShell
at a `< 640px` viewport; assert `<main>`'s class list includes the default background utility
(`bg-default`) and not the muted one; assert the card has no border class; assert the card's
padding is 20px; assert the Username/Password inputs and submit button render at `size="xl"`.
5. **Login form is a UForm** — mount LoginView at /login; assert the form wrapping the fields is
a UForm component instance, not a bare `<form>` element with only `@submit.prevent` wired by
hand.
6. **Forgot password in the #hint slot** — mount LoginView at /login; assert the Password
UFormField is passed content (or renders content) in its `hint` slot containing a
"Forgot password?" link to /register/reset/request; assert the field's rendered label text is
exactly "Password" with no link inside it.
7. **Inputs default to neutral, then highlight red on error** — mount LoginView at /login; assert
the Username and Password `UInput` components render with `highlight` falsy (or absent) and
the theme's default ring class (`ring-(--ui-border-accented)`); then trigger the Validation
errors state (type "ma" into Username, submit with Password empty); assert both `UInput`s now
receive `:highlight="true"` (or the field's error is otherwise truthy) so the edge renders in
`--ui-error`, alongside "Use at least 3 characters." and "Enter your password." respectively.
### Implementation finding for coder
`web/src/views/LoginView.vue`'s Username and Password `UInput`s currently receive no `:highlight`
prop at all — `UFormField` gets `:error="usernameError"` / `:error="passwordError"`, but that value
is never forwarded to the inner `UInput`. Today the edge never turns red in the Validation errors
state; this isn't a stale/inverted binding, it's a missing one. Wiring `:highlight="!!usernameError"`
/ `:highlight="!!passwordError"` (or equivalent) onto each `UInput` is the fix scenario 7 above
pins down.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", plus
`docs/design/source/RenewCMS Components Product.dc.html`'s AuthShell drawing and
`docs/design/source/RenewCMS Components Primitives.dc.html`'s Input drawing.
- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not
centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg
drawing exactly (white R on a black square), desktop and mobile; no edge stroke is visible
around the square in either light or dark mode.
- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing
and receives focus (visible focus ring) on route change; description text matches
"Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark.
- [ ] At sm and up: 400px card with its border, 12vh top offset, on the muted page canvas, light
and dark.
- [ ] Below sm: card fills the screen borderless with 20px padding on the default (not muted)
background; fields and the submit button render at the touch (xl, 44px) size, light and
dark.
- [ ] The Password field's "Forgot password?" link sits right-aligned on the label row (the hint
position), matching the Auth board, desktop and mobile, light and dark.
- [ ] Username and Password fields show the neutral control-edge border by default; in the
Validation errors state (short username, empty password) both fields' edges turn to the
error color alongside their messages, in both light and dark.
- [ ] Keyboard/focus: Tab order remains Username → Password → show/hide toggle → "Forgot
password?" → submit; UPageCard's h1 is reachable/focusable via tabindex="-1" on route
change, matching AuthShell's existing a11y spec.
## Manual verification
- **AppLogo reused in DashboardShell** — out of scope for this slice (AuthShell/login only).
`DashboardShell.vue`'s sidebar header is currently a plain `<span class="font-semibold">
RenewCMS</span>` (not AppLogo), and its footer is a bare `<span>Log out</span>` instead of the
UserMenu component components.md specifies. Both need their own "Built from" conformance pass in
a later DashboardShell slice; AppLogo (built here with a `size` prop) will be ready to drop in
at that point without changes.
+104
View File
@@ -0,0 +1,104 @@
Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
As the maintainer of RenewCMS
I want DashboardShell built from the exact primitives docs/design/components.md specifies —
AppLogo plus the "RenewCMS" wordmark in the sidebar header, and a UserMenu (UDropdownMenu on a
ghost UButton with a UAvatar) in the sidebar footer, with a collapsed rail that hides text but
never drops an accessible name
So that every verified route's sidebar matches the visual spec of record and stays usable when
collapsed, instead of the current plain-text "RenewCMS" header and "Log out" footer
Background:
Given DashboardShell is the layout for every verified route, per docs/design/routes.md
And DashboardShell composes a UDashboardGroup storage="local" with a UDashboardSidebar
collapsible :resizable="false" :default-size="14" :collapsed-size="3.5", per
docs/design/components.md's DashboardShell section
And the sidebar's #header slot renders AppLogo (the same reusable inline-SVG brand mark
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> the shell renders today
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order
And the sidebar's #footer slot renders a UserMenu component, not the plain
<span>Log out</span> text the shell renders today
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
ghost UButton block containing a UAvatar and the session's name (the display name when not
empty, else the username)
And the UAvatar's initials follow features/profile_page.feature (the first letters of the
first two words of a display name, the first two characters of a single word or of a
username, upper-cased; for example "marta" gives "MA"), and the avatar is aria-hidden
because the name is shown next to it
And UserMenu's groups, top to bottom, are: a header (the name and the email); Profile; a
checkbox group of System, Light and Dark bound to useColorMode().preference, with the
current preference's item checked; then Log out, in its own trailing group
And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
then calls session.clear() and navigates to /login — the exact same shape PendingView.vue
and VerifyView.vue already use for their own log-out actions, not a new resilience pattern
And per docs/design/components.md's DashboardShell a11y note, in the collapsed rail the
UNavigationMenu's items show tooltips and keep their aria-label; the wordmark and UserMenu
apply that same spirit when the sidebar collapses to :collapsed-size="3.5"
Scenario: The sidebar header renders AppLogo and the "RenewCMS" wordmark
Given the user is on any verified route with the sidebar expanded
When DashboardShell mounts
Then the sidebar's #header slot renders an AppLogo before the text "RenewCMS"
And the wordmark text "RenewCMS" is visible next to the logo
And neither the logo nor the wordmark is a link, since there is nothing to navigate to from
the shell itself
Scenario: The sidebar collapse trigger toggles the rail and exposes aria-expanded
Given the user is on any verified route with the sidebar expanded
When the user activates UDashboardSidebarCollapse in the navbar's leading slot
Then the sidebar transitions to its :collapsed-size="3.5" icon-only rail
And UDashboardSidebarCollapse carries aria-expanded, reflecting the sidebar's new collapsed
state
When the user activates UDashboardSidebarCollapse again
Then the sidebar returns to its :default-size="14" expanded width
And aria-expanded reflects the expanded state
Scenario: UserMenu lists the header, Profile, color-mode checkboxes, then Log out
Given the session's username is "marta" and its email is "marta@renew.example"
When the user activates the UserMenu trigger in the sidebar footer
Then the menu opens with a UAvatar showing initials "MA" and the trigger's own name text
"marta"
And the first group is the header showing "marta" and "marta@renew.example"
And the next item is "Profile"
And the following group is a checkbox group with items System, Light and Dark, and exactly the
item matching the current useColorMode().preference is checked
And the final group contains only "Log out", separated from the color-mode group
Scenario Outline: Selecting a color-mode item updates the preference and its checked state
Given the user has opened the UserMenu dropdown
And the current useColorMode().preference is "<from>"
When the user activates the "<to>" checkbox item
Then useColorMode().preference is set to "<to>"
And the "<to>" item is checked and the "<from>" item is no longer checked
Examples:
| from | to |
| system | light |
| light | dark |
| dark | system |
Scenario: A successful logout clears the session and navigates to /login
Given the user has opened the UserMenu dropdown
When the user activates "Log out"
Then a POST request is made to /v1/auth/logout
And once it resolves, the local session is cleared
And the user is navigated to /login
Scenario: Collapsed sidebar hides the wordmark and username text but keeps an icon-only rail
Given the user is on any verified route
When the sidebar is collapsed to its :collapsed-size="3.5" icon-only rail
Then the AppLogo icon in the sidebar header remains visible, but the "RenewCMS" wordmark text
is hidden, not merely visually truncated
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text
reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
regression
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the name text
next to it is hidden
And the UserMenu trigger keeps an accessible name that includes the name (an aria-label
such as "marta — account menu", or a tooltip plus aria-label), matching the same
tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
docs/design/components.md's DashboardShell a11y note
And activating the collapsed UserMenu trigger still opens the same menu, with the same
header, Profile, color-mode checkbox group and Log out item as the expanded state
+65
View File
@@ -0,0 +1,65 @@
# QA: DashboardShell branding (wordmark, sidebar collapse, UserMenu)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount DashboardShell (or a verified route rendered inside it) with a resolved session (username
"marta") and a test router. Mock `useApi`'s `/v1/auth/logout` call per case; no real backend, no
httptest server. Stub `useColorMode()` so its `preference` can be asserted and set.
1. **Header renders AppLogo and the wordmark** — mount DashboardShell expanded; assert the
sidebar's #header slot renders an AppLogo followed by the text "RenewCMS"; assert neither is a
link.
2. **Sidebar collapse trigger toggles the rail and aria-expanded** — mount DashboardShell
expanded; activate UDashboardSidebarCollapse; assert the sidebar reaches its
:collapsed-size="3.5" state and the trigger's aria-expanded reflects it; activate it again; assert
the sidebar returns to :default-size="14" and aria-expanded flips back.
3. **UserMenu dropdown groups and order** — mount with username "marta" and
`useColorMode().preference` set to each of "system", "light", "dark" in turn; open the UserMenu
dropdown; assert the avatar shows initials "MA"; assert the trigger shows "marta"; assert the
first group is the account label "marta"; assert the second group has System, Light and Dark
checkbox items with exactly the current preference's item checked; assert the third, separate
group contains only "Log out".
4. **Selecting a color-mode item updates the preference** — Examples-driven: (system → light),
(light → dark), (dark → system). Open the dropdown, activate the target item, assert
`useColorMode().preference` is set to the target and its checkbox item is checked while the
previous one is not.
5. **Successful logout clears the session and navigates** — mock `POST /v1/auth/logout` resolving
200; open the UserMenu dropdown; activate "Log out"; assert the mock was called; assert the
session store's `clear()` is called once it resolves; assert the router navigates to /login.
6. **Collapsed sidebar hides wordmark/username text but keeps an accessible name** — mount
DashboardShell and collapse the sidebar to :collapsed-size="3.5"; assert the AppLogo icon is still
present but the "RenewCMS" text node is not rendered (or is visually hidden, e.g. sr-only) while
an accessible name for the logo (aria-label or sr-only text reading "RenewCMS") is still present
in the accessibility tree; assert the UserMenu trigger still renders the UAvatar but not the
visible "marta" text node, while the trigger's accessible name still includes "marta" (via
aria-label or a tooltip plus aria-label); activate the collapsed trigger and assert the same
dropdown (account label, color-mode group, Log out) opens as in the expanded state.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/components.md`'s DashboardShell section and
`docs/design/source/RenewCMS Screens Back-office.dc.html`. Run `task visual:check -- <a verified
screen, e.g. home>` for each state and compare the sidebar header/footer against the board frame.
- [ ] Expanded sidebar: AppLogo plus "RenewCMS" wordmark in the header; UserMenu in the footer
shows the avatar and username, matching the board exactly at desktop and mobile.
- [ ] UserMenu dropdown: opens above the trigger (`side: 'top'`), left-aligned (`align: 'start'`),
with the account label, the System/Light/Dark checkbox group and Log out visually separated
as in the board.
- [ ] Color mode selection: choosing Light/Dark/System visibly re-themes the app and moves the
checkmark to the selected item.
- [ ] Collapsed sidebar (icon-only rail): the AppLogo icon and the avatar remain visible; the
"RenewCMS" wordmark and the username text are hidden; nothing overflows or clips awkwardly at
the :collapsed-size="3.5" width, matching the board's collapsed rail treatment.
- [ ] Keyboard/focus: Tab reaches UDashboardSidebarCollapse, then the UserMenu trigger; the
dropdown opens with Enter/Space, arrow keys move through the checkbox group, and Esc closes
it and returns focus to the trigger, in both the expanded and collapsed rail.
## Manual verification
- **Real POST /v1/auth/logout against a running server** — Vitest only mocks `useApi`; confirm the
real 200 round-trip against `task dev` with a live SQLite file, since these automated tests
never exercise the actual Go handler.
- **Real useColorMode persistence across reload** — confirm a selected preference survives a full
page reload via the plugin's `colorMode: true` storage, since Vitest only asserts the in-memory
preference value.
+242
View File
@@ -0,0 +1,242 @@
Feature: Editor screen — load, edit and save an article's body
As a verified RenewCMS owner
I want /article/:id/edit to load an article, let me edit its body, save it, and warn me
before I lose unsaved work
So that I can safely write and revise an article's content, with clear feedback while
saving, near the length limit, offline, or when my session expires mid-edit
Background:
Given /article/:id/edit is a verified route (meta.auth "verified") rendered as EditorPage
inside DashboardShell, per docs/design/routes.md
And EditorPage calls GET /v1/article/{id} once per navigation to load the article, per
docs/design/screens/editor.md and features/public_article_read.feature
And the header shows breadcrumb "Articles › {article.title}" linking "Articles" to
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
and a PublishControl button, per docs/design/components.md
And the article's title is never editable after creation, per
features/article_title_rules.feature; only the body is edited here
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
the article is a draft; clicking PublishControl itself is out of scope for this slice and
is specified in a follow-on slice (editor_publish_and_delete)
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
it is disabled in every other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
the same contract features/edit_article_body.feature already gives the API: 200 with the
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
an hour has passed, per docs/design/components.md
And a 500 response or a request that never reaches the server sets useArticleDraft's state
to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs"
variant="outline"); the edited body is kept exactly as typed, both in the editor and for
the next Retry
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
HTTP-status-to-copy table
And a 401 response to a save is handled by useApi's global handling
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
the redirect to /login?redirect=/article/{id}/edit&reason=expired
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
and disables both Save and PublishControl until the length is 10000 or fewer again
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
read-only rendering of the title and body in the content type scale; switching it off
restores the editable surface with the same content, unchanged
And when navigator.onLine is false, or the browser fires a window "offline" event,
useArticleDraft's state becomes "offline"; SaveStatus shows the wifi-off icon and reads
"Waiting for connection"; Save and PublishControl are disabled with a tooltip explaining
why, and DashboardShell's existing OfflineBanner is shown; when the connection returns (a
window "online" event, or navigator.onLine true again), Save and PublishControl re-enable
according to the underlying dirty/saved/error state, which is unaffected by having been
offline
And navigating away (the breadcrumb "Articles" link, a sidebar nav link, the browser back
button, or closing the tab) while useArticleDraft's state is "dirty" opens ConfirmDialog
(neutral tone) titled "Leave without saving?", text 'Your changes to "{article.title}"
haven't been saved.', cancel "Discard changes", confirm "Keep editing"; "Discard changes"
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
keeps the user on the editor with the edit intact; navigating away while "clean" or
"saved" never opens this dialog
And an id absent from the repository (GET resolves 404) replaces the entire editor with an
error state: title "This article doesn't exist.", and a single action "All articles"
navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or
ArticleEditor renders in this state
And skeleton placeholders (for the title, content and details) replace the editor's content
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
flashes a skeleton, per the Skeleton component spec
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /article/12/edit for the first time in this session
When GET /v1/article/12 has not resolved 150 ms after the navigation
Then skeleton placeholders replace the title, content and details areas
When GET /v1/article/12 then resolves 200 with a draft article titled "How we price a
bathroom renovation"
Then the skeletons are replaced by the Saved state's header, editor and details
Given a second, separate navigation to /article/12/edit
When GET /v1/article/12 resolves in under 150 ms
Then no skeleton is ever shown, and the Saved state's content renders directly
Scenario Outline: Saved — a freshly loaded article, draft or online
Given GET /v1/article/12 resolves 200 with an article titled "How we price a bathroom
renovation", is_online <is_online>, body length 4318, updated_at "Today, 11:02"
When the user navigates to /article/12/edit
Then the breadcrumb reads "Articles › How we price a bathroom renovation"
And the ArticleStatusBadge reads "<badge>"
And SaveStatus reads "Saved at 11:02"
And the Save button is disabled
And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000"
Examples:
| is_online | badge | publish_label |
| false | Draft | Publish |
| true | Online | Unpublish |
Scenario: Unsaved changes — any edit marks the article dirty
Given the user is on /article/12/edit in the Saved state, article "How we price a bathroom
renovation", draft
When the user types into the body
Then SaveStatus reads "Unsaved changes"
And the Save button becomes enabled
And PublishControl now reads "Save and publish"
Scenario: Saving — Save sends the PATCH and disables itself while in flight
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
And the Save button is disabled
Scenario: Ctrl/⌘ S saves the same way as clicking Save
Given the user is on /article/12/edit with unsaved changes to the body
When the user presses Ctrl+S (or ⌘+S)
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
Scenario: A successful save returns to the Saved state with the new time
Given the user is on /article/12/edit in the Saving state
When PATCH /v1/article/12 resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
And the Save button is disabled again
Scenario: SaveStatus switches to a relative time after an hour
Given the user is on /article/12/edit and PATCH /v1/article/12 resolved 200 at 11:02
When more than an hour has passed with no further edit or save
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
paragraph."
When the user activates Save
And PATCH /v1/article/12 <failure>
Then SaveStatus reads "Save failed" with a Retry button
And the editor still shows the body "Revised opening paragraph."
And no navigation occurs
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retry resends the same body and can recover
Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised
opening paragraph." and PATCH /v1/article/12 having failed with a 500
When the user activates Retry
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
When it resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
Scenario: A save against an article deleted elsewhere shows a toast, not the error SaveStatus
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
And PATCH /v1/article/12 resolves 404
Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed"
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters
Then the character counter reads "9 240 / 10 000" with a warning icon
And the Save button remains enabled
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
10214 characters
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
Shorten the text or remove an image. Formatting counts too."
And the Save button is disabled
And PublishControl is disabled
When the user shortens the body to 10000 characters or fewer
Then the character counter no longer shows the over-limit text
And the Save button is enabled again
Scenario: Preview shows the rendered title and body without the editing surface
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
with some body content
When the user switches Preview on
Then the editing toolbar and the editable surface are replaced by a read-only rendering of
the title and body in the content type scale
When the user switches Preview off
Then the editable ArticleEditor and toolbar return, with the same body content unchanged
Scenario: Offline blocks Save and Publish, and recovers when the connection returns
Given the user is on /article/12/edit with unsaved changes to the body
When navigator.onLine becomes false
Then SaveStatus shows the wifi-off icon and reads "Waiting for connection"
And the Save button and PublishControl are disabled with a tooltip explaining why
When navigator.onLine becomes true again
Then the Save button re-enables, reflecting the still-unsaved change
And SaveStatus reads "Unsaved changes" again
Scenario: Leaving with unsaved changes prompts, and "Discard changes" proceeds
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates the "Articles" breadcrumb link
Then ConfirmDialog opens, titled "Leave without saving?", text 'Your changes to "How we
price a bathroom renovation" haven't been saved.', cancel "Discard changes", confirm
"Keep editing"
When the user activates "Discard changes"
Then the app navigates to /article
And the edited body is not saved
Scenario: Leaving with unsaved changes, "Keep editing" stays on the page
Given the user is on /article/12/edit with unsaved changes to the body, and the "Leave
without saving?" dialog is open
When the user activates "Keep editing"
Then the dialog closes
And the user remains on /article/12/edit with the unsaved body intact
Scenario: Leaving a clean article never prompts
Given the user is on /article/12/edit in the Saved state, with no unsaved changes
When the user activates the "Articles" breadcrumb link
Then the app navigates to /article directly, with no ConfirmDialog
Scenario: Session expired mid-save keeps the draft and restores it after logging back in
Given the user is on /article/12/edit with unsaved changes to the body "Paragraph added
just before the session expired."
When the user activates Save
And PATCH /v1/article/12 resolves 401
Then the body "Paragraph added just before the session expired." is mirrored to
sessionStorage keyed by article id 12
And the app navigates to /login?redirect=/article/12/edit&reason=expired
When the user logs in again and is returned to /article/12/edit
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
Then the editor shows the body "Paragraph added just before the session expired." restored
from sessionStorage, not the last-saved body from the GET
And SaveStatus reads "Unsaved changes"
Scenario: An unknown or deleted article id replaces the editor with an error state
Given GET /v1/article/9999 resolves 404
When the user navigates to /article/9999/edit
Then the editor shows a single error state: title "This article doesn't exist." and one
action "All articles"
And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders
When the user activates "All articles"
Then the app navigates to /article
+129
View File
@@ -0,0 +1,129 @@
# QA: Editor screen — load, edit and save (core)
## Implementation note for coder (informational, not a QA assertion)
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
a new component, so it doesn't need its own slice.
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
response alone, and correctly shows the "This article doesn't exist." state.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no
real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified
session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the
150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked
`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios.
Fixture article (reused across cases unless a scenario overrides a field): id 12, title
"How we price a bathroom renovation", is_online false, body length 4318, created_at
"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now`
"2026-09-26T11:02:00Z" unless a scenario advances it).
1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton
before 150 ms; advance past it; assert skeleton placeholders for title/content/details;
resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no
skeleton ever renders.
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus
"Saving…", Save disabled while unresolved.
5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert
SaveStatus "Saved at {time}" and Save disabled again.
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
shows the typed body.
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
the same body; resolve 200; assert SaveStatus "Saved at {time}".
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
toolbar and editable surface are gone and a read-only rendering of title+body appears;
switch off; assert the editable surface returns with the same content.
13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and
dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting
for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine`
true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes"
again.
14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles"
breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click
"Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires.
15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog
closes and the router does not navigate, and the unsaved body is still shown.
16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link;
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
(with the paragraph) and SaveStatus reads "Unsaved changes".
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and
compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today,
11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation";
3 images).
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible.
- [ ] Not found (404): the standalone error state, no editor chrome.
- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details
panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb.
- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a
sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard
ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and
Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live
region announces only when crossing the warn/over thresholds, not on every keystroke.
- [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and
stop under reduced motion.
## Manual verification
- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm
the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual
session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite
file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a
real cookie's `exp` claim.
- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a
real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom
doesn't model it.
+131
View File
@@ -0,0 +1,131 @@
Feature: Crop and resize step in the editor's image uploader
As a verified RenewCMS owner adding an image to an article
I want to crop and resize a JPEG, PNG or WEBP before it uploads
So that I control the framing and file size of images in my articles, without needing an
external editor
Background:
Given ImageUploader inserts a crop-and-resize step between the idle file picker and the
upload, for any JPEG, PNG or WEBP that already passed the browser validation described in
features/editor_image_upload.feature; that feature's idle, dragging-over, rejected,
uploading, inserted and failed states and its POST /v1/article/{articleId}/image contract
are unchanged and out of scope here
And an SVG file skips this step entirely and uploads immediately, as before
And opening the step retitles the modal from "Add an image" to "Crop and resize", with the
description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB")
And the stage shows the chosen image at its natural pixel dimensions with a crop frame that
starts as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px
corner handles, and a mono pixel-size label in a dark pill at the frame's bottom-left
corner, tracking the frame's current size
And the frame is reachable by Tab (tabindex="0"), aria-label "Crop area. Arrow keys move it,
Shift and arrow keys resize it."; dragging the frame moves it, dragging a handle resizes
it, arrow keys nudge it by 1 px, and Shift+arrow keys resize it by 1 px
And above the frame, "Aspect ratio" is a radiogroup of five pills — Original, Free, 16:9,
4:3, 1:1 — next to "Rotate" and "Reset" actions; "Width" is a second radiogroup — Original,
1600 px (default), 1200 px, 800 px
And a mono line below reads "Uploads as {width} × {height} px · about {size}" (KB under
1 MB, MB at or above it), recomputed whenever the ratio, width, frame rect or rotation
changes
And the output width is min(the chosen width preset, the crop frame's own pixel width) —
never upscaled — and the output height keeps the crop frame's own aspect ratio; when the
crop is narrower than the chosen width, an info row reads "The crop is {crop width} px
wide, so it's uploaded at that size. Enlarging it would make it blurry."
And the footer offers "Choose another file" (discards this step and returns to the idle file
picker with no adjustments carried over) and "Upload image" (uploads the cropped, resized
file through the unchanged POST /v1/article/{articleId}/image flow)
And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and
width presets apply to the post-rotation image
And "Reset" returns the ratio to Original, the width to 1600 px, the crop frame to the whole
unrotated image, and the rotation to 0°, without closing the modal
Scenario: A raster file opens the crop-and-resize step instead of uploading immediately
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB,
natural dimensions 4032 × 3024 px
Then no request is sent to POST /v1/article/12/image
And the modal retitles to "Crop and resize" with the description "north-wall.jpg · 3.9 MB"
And the crop frame starts as the whole image, ratio "Original" is selected, and width
"1600 px" is selected
And the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
And the footer reads "Choose another file" and "Upload image"
Scenario: An SVG skips the crop step and uploads immediately
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "diagram.svg", content type "image/svg+xml", size 40 KB
Then POST /v1/article/12/image is sent immediately with that file
And the crop-and-resize step is never shown
Scenario Outline: Choosing an aspect ratio locks the frame to it
Given the crop-and-resize step is open for "north-wall.jpg" (4032 × 3024 px), ratio
"Original", width "1600 px"
When the user selects ratio "<ratio>"
Then the crop frame becomes <crop_width> × <crop_height> px, centered on the image
And dragging or resizing the frame afterwards keeps that aspect ratio
And the mono line reads "Uploads as <out_width> × <out_height> px · about {size}"
Examples:
| ratio | crop_width | crop_height | out_width | out_height |
| 16:9 | 4032 | 2268 | 1600 | 900 |
| 4:3 | 4032 | 3024 | 1600 | 1200 |
| 1:1 | 3024 | 3024 | 1600 | 1600 |
Scenario: Choosing a narrower width preset recomputes the output without upscaling
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original", width
"1600 px"
When the user selects width "800 px"
Then the mono line reads "Uploads as 800 × 600 px · about {size}", a smaller estimate than
before
Scenario: A crop narrower than the chosen width is uploaded at its own size, not upscaled
Given the crop-and-resize step is open for "north-wall.jpg", ratio "1:1", width "1600 px"
When the user resizes the crop frame down to 640 × 640 px
Then the mono line reads "Uploads as 640 × 640 px · about {size}"
And an info row reads "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
Scenario: Free ratio removes the aspect-ratio constraint
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original"
When the user selects ratio "Free"
Then the crop frame can be dragged or resized to any width and height independently, no
longer snapping to a fixed ratio
Scenario: Arrow keys move the crop frame and Shift+arrow keys resize it
Given the crop-and-resize step is open for "north-wall.jpg" with the crop frame focused
When the user presses ArrowRight
Then the frame moves 1 px right and the mono line's size is unchanged
When the user presses Shift+ArrowRight
Then the frame's width grows by 1 px and the mono line updates to the new output size
Scenario: Rotate cycles the image in 90° steps and Reset returns to the opened defaults
Given the crop-and-resize step is open for "north-wall.jpg" at ratio "16:9", width "800 px"
When the user activates "Rotate"
Then the image and crop frame rotate 90° clockwise
When the user activates "Reset"
Then ratio returns to "Original", width returns to "1600 px", the frame returns to the
whole, unrotated image, and the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
Scenario: "Choose another file" returns to the file picker without uploading
Given the crop-and-resize step is open for "north-wall.jpg"
When the user activates "Choose another file"
Then no request is sent to POST /v1/article/12/image
And the idle state is shown again, with the file picker available
Scenario: A newly chosen file opens a fresh crop step, not the previous file's adjustments
Given the user returned to idle via "Choose another file" from a crop step that had ratio
"16:9" and width "800 px"
When the user chooses a new file named "sunset.jpg", content type "image/jpeg", size 2.1 MB,
natural dimensions 3000 × 2000 px
Then the crop-and-resize step reopens with ratio "Original", width "1600 px", and the frame
as the whole new image
Scenario: "Upload image" uploads the cropped, resized file, not the original
Given the crop-and-resize step is open for "north-wall.jpg" with ratio "Original", width
"1600 px" and the mono line "Uploads as 1600 × 1200 px · about 420 KB"
When the user activates "Upload image"
Then POST /v1/article/12/image is sent with a single file under the field name "file" whose
pixel dimensions are 1600 × 1200 and whose size is about 420 KB, not the original
4032 × 3024, 3.9 MB file
And the uploading state shows "Uploading 0.0 of 420 KB"
When the upload reports 180 KB sent of 420 KB
Then the uploading state shows "north-wall.jpg 43%" and "Uploading 180 of 420 KB"
+86
View File
@@ -0,0 +1,86 @@
# QA: Crop and resize step in the editor's image uploader
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the editor_core.qa.md fixture
article (id 12, "How we price a bathroom renovation", is_online false), GET already resolved
200. Reuse editor_image_upload.qa.md's fake-XHR setup for the upload itself. Stub the chosen
file's decoded natural size (e.g. mock `Image`/`createImageBitmap` or whatever the component
uses to read `naturalWidth`/`naturalHeight`) so tests control it deterministically, and stub the
crop/resize step's own output producer (whatever function turns the frame rect + width preset
into a Blob) as a spy, asserting it is called with the frame's rect in natural-image pixels and
the resolved output width/height, and stubbing its returned Blob's size — without asserting how
it renders internally (canvas/OffscreenCanvas is a coder implementation detail).
1. **A raster file opens "Crop and resize" instead of uploading** — choose `north-wall.jpg`
(image/jpeg, 3.9 MB, natural 4032×3024); assert no XHR is created; assert the modal title is
"Crop and resize", description "north-wall.jpg · 3.9 MB", ratio "Original" and width
"1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose
another file" / "Upload image".
2. **An SVG bypasses the step** — choose `diagram.svg` (image/svg+xml, 40 KB); assert an XHR to
`/v1/article/12/image` is created immediately and the crop step never renders.
3. **Ratio presets lock the frame, Examples-driven (16:9 / 4:3 / 1:1)** — from the opened step,
select each ratio pill; assert the frame's reported rect and the mono line's output size match
the feature's Examples table.
4. **Width preset recomputes the output** — select width "800 px"; assert the mono line reads
"Uploads as 800 × 600 px" (assert dimensions exactly; assert only that the size figure changed
to a smaller value, not a specific number).
5. **A crop narrower than the width shows the info row and keeps its own size** — resize the
frame (via its resize API/spy) to 640×640 with ratio "1:1", width "1600 px"; assert the mono
line reads "Uploads as 640 × 640 px · about {size}" and the info row reads exactly "The crop
is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
6. **Free ratio removes the constraint** — select ratio "Free"; assert a subsequent resize call
with independent width/height is accepted without being coerced back to a ratio.
7. **Arrow and Shift+Arrow keys move/resize** — focus the frame; fire `ArrowRight`; assert the
frame's x increases by 1 px and the mono line's size is unchanged; fire `Shift+ArrowRight`;
assert the frame's width increases by 1 px and the mono line updates.
8. **Rotate cycles 90° and Reset restores defaults** — activate "Rotate"; assert the reported
rotation is 90°; activate "Reset"; assert ratio "Original", width "1600 px", the frame back to
the whole unrotated image, and the mono line "Uploads as 1600 × 1200 px · about 420 KB".
9. **"Choose another file" discards the step** — activate it; assert no XHR was created and the
idle state renders.
10. **A newly chosen file reopens at defaults** — from idle after "Choose another file" out of a
16:9/800px step, choose `sunset.jpg` (image/jpeg, 2.1 MB, natural 3000×2000); assert the new
crop step opens with ratio "Original", width "1600 px", frame as the whole new image (not the
previous file's ratio/width).
11. **"Upload image" sends the cropped/resized file** — from the default 1600×1200/420 KB step,
activate "Upload image"; assert the XHR is created with a file whose declared size is 420 KB
(not 3.9 MB); assert the uploading state shows "Uploading 0.0 of 420 KB"; fire an
`upload.progress` with `loaded: 180 * 1024, total: 420 * 1024`; assert "north-wall.jpg 43%"
and "Uploading 180 of 420 KB".
## Visual (manual verification via `task visual:check -- editor` — desktop and mobile, light and
dark)
Reference: `docs/design/screens/editor.md` states "Upload: crop and resize", "Upload: crop to a
ratio", "Upload: crop narrower than the width", and the updated "Upload: in progress"; board
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Editor". Add each state's mock
setup to `web/visual/states.ts`, reusing editor_core.qa.md's fixture article (id 12, "How we
price a bathroom renovation", draft) and the sample file "north-wall.jpg · 3.9 MB".
- [ ] Upload: crop and resize — modal "Crop and resize" / "north-wall.jpg · 3.9 MB", ratio
"Original", width "1600 px", cancel "Choose another file", confirm "Upload image".
- [ ] Upload: crop to a ratio — same modal, ratio "16:9" selected, frame locked to that ratio.
- [ ] Upload: crop narrower than the width — ratio "1:1", width "1600 px", frame resized to
640×640, info row "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
- [ ] Upload: in progress (updated) — modal "Add an image", uploading "north-wall.jpg 43%" /
"Uploading 180 of 420 KB", cancel "Cancel upload".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor modals.
- [ ] Keyboard/focus: the crop frame is reachable by Tab; its `aria-label` is "Crop area. Arrow
keys move it, Shift and arrow keys resize it."; Arrow/Shift+Arrow move and resize it without
moving focus elsewhere; the aspect-ratio and width radiogroups follow standard roving-tabindex
radiogroup keyboard behavior (arrow keys between pills, one tab stop per group); "Rotate" and
"Reset" are reachable and named by their visible text.
- [ ] Motion: no auto-close or timed transition in this step; frame drag/resize follows pointer
with no easing delay (not part of the 10 % capture budget, spot-checked only).
## Manual verification
- **Real image decoding and resizing** — Vitest stubs `naturalWidth`/`naturalHeight` and the
output producer; confirm real JPEG/PNG/WEBP files of varying orientations and EXIF rotation
crop, rotate and resize correctly against `task dev` with a live server, since jsdom has no
real canvas/image pipeline.
- **Real POST of the resized file** — confirm the actual uploaded bytes decode to the expected
pixel dimensions server-side, complementing `features/upload_article_image.feature`'s
httptest coverage of the handler in isolation.
+148
View File
@@ -0,0 +1,148 @@
Feature: Removing an inline image from the editor
As a verified RenewCMS owner writing an article
I want to remove an image I've already inserted into the article body,
either through an explicit delete button or by pressing Backspace/Delete
on the selected image, directly from the editor
So that a mistaken or unwanted image doesn't have to stay in the article
forever, and removing it behaves like every other destructive action in
RenewCMS: it asks for confirmation first, however the user starts it
Background:
Given docs/design/ has no board or state for removing an inline image;
this feature was added at the user's explicit request ahead of design
coverage, reusing the editor's existing visual language rather than
inventing new components: the trash icon and error color already used
by the article "Delete" button, and the ConfirmDialog component
already used by "Delete this article?" (docs/design/screens/editor.md's
"Delete: confirm" state)
And this slice changes ArticleEditor's exposed insertImage(src, alt) to
insertImage(src, alt, imageId), stamping the inserted <img> node with
the image's numeric id as a data-image-id attribute, persisted in the
saved article body HTML; EditorView passes the id it now receives from
a successful upload (features/remove_article_image.feature's change to
POST .../image's 201 body) through to insertImage
And an image already present in an article's body from before this slice
has no data-image-id attribute and so shows no delete affordance and
is not intercepted by Backspace/Delete, until it is removed and
re-added
And selecting an image node that does carry a data-image-id attribute
shows a small delete button anchored to the image's top-right corner,
icon i-lucide-trash-2, color error, matching the article Delete
button's icon and color; deselecting the image (clicking elsewhere)
hides it
And there are two equivalent triggers for the same removal flow:
activating that delete button, and pressing Backspace or Delete while
the image node is selected (a Tiptap NodeSelection on the image).
Tiptap's native node deletion on Backspace/Delete is intercepted
before it reaches the document (e.g. via a keyboard-shortcut/keydown
handler registered on the Image node or at the editor level, checked
first while an image node is selected) so the key press never removes
the node directly; instead it triggers exactly the same flow as the
button
And either trigger emits ArticleEditor's new "request-remove-image"
event with the node's numeric image id; ArticleEditor does not call
the API or show any dialog itself, mirroring how "request-image"
already only opens ImageUploader from EditorView
And EditorView reacts to "request-remove-image" by opening the existing
ConfirmDialog component with title "Remove this image?", description
"This image will be removed from the article. This can't be undone.",
confirmLabel "Remove image", cancelLabel "Cancel", pendingLabel
"Removing…", tone "danger" — the same component and tone already used
for "Delete this article?"
And ConfirmDialog's action calls DELETE /v1/article/{articleId}/image/
{imageId} through useApi (not the raw XMLHttpRequest used for upload,
since there is no progress to observe)
And on success (204) the action removes the image node from
ArticleEditor's content via a new exposed removeImage(imageId) method,
which moves useArticleDraft's state to "dirty" the same way any other
body edit does; ConfirmDialog then closes and a toast appears: color
success, icon i-lucide-circle-check, title "Image removed",
description "Save to keep this change."
And on any failure (404 for either the article or the image, 500, or a
network failure) the action throws, so ConfirmDialog shows its own
inline error "Couldn't remove the image, so it's still there. Try
again in a moment." and stays open; the image node is not removed and
the draft's dirty state is unchanged
And a 401 is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of
scope for new scenarios here
And once the confirm dialog is open, its cancel/confirm/failure
behaviour (covered below) is identical regardless of which trigger
opened it, so those scenarios are written once, against the button
trigger, and are not duplicated per trigger
Scenario: Selecting an inserted image reveals its delete button
Given the user is on /article/12/edit with an image in the body carrying
data-image-id "10"
When the user clicks that image
Then a delete button (trash icon, error color) appears at the image's
top-right corner
When the user clicks elsewhere in the editor
Then the delete button is no longer shown
Scenario Outline: Either trigger opens the confirm dialog and sends no request yet, without letting Tiptap delete the node itself
Given the image with data-image-id "10" is selected on /article/12/edit
When the user <triggers-removal>
Then a dialog titled "Remove this image?" opens, with description "This
image will be removed from the article. This can't be undone.",
cancel "Cancel" and confirm "Remove image"
And no request has been sent to DELETE /v1/article/12/image/10
And the image node is still present in the editor's content (Tiptap's
native node deletion did not run)
Examples:
| triggers-removal |
| activates the image's delete button |
| presses Backspace with the image node selected |
| presses Delete with the image node selected |
Scenario: Cancelling the confirm dialog changes nothing
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Cancel"
Then the dialog closes
And no request has been sent to DELETE /v1/article/12/image/10
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Scenario: Confirming removes the image, marks the draft dirty, and shows a success toast
Given the confirm dialog is open for image id 10 on /article/12/edit,
the draft in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
Scenario Outline: A failed removal keeps the image and shows the dialog's inline error
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Remove image"
And DELETE /v1/article/12/image/10 <failure>
Then the dialog stays open, showing the inline error "Couldn't remove
the image, so it's still there. Try again in a moment."
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Examples:
| failure |
| resolves with a 404 response |
| resolves with a 500 response |
| never reaches the server |
Scenario: Confirming a removal started by Backspace/Delete behaves exactly like confirming one started by the button
Given the confirm dialog is open for image id 10 on /article/12/edit,
opened by pressing Backspace with the image node selected, the draft
in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
+88
View File
@@ -0,0 +1,88 @@
# QA: Removing an inline image from the editor
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, "How we price a bathroom
renovation", is_online false), GET already resolved 200, and a body
containing `<img src="/static/uploadedImages/before.png" data-image-id="10"
alt="">`. Mock `useApi`'s `request` for `DELETE /v1/article/12/image/10`
(fetch-based, not XHR — reuse whatever mock
`api_client_global_error_handling.qa.md` already sets up for `request`).
1. **Selecting the image reveals the delete button** — click the image
node; assert a delete button (trash icon, error color) renders anchored
to it; click elsewhere in the editor; assert the button is gone.
2. **Activating the delete button opens the confirm dialog** — select the
image, activate its delete button; assert a dialog titled "Remove this
image?", description "This image will be removed from the article. This
can't be undone.", cancel "Cancel", confirm "Remove image"; assert no
request was sent yet; assert the image node is still present (Tiptap's
native deletion did not run).
3. **Backspace on the selected image opens the same confirm dialog instead
of deleting the node** — select the image node (NodeSelection), fire a
`keydown` for `Backspace`; assert the same dialog as step 2 opens;
assert no request was sent yet; assert the image node is still present
in the editor's document (i.e. the keydown was intercepted before
Tiptap's default node-deletion ran, not just undone afterwards).
4. **Delete on the selected image opens the same confirm dialog instead of
deleting the node** — same as step 3 but firing `keydown` for `Delete`.
5. **Cancelling changes nothing** — from the open dialog (opened via the
button), click "Cancel"; assert the dialog closes, no request was sent,
the image node is still in the editor's content, and SaveStatus/dirty
state is unchanged.
6. **Confirming removes the image, marks dirty, and toasts (button
trigger)** — from the open dialog (Saved state), click "Remove image";
assert a DELETE request to `/v1/article/12/image/10` was made; resolve
it 204; assert the dialog closes, the image node is gone from the
editor's content, SaveStatus reads "Unsaved changes" and Save is
enabled, and a toast appears with color success, icon
`i-lucide-circle-check`, title "Image removed", description "Save to
keep this change."
7. **Confirming removes the image the same way when opened via
Backspace/Delete** — repeat step 6's assertions, but open the dialog via
the Backspace keydown from step 3 instead of the button; assert the
outcome (request, node removal, dirty state, toast) is identical.
8. **A failed removal keeps the image and shows the inline error,
Examples-driven** — for a 404 response, a 500 response, and a rejected/
network-failure request: from the open dialog (button trigger), click
"Remove image"; resolve/reject accordingly; assert the dialog stays open
showing the inline error "Couldn't remove the image, so it's still
there. Try again in a moment."; assert the image node is still present
and the draft's dirty state is unchanged.
## Visual
No `docs/design/` board or state exists for this trigger (see the feature's
Background). Per the user's explicit direction this slice ships the SPA
behaviour anyway, but `task visual:check -- editor` has nothing to compare
it against, so no visual capture is added for it — this is an intentional
gap, `skip: no-design-coverage` (distinct from `skip: clock-sensitive`), not
a QA oversight. If a board for this state is added to `docs/design/` later,
it needs its own follow-up slice to add the visual check.
- [ ] Keyboard/focus: once a delete button is shown, it is reachable by Tab
and activates on Enter/Space; pressing Backspace/Delete with the
image node selected but focus elsewhere in the page does not trigger
removal (only an actual Tiptap NodeSelection on the image should);
ConfirmDialog's existing focus trap and cancel-button-default-focus
behaviour (already covered by the article-delete flow) applies
unchanged regardless of which trigger opened it.
## Manual verification
- **Real DELETE round trip against a running server** — Vitest only mocks
`useApi`'s request; confirm a real removal (204/404/500) against
`task dev` with a live SQLite file and the real
`DELETE /v1/article/{id}/image/{imageId}` handler, since
`features/remove_article_image.feature`'s httptest suite covers the
handler in isolation but never through this SPA flow.
- **Real keyboard behaviour** — confirm in a real browser that selecting an
image (click) and pressing Backspace/Delete opens the confirm dialog
rather than deleting the node, including with an IME or on a platform
where Backspace/Delete semantics differ, since jsdom's `keydown`
simulation doesn't exercise Tiptap's real ProseMirror keymap precedence.
- **Design review of the delete-button/ConfirmDialog approach** — since
there is no board for this state, once a board is added, re-verify this
implementation still matches it (or update the implementation to match);
until then this is a best-effort, design-team-unreviewed UI.
+136
View File
@@ -0,0 +1,136 @@
Feature: Wiring the editor's image upload flow
As a verified RenewCMS owner writing an article
I want the editor's image button (and dropping a file onto the editor) to open an upload
modal that validates the file, shows progress, and inserts the result at my cursor
So that I can add images to an article's body without leaving the editor, with clear
feedback when a file is rejected or the upload fails
Background:
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
ImageUploader (a UModal titled "Add an image") with the current article's id
And dropping a file directly onto the editor surface also opens ImageUploader, with that
file already provided to it instead of an empty idle state
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
type and its file extension against png/jpeg/webp/svg, and that its size is at most
10 × 1024 × 1024 bytes, before sending anything
And a file that passes validation is uploaded via a new useImageUpload composable, using
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
the file under the field name "file", to POST /v1/article/{articleId}/image, per
features/upload_article_image.feature's already-implemented and already-mounted contract
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
moves useArticleDraft's state to "dirty" the same way any other body edit does
And closing the modal while an upload is in flight aborts the XMLHttpRequest
And a 401 response is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
existing not-found handling already covers (features/editor_core.feature)
Scenario: Opening the uploader from the toolbar's image button shows the idle state
Given the user is on /article/12/edit with the editor focused
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image"
And it shows "Drop an image here", a "Choose file" action, and the help text
"PNG, JPEG, WEBP, SVG · max 10 MB"
And its cancel action reads "Cancel"
And no request has been sent
Scenario: Dragging a file over the editor surface shows the dragging-over state
Given ImageUploader is open in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
the editor surface
Then ImageUploader opens titled "Add an image"
And POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state is shown, not the idle state
Scenario Outline: Choosing a file the browser can reject never sends a request
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no request is sent to POST /v1/article/12/image
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for a file named "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the file picker available
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
Then POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
And its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When the user activates "Cancel upload" (or otherwise closes the modal)
Then the in-flight XMLHttpRequest is aborted
And the modal closes
And the editor's body is unchanged and remains in its previous dirty/clean state
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
file named "north-wall.jpg" to article 12
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
Then the uploading state briefly shows "Image added"
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
content at the cursor via insertImage
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
description "Save to keep it in the article."
And the modal closes 600 ms after the response resolves
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When POST /v1/article/12/image <failure>
Then the failed state shows the title "Upload failed"
And its text reads "The server didn't accept the file. Your article hasn't changed."
And its confirm action reads "Try again"
And the editor's body is unchanged, and no image is inserted
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
after a 500 response
When the user activates "Try again"
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
And the uploading state is shown
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"notes.pdf\" can't be used"
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
defensively passed the browser's own size check somehow, e.g. its reported size changed
between selection and send)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
+90
View File
@@ -0,0 +1,90 @@
# QA: Wiring the editor's image upload flow
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
false), GET already resolved 200.
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file",
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created.
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on
the drop zone; assert the text changes to "Drop to upload".
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that
file, and the uploading state (not idle) is shown.
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf`
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif`
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples.
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another
file"; assert the idle state re-renders.
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg,
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
and dirty state are unchanged from before the upload started.
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
assert `insertImage` (or the editor's exposed method) was called with that location; assert
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
the article."; advance fake timers 600 ms and assert the modal has closed.
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
body/dirty state is unchanged.
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
the uploading state is shown again.
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
call.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress",
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
2.4 of 3.9 MB", cancel "Cancel upload".
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
board's script, cancel "Cancel", confirm "Choose another file".
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
"draft", save "dirty".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
modals (leading arrow-left button, trailing ellipsis in the header behind it).
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
`aria-valuenow` updates are announced without spamming on every progress tick.
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
auto-close is not visually checked as a capture (it's a timing behavior, not a static
state) — cover only via the Vitest fake-timer assertion above.
## Manual verification
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`;
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s
httptest suite covers the handler in isolation but never through this SPA flow.
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
real OS-level drag of an image file onto the editor in a real browser.
+4 -3
View File
@@ -9,9 +9,10 @@ Feature: A logged-in user fetches their own public profile without triggering th
without being throttled like a login attempt
Background:
Given the existing PublicUser struct and newPublicUser(u user.User) converter in
api/handlers/auth/verificationHandler.go, exactly the JSON shape POST /v1/auth/verify already
returns: id, username, email, is_verified, created_at, updated_at
Given the public user struct and its converter, shared by the auth and user handlers from a
package under api/pkg (features/user_display_name.feature), exactly the JSON shape
POST /v1/auth/verify returns: id, username, email, is_verified, created_at, updated_at and
display_name
And this slice adds GET /v1/auth/me, handled by a new MeHandler (api/handlers/auth/meHandler.go)
built with NewMeHandler(container.GetUserUseCase), the same GetUserUseCase every other
protected handler already gets from api/dependecyInjection.go; no new use case or domain
+125
View File
@@ -0,0 +1,125 @@
Feature: Home screen
As a logged-in RenewCMS owner
I want /home to summarize how many articles I have, how many are online or drafts, and my five
most recently updated articles, all from the single GET /v1/article/ call the route already
makes
So that I land somewhere useful after logging in, instead of an empty dashboard or a screen that
silently fails
Background:
Given /home is a verified route (meta.auth "verified") rendered as HomePage inside
DashboardShell, per docs/design/routes.md
And HomePage calls GET /v1/article/ once per navigation; there is no separate stats endpoint,
so the three stat tiles and the recent list are both derived from that one response
And the PageHeader shows breadcrumb "Home" (mobile title "Home") and a primary action "New
article" opening NewArticleModal
And a "Connect your website" card is always rendered on the page, in every state (Loading,
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site
reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
link "Open the guide" navigating to /integration
And a greeting reading "{greeting}, {name}" sits above the subtitle in every state
(Loading, Default, Empty and Error alike); name is the session's display name when it is
not empty and the session's username otherwise; it is computed from that name and
the device's local hour via Date.getHours(), needs no request of its own since the session
is already resolved by the app-boot flow before HomePage ever renders, and so never has its
own loading or error state
And hours 0 through 11 give the greeting "Good morning", hours 12 through 17 give "Good
afternoon", and hours 18 through 23 give "Good evening"
And once the list resolves with at least one article, the subtitle reads "Here's where things
stand on your site.", three stat tiles show the total article count labelled "articles", the
online count labelled "online" and the draft count labelled "drafts", and a recent list shows
the 5 most recently updated articles, each with its title, an ArticleStatusBadge (online or
draft) and its updated date, linking to /article/:id/edit
And while the list request is in flight, skeleton placeholders replace the stat tiles and the
recent list rows, but only after 150 ms, so a fast response never flashes a skeleton
And when the list resolves with zero articles, the subtitle reads "Welcome to RenewCMS.", and
the stat tiles and recent list are replaced by an empty state: icon file-text, title "Write
your first article", text "It starts as a draft, so nothing appears on your site until you
publish it.", and a single action "New article" that opens NewArticleModal
And when the list request fails with a 500 or a network error, the breadcrumb, greeting and
subtitle stay visible, the subtitle stays "Here's where things stand on your site.", and the
stat tiles and recent list are replaced by an inline role="alert" banner: icon circle-x,
title "Couldn't load your articles", text "Check your connection and try again.", and a
single action "Try again"
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /home for the first time in this session
When GET /v1/article/ has not resolved 150 ms after the navigation
Then skeleton placeholders replace the three stat tiles and the five recent-list rows
And the recent list container is aria-busy="true"
When GET /v1/article/ then resolves 200 with a non-empty list of articles
Then the skeletons are replaced by the Default state's stat tiles and recent list
Given a second, separate navigation to /home
When GET /v1/article/ resolves in under 150 ms with a non-empty list of articles
Then no skeleton is ever shown, and the Default state's content renders directly
Scenario: Default — the list loaded with a mix of online and draft articles
Given GET /v1/article/ resolves 200 with 6 articles, 4 online and 2 drafts, most recently
updated first: "How we price a bathroom renovation" (online), "Winter checklist for timber
windows" (online), "Choosing tiles for a small bathroom" (draft), "A guide to underfloor
heating" (online), "When to reseal a stone worktop" (draft), and "Small bathroom lighting
ideas" (online, least recently updated)
When the user navigates to /home
Then the subtitle reads "Here's where things stand on your site."
And the stat tiles read "6" "articles", "4" "online" and "2" "drafts"
And the recent list shows exactly the first 5 articles above, in that order, each with its
ArticleStatusBadge and updated date, and each row links to its /article/:id/edit
And "Small bathroom lighting ideas" does not appear in the recent list
And the "Connect your website" card's "Open the guide" link navigates to /integration, and
the header's "New article" action opens NewArticleModal
Scenario: Empty, first run — zero articles
Given GET /v1/article/ resolves 200 with zero articles
When the user navigates to /home
Then the subtitle reads "Welcome to RenewCMS."
And no stat tiles are shown
And an empty state is shown in place of the recent list: icon file-text, title "Write your
first article", text "It starts as a draft, so nothing appears on your site until you
publish it.", and a single action "New article"
When the user activates "New article"
Then NewArticleModal opens
Scenario Outline: Error — a 500 or a network failure shows the same inline alert
Given GET /v1/article/ <failure>
When the user navigates to /home
Then the breadcrumb, greeting and subtitle remain visible, and the subtitle stays "Here's
where things stand on your site."
And no stat tiles or recent list are shown
And an inline alert with role="alert" is shown in their place: icon circle-x, title
"Couldn't load your articles", text "Check your connection and try again.", and a single
action "Try again"
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: Retry re-issues the request and recovers into the Default state
Given the user is on /home in the Error state, because GET /v1/article/ failed
When the user activates "Try again"
Then GET /v1/article/ is called again
When it resolves 200 with 6 articles, 4 online and 2 drafts
Then the Alert is replaced by the stat tiles reading "6" "articles", "4" "online", "2" "drafts",
and the recent list
Scenario: The list is fetched exactly once per navigation
Given the user navigates to /home
Then GET /v1/article/ is called exactly once
And both the stat tiles and the recent list are derived from that single response, with no
second request made for either
Scenario Outline: The greeting matches the hour of day
Given the session's username is "marta" and it has no display name
And the device's local hour is <hour>
When the user views /home
Then the greeting above the subtitle reads "<greeting>, marta"
Examples:
| hour | greeting |
| 0 | Good morning |
| 11 | Good morning |
| 12 | Good afternoon |
| 17 | Good afternoon |
| 18 | Good evening |
| 23 | Good evening |
+101
View File
@@ -0,0 +1,101 @@
# QA: Home screen
## Implementation note for coder (informational, not a QA assertion)
The greeting requires `useApi.getMe()` and the `Session` type/store
(`web/src/composables/useApi.ts`, `web/src/stores/session.ts`) to also expose `username`, which
`GET /v1/auth/me` already returns in its JSON body. Today `getMe()` only extracts `is_verified` and
discards the rest, and `Session` is just `{ verified: boolean }`. Widen `MeResult` and `Session` to
carry `username: string` as well; this is a same-layer frontend response-type widening (the wire
contract doesn't change), not a backend change.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `GET /v1/article/` call per case; no real backend, no httptest server. Mount
HomePage inside DashboardShell with a resolved session (username "marta") and a test router at
/home.
1. **Skeleton only after 150 ms** — mock `GET /v1/article/` with an unresolved promise; assert no
skeleton is present before 150 ms elapse (use fake timers); advance past 150 ms; assert skeleton
placeholders replace the stat tiles and the 5 recent-list rows, and the list container has
aria-busy="true"; resolve the promise with 200 and a non-empty list; assert the skeletons are
gone and the Default content is shown. Separately, mock a promise that resolves in under 150 ms;
assert no skeleton ever renders.
2. **Default state** — mock `GET /v1/article/` resolving 200 with the 6-article fixture (4 online,
2 drafts, ordered most-recently-updated first as in the feature); assert subtitle "Here's where
things stand on your site."; assert stat tiles "6" "articles", "4" "online", "2" "drafts"; assert
the recent list shows exactly the first 5 articles in order, each with its ArticleStatusBadge and
updated date, each row linking to /article/:id/edit; assert the 6th (least recently updated)
article is absent from the recent list; assert the PageHeader's "New article" action opens
NewArticleModal.
3. **Empty, first run** — mock `GET /v1/article/` resolving 200 with `[]`; assert subtitle "Welcome
to RenewCMS."; assert no stat tiles render; assert an empty state renders in place of the recent
list with icon file-text, title "Write your first article", text "It starts as a draft, so
nothing appears on your site until you publish it.", and action "New article"; click it; assert
NewArticleModal opens.
4. **Error state** — Examples-driven: (a) `GET /v1/article/` resolves 500, (b) it rejects with a
network error. In each: assert the breadcrumb, greeting and subtitle remain visible and the
subtitle stays "Here's where things stand on your site."; assert no stat tiles or recent list
render; assert an inline alert with role="alert" renders in their place, with icon circle-x,
title "Couldn't load your articles", text "Check your connection and try again.", and a single
action "Try again"; assert the alert text is identical between the 500 case and the network-
failure case — there is no separate wording per cause.
5. **Retry recovers into Default** — start from the Error state (mocked 500); click "Try again"; assert
`GET /v1/article/` is called a second time; resolve it 200 with the 6-article fixture; assert the
Alert is replaced by the stat tiles "6"/"4"/"2" and the recent list.
6. **Single request per navigation** — mock `GET /v1/article/` resolving 200 with the 6-article
fixture; mount /home; assert the mock was called exactly once, and both the stat tiles and the
recent list read from that one resolved value (no second call fires for either).
7. **Greeting matches the hour of day, at every state, for the session username** — stub the clock
(e.g. `vi.setSystemTime` or a mocked `Date.now`/`Date.prototype.getHours`) to each boundary hour
in turn (0, 11, 12, 17, 18, 23) and mount with session username "marta"; assert the greeting
above the subtitle reads exactly "Good morning, marta" for hours 0 and 11, "Good afternoon,
marta" for hours 12 and 17, and "Good evening, marta" for hours 18 and 23. Then, fixing the hour
at 9 ("Good morning, marta"), assert the same exact greeting text is shown above the subtitle in
all four states: while `GET /v1/article/` is unresolved past 150 ms (Loading), once it resolves
with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it rejects
with a 500 (Error).
8. **"Connect your website" card renders in every state** — assert the static card (icon plug,
title "Connect your website", text "Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.", link "Open the guide" navigating to /integration) is
present and identical while `GET /v1/article/` is unresolved past 150 ms (Loading), once it
resolves with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it
rejects with a 500 (Error). The card is not part of the PageHeader's actions.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/home.md` and `docs/design/source/RenewCMS Screens Back-office.dc.html`,
group "Home". Run `task visual:check -- home` for each state and compare against the board frame.
- [ ] Loading state: skeleton stat tiles and 5 skeleton recent rows, shaped like the real layout,
matching the board.
- [ ] Default state: stat tiles, recent list with ArticleStatusBadge per row, PageHeader primary
action "New article", breadcrumb "Home", and the "Connect your website" card below — matches
the board exactly at desktop and mobile.
- [ ] Empty, first run state: the empty state replaces the stats and recent list, matching the
board's icon tile, title "Write your first article", text, and single "New article" action.
- [ ] Error state: the inline alert's icon (circle-x), title "Couldn't load your articles", text
"Check your connection and try again.", and "Try again" action match the board; the
breadcrumb, greeting and subtitle stay visible above it.
- [ ] "Connect your website" card: icon plug, title, text and "Open the guide" link match the
board, and the card renders identically in every state (Loading, Default, Empty, Error).
- [ ] Greeting placement: the greeting sits above the subtitle in the PageHeader area (not the note
board's fixture text, since the greeting is new content — position and type scale should read
as a lightweight line above the description, not competing with the h1).
- [ ] Keyboard/focus: Tab order reaches "New article" in the header, then the "Connect your
website" card's "Open the guide" link, before the recent list; the Error state's "Try again"
button is reachable and, on activation, focus stays sensible (not lost) while the request
re-runs; the recent list's rows are reachable and each activates its /article/:id/edit link
with Enter.
- [ ] Motion: the skeleton pulse runs at 1.6 s and stops under reduced motion, per the Skeleton
component spec.
## Manual verification
- **Real GET /v1/article/ against a running server** — Vitest only mocks `useApi`; confirm the real
200/500 round-trip and the real article ordering (by updated_at) against `task dev` with a live
SQLite file, since these automated tests never exercise the actual Go handler or the
`ListArticles` use case.
- **Real session username on a live app-boot** — confirm `GET /v1/auth/me`'s `username` field flows
through the real session store into the greeting, since Vitest only ever supplies a mocked
session.
+63
View File
@@ -0,0 +1,63 @@
Feature: Integration screen
As a verified RenewCMS owner
I want /integration to show my site's base API URL and ready-to-use endpoint examples
So that I, or the developer who builds my public site, can wire it up without reading the
source
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the PageHeader shows title "Connect your website", description "A guide for the developer
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
actions
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
articles but doesn't show them to visitors. Your website fetches published articles from the
two addresses below and displays them in its own design."
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
(icon copy) beside it
And two EndpointCard entries follow, in this order:
| method | path | description |
| GET | /v1/article/ | Lists every online article. |
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
And each EndpointCard's fetch example uses the current origin as the base of its URL:
| path | fetch example |
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
And the curl tab's content is a plain curl command built from the current origin (e.g.
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
content is a JSON sample built from the real domain Article fields; neither tab is active by
default, and neither is visually checked, since the fetch tab is the one shown in the board
And three info notes follow the endpoint cards, in this order:
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
them."
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
base URL before rendering."
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
whoever runs the server to add your site's address."
Scenario: Default — the page loads with the app's own origin
Given the app is served from origin "{origin}"
When the user navigates to /integration
Then the Base URL row shows exactly "{origin}"
And the Base URL "Copy" button reads "Copy", not "Copied"
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
Scenario: Copied — the Base URL copy button
Given the user is on /integration, served from origin "{origin}"
When the user activates the Base URL row's "Copy" button
Then "{origin}" is written to the clipboard
And the button's icon changes to check and its text changes to "Copied"
And the change is announced to assistive technology
When 2 seconds elapse without a further click
Then the button reverts to icon copy and text "Copy"
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
Given the user is on /integration
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
button
Then that card's own code is written to the clipboard, and only that button reads "Copied"
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
+66
View File
@@ -0,0 +1,66 @@
# QA: Integration screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
`/v1` mocks are needed; the screen makes no calls.
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
"Connect your website" and description "A guide for the developer who builds your public
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
the three info notes render in order.
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
button reverts to icon copy and text "Copy".
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
Vitest-only; it has no visual-check state of its own (see Visual below).
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
content is a JSON sample built from the real domain Article fields. Neither tab's content is
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Reference: `docs/design/screens/integration.md` and
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
Playwright request routing, so the real `window.location.origin` the running app reads matches the
board's sample text exactly; no production test hook or origin stub is needed in app code.
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
`https://cms.renew.example`, served as above.
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
color, matching the board's `copied: true` frame. This state is timer-driven, not
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
elsewhere in the app (UserMenu, ArticleTable row menu).
## Manual verification
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
+183
View File
@@ -0,0 +1,183 @@
Feature: A verified user can invite another person by email, and the users list shows users and open invites
As a member of the shared RenewCMS workspace
I want to invite someone by email and see who has an account or a pending invite
So that they can later create their own account with exactly the same access, while
registration stays closed to everyone who was not invited
Background:
Given the workspace is shared: there are no roles, no ownership and no way to remove a user
And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that
requires a valid jwt cookie, a current token_version and a verified user
And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to
its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
And the raw invite code is a uuid, hashed with the same hash.Hasher used for verification codes,
and only the hash is stored
And an invite's status is derived and never stored: pending when accepted_at is empty and
expires_at is in the future, expired when accepted_at is empty and expires_at is in the past,
accepted when accepted_at is set
And the users list item shape is: id, kind ("user" or "invite"), status, created_at, plus
username and display_name for a user, plus email for an invite; a user is never listed with
an email, and neither the code, its hash, invited_by nor password fields ever appear
And error responses are plain text from http.Error, success responses are JSON
And the invite email uses the template "invitation" with data Host (the configured PublicURL),
VerificationCode (the raw code), Email (the invited address) and InvitedBy (the inviter's
display name, or their username when the display name is empty)
And the email subject is "<InvitedBy> invited you to RenewCMS"
And the link in the email is <Host>/invite/accept?c=<raw code>
And POST /v1/user/invite is rate-limited per client IP with the auth rate limiter
(AUTH_RATE_LIMIT_MAX_ATTEMPTS, AUTH_RATE_LIMIT_WINDOW_SECONDS), independently counted from
/login and /password-reset
Scenario: A verified user invites a new address
Given a user repository containing verified user id 7, username "marta", display_name ""
And an empty invite repository
And a valid jwt cookie for user id 7
When the client sends POST /v1/user/invite with body {"email": "Sam.Oliveira@Renew.example"}
Then the response status is 201
And the response body is a JSON object with exactly the keys id, kind, email, status, created_at
and expires_at, with kind "invite", email "sam.oliveira@renew.example" and status "pending"
And the response body contains no key named code, hashed_code, verification_code or password
And the invite repository holds one invite with email "sam.oliveira@renew.example",
invited_by 7, accepted_at empty and expires_at 3 days after created_at
And the stored code is the hash of the raw code, not the raw code
And exactly one email was sent to "sam.oliveira@renew.example" with template "invitation"
And that email's Host is the configured PublicURL, its VerificationCode is the raw code,
its Email is "sam.oliveira@renew.example" and its InvitedBy is "marta"
Scenario: The email names the inviter by display name when there is one
Given verified user id 7, username "ines", display_name "Ines Duarte"
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
Then the response status is 201
And the email's InvitedBy is "Ines Duarte"
Scenario Outline: An invalid email is rejected before anything is stored or sent
Given verified user id 7 and a valid jwt cookie for user id 7
When the client sends POST /v1/user/invite with body <body>
Then the response status is <status>
And the invite repository is still empty
And no email was sent
Examples:
| body | status |
| {"email": "sam.oliveira@renew"} | 400 |
| {"email": ""} | 400 |
| {} | 400 |
| {"email": "not-an-email"} | 400 |
| not json | 400 |
Scenario: An address that already has an account is refused
Given verified user id 7 and a user "jonas" with email "Jonas@Renew.example"
When the client sends POST /v1/user/invite with body {"email": "jonas@renew.example"}
Then the response status is 409
And the response body is "This address already has an account."
And the invite repository is still empty
And no email was sent
Scenario: An address with a live pending invite is refused
Given verified user id 7 and a pending invite for "sam.oliveira@renew.example" expiring in 2 days
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
Then the response status is 409
And the response body is "There's already a pending invite for this address."
And the invite repository still holds only the original invite, unchanged
And no email was sent
Scenario: An expired invite is replaced by a new one
Given verified user id 7 and an expired invite for "p.huber@gmail.com" that expired 1 hour ago
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
Then the response status is 201
And the invite repository holds exactly one invite for "p.huber@gmail.com", pending, with a
new code, and the expired one no longer exists
And exactly one email was sent to "p.huber@gmail.com"
Scenario: A failed email removes the invite
Given verified user id 7 and the mail repository is set to fail its next Send
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
Then the response status is 500
And the response body is "Couldn't send the invite. Try again in a moment."
And the invite repository is empty
When the failure is cleared and the client sends the same request again
Then the response status is 201
Scenario: A failed email does not restore a replaced expired invite
Given an expired invite for "p.huber@gmail.com" and the mail repository set to fail its next Send
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
Then the response status is 500
And the invite repository holds no invite for "p.huber@gmail.com"
Scenario Outline: Only an authenticated, verified, current-token user can use the endpoints
Given the request is made <credentials>
When the client sends <method> <path>
Then the response status is <status>
And no invite was created and no email was sent
Examples:
| credentials | method | path | status |
| with no jwt cookie | POST | /v1/user/invite | 401 |
| with a jwt cookie of an unverified user | POST | /v1/user/invite | 403 |
| with a jwt cookie whose token_version is stale | POST | /v1/user/invite | 401 |
| with no jwt cookie | GET | /v1/user/ | 401 |
| with a jwt cookie of an unverified user | GET | /v1/user/ | 403 |
Scenario: The invite endpoint is rate-limited
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
When the client at IP "203.0.113.20" sends 2 valid POST /v1/user/invite requests
And then sends a 3rd
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
And no invite was created and no email was sent for the 3rd request
And GET /v1/user/ from the same client is not rejected with 429
Scenario: The list shows users and open invites, newest first
Given verified user id 7 "marta" created 12 Mar 2026
And user "jonas" created 2 Apr 2026
And user "ines" with display_name "Ines Duarte" created 18 Jun 2026
And an expired invite for "p.huber@gmail.com" created 3 Aug 2026
And a pending invite for "sam.oliveira@renew.example" created 27 Sep 2026
And an accepted invite for "jonas@renew.example" created 1 Apr 2026
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
Then the response status is 200
And the response body is a JSON array of exactly 5 items in this order: the pending invite for
"sam.oliveira@renew.example", the expired invite for "p.huber@gmail.com", user "ines", user
"jonas", user "marta"
And the accepted invite is not listed
And the invite items have exactly the keys id, kind, email, status, created_at, with kind
"invite" and status "pending" or "expired"
And the user items have exactly the keys id, kind, username, display_name, status, created_at,
with kind "user" and status "accepted"
And no user item has an email key, and no item has a key named password, password_reset_code,
verification_code, verification_expiration, password_reset_expiration, token_version,
code, hashed_code, invited_by or accepted_at
Scenario: With nobody invited the list contains only the registered user
Given only verified user id 7 "marta"
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
Then the response status is 200
And the response body is a JSON array of exactly one item, kind "user", username "marta",
status "accepted"
Scenario: An invite turns from pending to expired with time, without any stored status
Given an invite created 3 days and 1 minute ago that has not been accepted
When the client sends GET /v1/user/
Then that invite's status is "expired"
Scenario: Registration stays closed for uninvited people
Given a user repository containing one verified user
And a pending invite for "sam.oliveira@renew.example"
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
email "sam.oliveira@renew.example"
Then the response status is 403
And the response body is exactly apperr.RegistrationClosedErrorMessage
And no user was created and no email was sent
Scenario: Registration on an empty instance is unchanged
Given the user repository is empty
When the client sends POST /v1/auth/register with username "bob", password "hunter2example12",
email "bob@example.com"
Then the response status is 200
And a verification email is sent with template "verification"
And no invite exists
Scenario: Inviting someone never changes GET /v1/auth/registration
Given a user repository containing one verified user
When a pending invite exists
Then GET /v1/auth/registration still reports registration closed
+99
View File
@@ -0,0 +1,99 @@
# QA procedure: invitations
Package: `test` (black-box). New fakes must have unique type names across the package:
`mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`),
plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher`
(`user_test.go`) and `mockMailRepo` (`mail_test.go`; `SentEmails` entries expose `Address`,
`Template`, `Data`; `fail.failNext("Send")`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Use cases: `useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo,
"https://cms.example.com")` and `useCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)`
(final names are the coder's; constructors take only domain interfaces).
3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh
`chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`,
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. For the rate-limit case
wrap only `POST /invite` in `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`.
Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in
`test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP.
4. Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance.
## POST /v1/user/invite
5. Body `{"email":"Sam.Oliveira@Renew.example"}`, cookie for 7 -> `201`. Decode into
`map[string]json.RawMessage`: the key set is exactly `id`, `kind`, `email`, `status`,
`created_at`, `expires_at`, with `kind`="invite", `email`="sam.oliveira@renew.example",
`status`="pending"; none of `code`, `hashed_code`, `verification_code`, `password`.
`inviteRepo` holds one invite: lower-case email, `InvitedBy==7`, `AcceptedAt` nil,
`abs(ExpiresAt - (CreatedAt+72h)) < 5s`, stored code == `"hashed:"+rawCode` (stubHasher) where
`rawCode` is taken from the mail data; the stored code is not equal to `rawCode`.
6. `mailRepo.SentEmails` has exactly one entry: `Address`, `Template=="invitation"`, data of the
invitation mail type with `Host=="https://cms.example.com"`, `Email` the invited address,
`InvitedBy=="marta"` and a uuid-shaped `VerificationCode`.
7. Inviter with `DisplayName:"Ines Duarte"` -> `InvitedBy=="Ines Duarte"`. Inviter with an
empty display name -> username.
8. Table for 400 (`{"email":"sam.oliveira@renew"}`, `{"email":""}`, `{}`, `{"email":"not-an-email"}`,
`not json`): status `400`, `inviteRepo` empty, `SentEmails` empty.
9. Existing user with email `"Jonas@Renew.example"`, invite `jonas@renew.example` -> `409`, body exactly the new
apperr constant "This address already has an account.", no invite, no mail. This case pins that
the match is case-insensitive.
10. Seed a pending invite (expires in 2 days) for the address -> `409`, body "There's already a pending
invite for this address.", the seeded invite unchanged (same code hash and expiry), no mail.
11. Seed an expired invite (expired 1 h ago) -> `201`; `inviteRepo` has exactly one invite for the address, with
a different code hash and an expiry about 3 days out; one mail sent.
12. `mailRepo.fail.failNext("Send")` -> `500`, body "Couldn't send the invite. Try again in a moment.",
`inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a
failing send -> `500` and no invite for that address remains.
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
`token_version` -> `401`. Each asserts no invite and no mail.
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
invite or mail. `GET /` from the same address afterwards is not `429`.
## GET /v1/user/
15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified for user and invite
items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string
contains no user email address.
16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`.
17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`;
with `AcceptedAt` set -> not listed.
18. Auth table (`GET`): no cookie -> `401`; unverified -> `403`.
## Use-case level (no HTTP)
19. `CreateInvite` on a repository holding a user with the same email -> `errors.Is(err, invite.ErrEmailHasAccount)`;
live pending invite -> `errors.Is(err, invite.ErrPendingInvite)` (sentinel names are the coder's).
A mail failure wraps the mail repository error and the `Delete` of the invite is called
(if `Delete` also fails, both errors are joined, as `CreateUser` does).
## Mailer (real embedded templates, no SMTP)
20. `mailer.MailRepository{}.Render("invitation", data)` with `Host:"https://cms.example.com"`,
`VerificationCode:"abc-123"`, `Email:"lea@example.com"`, `InvitedBy:"Ines Duarte"` returns subject
`"Ines Duarte invited you to RenewCMS"` and a body containing
`https://cms.example.com/invite/accept?c=abc-123`, `lea@example.com` and `Ines Duarte`.
An `InvitedBy` of `<b>x</b>` is HTML-escaped in body. Subjects for `verification` and `password-reset` are
unchanged, and an unknown template still gets the default subject.
21. `internal/infrastructure/mailer/templates/invitation.html` is byte-identical to
`docs/design/source/emails/invitation.html` (read both files and compare).
## Regression: registration (extends registration_gate_and_verification_email.qa.md)
22. With one verified user and a pending invite for `sam.oliveira@renew.example`, `POST /v1/auth/register` with that
email -> `403`, body exactly `apperr.RegistrationClosedErrorMessage`, no user created, no mail sent.
23. Empty user repository -> `POST /v1/auth/register` still `200` with a `jwt` cookie and one `verification`
mail, and `inviteRepo` untouched. `GET /v1/auth/registration` still reports closed when a verified user
and a pending invite exist.
## Manual verification (not exercised by the Go tests)
- Start the app once against a fresh SQLite file: the `invites` table exists (model registered in
`AutoMigrate` in `api/dependecyInjection.go`) and inviting persists across a restart.
- Send a real invitation through a real or sandboxed SMTP server: the subject line, the rendering in a mail
client (light and dark) and that the link `{PUBLIC_URL}/invite/accept?c=...` carries the raw code.
The screen the link opens is not designed and is out of scope, so the link will not resolve in the SPA yet.
- SPA copy for the 429 ("Too many invites. Wait a moment, then try again.") is a note for the later SPA
slice and is not tested here. The SPA keys list rows by kind and id, since a user and an invite can share
a numeric id.
- Layering check by review: use cases import only `internal/domain/*`; the mailer subject logic lives in
`internal/infrastructure/mailer`.
+102
View File
@@ -0,0 +1,102 @@
Feature: Log-in card sizing, alert placement and focus behavior match the Auth board
As the maintainer of RenewCMS
I want AuthShell's card padding, control heights, "Forgot password?" styling, field-error
presentation, theme select and h1 focus timing built exactly to the AuthCard board
(docs/design/source/AuthCard.dc.html) instead of Nuxt UI's untouched defaults
So that /login (and every other AuthShell-based public screen) matches the design board pixel
for pixel, at desktop and mobile, in light and dark
This complements features/auth_shell_login_conformance.feature, which covers AppLogo, the
UPageCard title/description wiring, the muted-background breakpoint and the neutral/error input
edge. This feature covers what that one doesn't: exact card/control measurements, the "Forgot
password?" link's own styling, the field-error icon and halo behavior, the theme select's
chevron, where the alert sits relative to the card, and when the h1 receives focus.
Background:
Given AuthShell is the layout for every public route, per docs/design/routes.md
And the card's padding, control heights and title size come from
docs/design/source/AuthCard.dc.html's renderVals(): pad "40px" and control height "40px"
from the sm breakpoint up; pad "8px 20px 0" (with a separate footer padding of
"0 20px 24px") and control height "44px" below sm; the title is always "font:600 20px/28px"
And the Password UFormField's "Forgot password?" link is styled color var(--ui-text-toned)
(muted), text-decoration underline, underline-offset 3px, per AuthCard.dc.html's f.link span
And a field error renders with the i-lucide-circle-alert icon in var(--ui-error), and the
field's edge/shadow is computed error-first, so an errored field that is also focused shows
only the single error-colored inset ring, never a focus ring stacked under or around it
And AuthShell's notice slot (the alert) renders inside the card's own layout, after the
title/description block and before the form — not as a sibling element above the card
And the footer's UColorModeSelect renders with no chevron/dropdown-arrow icon, as the last
(right-hand) child of the footer's space-between row, per AuthCard.dc.html's theme span
And on first paint, AuthShell's h1 (tabindex="-1") does not receive focus; focus moves to it
only when the title changes while AuthShell stays mounted (an in-app navigation between
public screens, e.g. /login to /register/reset/request), per components.md's AuthShell a11y
line ("On route change, focus moves to the h1")
And the h1 never shows a visible focus outline, since it is only ever focused programmatically
and is not part of the visible keyboard-tab order
Scenario: Card padding and control heights at sm and up
Given the user is on /login at a viewport of sm or wider
When AuthShell and the login form render
Then the card's own padding is 40px on every side
And the Username input, the Password input and the submit button are each 40px tall
And input text renders at 14px
Scenario: Card padding and control heights below sm
Given the user is on /login at a viewport below sm
When AuthShell and the login form render
Then the card's own padding is 8px top, 20px left and right, 0 bottom
And the footer (outside the card) has its own padding of 0 top, 20px left and right, 24px bottom
And the Username input, the Password input and the submit button are each 44px tall
And input text renders at 16px
Scenario: "Forgot password?" is underlined and muted
Given the user is on /login
When the Password field renders
Then the "Forgot password?" link uses the muted text color (--ui-text-toned)
And the link is underlined with a 3px underline offset
And the link carries no other emphasis (not bold, not the primary color)
Scenario: A field error shows the circle-alert icon with no extra focus halo
Given the user is on /login
When the user submits with Username "ma" and Password empty (the Validation errors state)
Then the Username field's error message is preceded by the i-lucide-circle-alert icon in the
error color
And the Password field's error message is preceded by the same icon in the same color
When the user tabs into the still-errored Username field so it is both errored and focused
Then the field shows exactly one inset ring, in the error color
And no second, differently colored focus ring is layered under or around it
Scenario: The theme select is right-aligned in the footer with no chevron
Given the user is on /login
When AuthShell's footer renders
Then the UColorModeSelect is the last (right-most) element in the footer's row
And it renders only its mode icon and label ("System", "Light" or "Dark")
And it renders no trailing chevron or dropdown-arrow icon
Scenario Outline: The alert renders inside the card, under the description
Given the user is redirected to <path>
When /login renders with its <state> alert
Then the alert is a child of the same card element that contains the title and description,
not a sibling positioned above the card
And the alert sits after the title/description block and before the Username field
And the alert's background uses UAlert's soft variant, which is dark-tinted automatically in
dark mode
Examples:
| path | state |
| /login?redirect=/article/12/edit&reason=expired | Session expired |
| /login?reason=password-changed | Password changed |
Scenario: The h1 is not focused on first load
Given the user navigates directly to /login (a fresh page load)
When AuthShell mounts and the h1 renders
Then the h1 does not receive focus
And focus remains on the document body (or wherever the browser places it on load)
Scenario: Focus moves to the h1 only on an in-app navigation between public screens
Given the user is on /login with AuthShell already mounted
When the user follows the "Forgot password?" link to /register/reset/request without a full
page reload
Then AuthShell's h1 receives focus once ResetRequestPage's title renders
And the h1 shows no visible outline when it receives this focus
+59
View File
@@ -0,0 +1,59 @@
# QA: Log-in card sizing, alert placement and focus behavior match the Auth board
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount AuthShell/LoginView directly; mock `useApi` (`GET /v1/auth/registration`,
`POST /v1/auth/login`) only as scaffolding, per features/login_screen.qa.md's setup.
1. **Card padding and control heights at sm+** — mount at a `>= 640px` viewport; assert the
card's own padding is 40px on every side (not Nuxt UI's untouched default); assert Username,
Password and the submit button each render at a computed height of 40px; assert input font-size
is 14px.
2. **Card padding and control heights below sm** — mount at a `< 640px` viewport; assert the
card's padding is 8px top / 20px sides / 0 bottom; assert the footer's own padding is 0 top /
20px sides / 24px bottom; assert Username, Password and the submit button are each 44px tall;
assert input font-size is 16px.
3. **"Forgot password?" styling** — assert the link's computed color equals `--ui-text-toned`,
`text-decoration-line: underline`, `text-underline-offset: 3px`, and that it carries no bold
weight or primary-color class.
4. **Field error icon and no stacked halo** — trigger the Validation errors state; assert each
field's error text is preceded by an `i-lucide-circle-alert` icon in the error color; then
focus the still-errored Username input and assert only one ring class (the error ring) is
present — the neutral/primary focus ring class is absent, not merely visually covered.
5. **Theme select has no chevron, is footer-last** — assert `UColorModeSelect` is the last child
of the footer's flex row; assert no chevron-down icon element renders inside it.
6. **Alert renders inside the card** — mount at `/login?reason=expired` and separately at
`/login?reason=password-changed`; assert the alert element's closest UPageCard/card ancestor is
the same element that contains the h1 (i.e., the alert is NOT a sibling before the card);
assert its DOM position is after the title/description block and before the Username field.
7. **h1 not focused on first load** — mount LoginView fresh (simulating first paint); assert
`document.activeElement` is not the h1.
8. **h1 focus on in-app navigation only** — mount AuthShell at /login, then update the `title`
prop the way an in-app route change to /register/reset/request would (per the existing `watch`
on `props.title`); assert the h1 now has focus.
## Visual (manual verification — `task visual:check -- log-in`, desktop and mobile, light and dark)
Reference: `docs/design/source/AuthCard.dc.html` via `RenewCMS Screens Auth.dc.html`, group
"Log in", all 9 states.
- [ ] Card padding is 40px on every side at desktop, and 8px/20px/0 (card) + 0/20px/24px
(footer) below sm, across all 9 states.
- [ ] Username, Password and the submit button are 40px tall at desktop and 44px below sm.
- [ ] "Forgot password?" is muted and underlined in every state that shows it.
- [ ] In the Validation errors state, both fields show the circle-alert icon and exactly one
ring around the field (no double ring), light and dark.
- [ ] The theme select shows icon + label only, no chevron, right-aligned in the footer.
- [ ] In Session expired and Password changed, the alert sits inside the card, under the
description, above the Username field, and is visibly dark-tinted (not a light-mode
background) in dark mode.
- [ ] Keyboard/focus: on a fresh /login load, nothing is auto-focused to the h1 (initial focus
stays with the browser/body); following "Forgot password?" to the reset-request screen moves
focus to that screen's h1, with no visible outline ring at any point.
## Manual verification
- **AppLogo edge in dark mode** — resolved by the amended
features/auth_shell_login_conformance.feature: AppLogo shows no edge stroke in either light or
dark mode, per AuthCard.dc.html's literal `edge: 'transparent'`. No check needed here; see that
feature's own QA file for the AppLogo assertions.
+122
View File
@@ -0,0 +1,122 @@
Feature: Log in screen
As a returning RenewCMS user
I want a /login screen that authenticates me against POST /v1/auth/login, shows why a previous
attempt or session failed, and offers to create the owner account only while registration is
open
So that I can get back into the back office from any entry point (a direct visit, an expired
session, a completed password reset) with copy that matches what actually happened
Background:
Given /login is a public route (meta.auth "public") rendered as LoginPage inside AuthShell,
per docs/design/routes.md
And on mount, LoginPage calls GET /v1/auth/registration to learn whether registration is open
And the form has a Username field (placeholder "Your username") and a Password field (a
password input with a show/hide toggle, autocomplete "current-password", and a
"Forgot password?" link), per docs/design/components.md
And client-side validation runs on submit before any request is sent: Username is required
with "Use at least 3 characters." under 3 characters, and Password is required with
"Enter your password." when empty
And a valid submit calls POST /v1/auth/login with { username, password }
And on a 200 response, LoginPage re-resolves the session (GET /v1/auth/me, per
features/nuxt_ui_shell_foundation.feature and features/api_client_global_error_handling.feature)
and then navigates: to the original ?redirect= target if present, else to /home when the
session is verified, or to /register/pending when it is not
And a 403 response means the username/password pair is wrong, since the API never reveals
which field was wrong
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed as
a live countdown on the submit button; the button re-enables when the countdown reaches zero
And a 500 response, or a request that never reaches the server, is surfaced as a network/server
error; the fields keep whatever the user typed
Scenario: Default — registration is closed
Given GET /v1/auth/registration resolves 200 with { "open": false }
When the user navigates to /login
Then the heading is "Log in"
And the description is "Welcome back. Log in to manage your articles."
And the Username field shows placeholder "Your username" with no value
And the Password field shows no value
And the submit button reads "Log in"
And no "Create the owner account" footer link is shown
Scenario: Fresh install — registration is open
Given GET /v1/auth/registration resolves 200 with { "open": true }
When the user navigates to /login
Then the heading, description and fields render exactly as in the Default state
And the footer reads "First time here?" with a "Create the owner account" link
Scenario: Validation errors — nothing is sent for a short username and an empty password
Given the user is on /login
When the user types "ma" into Username, leaves Password empty, and submits
Then POST /v1/auth/login is never called
And the Username field shows the error "Use at least 3 characters."
And the Password field shows the error "Enter your password."
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /login with Username "marta" and Password "limewash2026"
When the user submits and POST /v1/auth/login has not yet resolved
Then the Username and Password fields are disabled
And the submit button reads "Logging in…" and shows a loading state
Scenario: Wrong credentials — the password is cleared and focused
Given the user is on /login with Username "marta" and Password "wrong-password"
When the user submits and POST /v1/auth/login resolves 403
Then an error alert reads "That username and password don't match" with description
"Check both and try again. Passwords are case-sensitive."
And the Username field still shows "marta"
And the Password field is cleared and receives focus
And the submit button reads "Log in" again, no longer loading
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /login with Username "marta" and Password "limewash"
When the user submits and POST /v1/auth/login resolves 429 with a Retry-After of 42 seconds
Then an info alert reads "Too many attempts" with description
"For your security, wait a moment before trying again."
And the Username field still shows "marta" and the Password field still shows "limewash"
And the submit button reads "Try again in 0:42" and is disabled
And the Username and Password fields are disabled while the countdown runs, as the board draws
them
When 42 seconds elapse
Then the submit button re-enables and reads "Log in"
And the Username and Password fields are editable again
Scenario: Session expired — redirected from a 401 elsewhere
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
When /login renders
Then an info alert reads "Your session expired" with description
"Log in again to pick up where you left off. Unsaved changes are kept on this device."
And the Password field receives focus
And the submit button reads "Log in"
Scenario: Password changed — arrived from a successful reset
Given the user is redirected to "/login?reason=password-changed"
When /login renders
Then a success alert reads "Password changed" with description "Log in with your new password."
And the Username field shows placeholder "Your username" with no value
And the submit button reads "Log in"
Scenario Outline: Server or network error — the fields keep their values
Given the user is on /login with Username "marta" and Password <password>
When the user submits and POST /v1/auth/login <failure>
Then an error alert reads "Couldn't reach RenewCMS" with description
"Check your connection and try again."
And the Username field still shows "marta" and the Password field still shows <password>
And the submit button reads "Log in" again, no longer loading
Examples:
| password | failure |
| "limewash2026" | fails with a 500 response |
| "limewash2026" | never reaches the server |
| "short1" | fails with a 400 response, because "short1" clears client-side validation (non-empty, 3+ characters) but is under the server's 8-character minimum, which the client does not check |
Scenario Outline: A successful log in navigates to the right destination
Given the user is on <path>
And GET /v1/auth/me, called after the 200 response, resolves 200 with is_verified <verified>
When the user submits valid credentials and POST /v1/auth/login resolves 200 with a jwt cookie
Then the app navigates to <destination>
Examples:
| path | verified | destination |
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
+96
View File
@@ -0,0 +1,96 @@
# QA: Log in screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`GET /v1/auth/registration`, `POST /v1/auth/login`, `GET /v1/auth/me`) per case; no
real backend, no httptest server. Mount LoginPage inside AuthShell with a test router whose current
route starts at the path each case needs.
1. **Default state** — mock `GET /v1/auth/registration` resolving `{ open: false }`; mount at
/login; assert heading "Log in", description "Welcome back. Log in to manage your articles.",
Username placeholder "Your username" with no value, Password with no value, submit button "Log
in", and no "Create the owner account" link.
2. **Fresh install state** — mock `GET /v1/auth/registration` resolving `{ open: true }`; mount at
/login; assert the same heading/description/fields as case 1, plus a "Create the owner account"
link under "First time here?".
3. **Validation errors state** — mount at /login; set Username to "ma", leave Password empty,
submit; assert the mocked `POST /v1/auth/login` was never called; assert Username shows "Use at
least 3 characters." and Password shows "Enter your password."
4. **Submitting state** — set Username "marta", Password "limewash2026"; mock
`POST /v1/auth/login` with an unresolved promise; submit; assert Username and Password are
disabled and the submit button reads "Logging in…" with a loading indicator.
5. **Wrong credentials state** — set Username "marta", Password "wrong-password"; mock
`POST /v1/auth/login` resolving 403; submit; assert an error alert with title "That username and
password don't match" and description "Check both and try again. Passwords are case-sensitive.";
assert Username still reads "marta"; assert Password is empty and has focus; assert the submit
button reads "Log in" and is no longer loading.
6. **Too many attempts state** — set Username "marta", Password "limewash"; mock
`POST /v1/auth/login` resolving 429 with a `Retry-After: 42` header; submit; assert an info alert
with title "Too many attempts" and description "For your security, wait a moment before trying
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
advance fake timers by 42 seconds; assert the button re-enables and reads "Log in" and both fields
are editable again.
7. **Session expired state** — mount at
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
this device."; assert the Password field has focus; assert the submit button reads "Log in".
8. **Password changed state** — mount at "/login?reason=password-changed"; assert a success alert
with title "Password changed" and description "Log in with your new password."; assert Username
shows placeholder "Your username" with no value.
9. **Server or network error state** — three sub-cases, one per Examples row: (a) a 500 response
with Password "limewash2026"; (b) a rejected fetch (network error) with Password "limewash2026";
(c) a 400 response with Password "short1" (6 characters — passes client validation since it's
non-empty and 3+ characters, but is under the server's 8-character minimum, which the client does
not check, so the request reaches the server and comes back 400). In each sub-case, set Username
"marta" and the row's Password, submit, and assert an error alert with title "Couldn't reach
RenewCMS" and description "Check your connection and try again."; assert Username still reads
"marta" and Password still reads the row's value; assert the submit button reads "Log in" and is
no longer loading. Sub-case (c) also asserts the mocked `POST /v1/auth/login` was actually called
(proving client validation let it through), unlike scenario 3's validation-errors case.
10. **Successful log in redirects correctly** — one test per Examples row: mock
`POST /v1/auth/login` resolving 200, then `GET /v1/auth/me` resolving 200 with the row's
`is_verified`; mount at the row's path; submit valid credentials; assert the router's current
path equals the row's destination.
| path | verified | destination |
|----------------------------------------|----------|-------------------------|
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
- [ ] Default state, desktop and mobile, light and dark: heading, description, Username, Password
with show/hide toggle, "Forgot password?" link, "Log in" button, matches the board exactly.
- [ ] Fresh install state: "First time here? Create the owner account" footer renders below the
form, desktop and mobile, light and dark.
- [ ] Validation errors state: both field errors render inline under their fields with the error
styling from components.md, desktop and mobile, light and dark.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button, desktop
and mobile, light and dark.
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
cleared/focused Password field, desktop and mobile, light and dark.
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
the live "Try again in m:ss" countdown text, desktop and mobile, light and dark.
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
desktop and mobile, light and dark.
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
it), desktop and mobile, light and dark.
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
their values, desktop and mobile, light and dark.
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
submit; the alert (when present) is announced via `role="status"` (info/success) or
`role="alert"` (error) without re-announcing on re-render; Password receives programmatic focus
exactly in the Wrong credentials and Session expired states, and nowhere else.
## Manual verification
- **Real GET /v1/auth/registration and POST /v1/auth/login against a running server** — Vitest only
mocks `useApi`; confirm the Default/Fresh install split and a real 403/429/200 round-trip against
`task dev` with a live SQLite file, since this slice's automated tests never exercise the actual Go
handlers, the rate limiter, or the real `jwt` cookie.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift or double-fire
the re-enable against a real elapsed 42-second wait, since Vitest fake timers only prove the logic,
not real wall-clock behavior.
+215
View File
@@ -0,0 +1,215 @@
Feature: The multi-user flows hold up under concurrency, failures and edge input
As the maintainer of RenewCMS
I want the invitation, account-menu and password-reset flows to be safe under concurrent use,
server failures and unusual input
So that a closed registration cannot be bypassed, no address is revealed, and nobody is left
with a spinner or a lost focus
Background:
Given this pass adds no endpoint, no route, no response shape and no design change
And every item below is covered by a test that fails before the change and passes after it,
or is stated as not testable
And error responses are plain text from http.Error, success responses are JSON
And the layering rule holds: domain has no GORM or HTTP, use cases see only domain
interfaces, handlers see only use cases, and no comment is added anywhere
Rule: Accepting an invite is atomic
Background:
Given the persistence tests run against a real in-memory SQLite database migrated with the
same models as api/dependecyInjection.go, not a fake repository
And marking an invite accepted is a conditional update on the invite row with
accepted_at IS NULL, and the number of rows affected is checked
And the conditional update and the user insert run in one database transaction, so both
commit or neither does
And no domain, use case or handler code knows about the transaction: the use case calls a
domain repository method and the persistence implementation owns the transaction
Scenario: Two concurrent accepts of one code create exactly one user
Given a pending invite for "lea.martin@renew.example" with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
When two clients at the same moment send POST /v1/auth/invite/accept with that code, one
with username "lea" and one with username "lea2", both with password "limewash2026"
Then exactly one response is 200 with a jwt cookie
And the other response is 404 with body "This invite link doesn't work." and no jwt cookie
And the users table holds exactly one user with email "lea.martin@renew.example"
And the invite has accepted_at set
Scenario: The concurrency test is repeatable
Given the two-accept scenario above
When it runs 50 times, each on a fresh database
Then every run ends with exactly one user and one 200
Scenario: A used code loses at the conditional update, not at the earlier lookup
Given a pending invite that another accept marks accepted after this accept has read it
but before it marks it
When this accept marks the invite
Then zero rows are affected and the accept fails with the same invite-not-found error a
used code gives
And the response is 404 with body "This invite link doesn't work."
And no user was inserted for this accept
Scenario: A failed user insert leaves the invite pending
Given a pending invite for "lea.martin@renew.example"
And a verified user "marta"
When an accept with username "marta" is sent
Then the response is 409 with body "That username is taken. Try another."
And the invite is still pending, with accepted_at empty
Scenario: A failed invite update leaves no user behind
Given a pending invite for "lea.martin@renew.example"
And the conditional update fails after the user insert has been attempted in the transaction
When the accept runs
Then the transaction rolls back and no user "lea" exists
And the invite is still pending
And the response is 500 and no jwt cookie is set
Scenario: The existing accept behaviour is unchanged
Given the scenarios of features/accept_invitation.feature
Then all of them still pass, except that a failure marking the invite accepted no longer
makes the use case call userRepository.Delete: the rollback replaces the compensating delete
Rule: The accept screen recovers from a failed invite lookup
Background:
Given the route /invite/accept?c=CODE of docs/design/screens/accept-invite.md
And the design has a "Server error" state for the submit only, so the failed lookup is a
new state approved by the user, with no board frame
Scenario Outline: A failed lookup shows a server-error alert with a Try again action
Given the SPA is at /invite/accept?c=abc123
When GET /v1/auth/invite?c=abc123 <outcome>
Then the "Checking your invite…" spinner is replaced by an Alert (error, circle-x) with
title "Couldn't check your invite" and text "Something went wrong on the server. Try again in a moment."
And a "Try again" button is visible
And no form, no Email field and no ResultPanel is shown
And the spinner is not shown
Examples:
| outcome |
| answers 500 |
| answers 502 |
| fails with a network error |
Scenario: Try again looks the invite up again
Given the failed-lookup alert is shown
When the person activates "Try again"
Then the "Checking your invite…" spinner is shown and GET /v1/auth/invite?c=abc123 is sent again
And when it answers 200 {"email": "lea.martin@renew.example"} the Default state is shown
Scenario: Try again failing again shows the alert again
Given the failed-lookup alert is shown
When the person activates "Try again" and the lookup fails again
Then the alert is shown again and the button is enabled
Scenario Outline: Other lookup outcomes keep their design states
When GET /v1/auth/invite?c=abc123 answers <status>
Then the design state "<state>" is shown
Examples:
| status | state |
| 404 | Invalid or used link (404) |
| 410 | Expired link (410) |
Rule: The reset-request 200 stays generic when the mail fails
Scenario: A failed send is logged and the response is unchanged
Given the user repository contains a user "alice" with email "alice@example.com"
And the mail repository is set to fail on Send with the error "smtp: connection refused"
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
Then the response status is 200
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
And no jwt cookie is set
And the process log contains a line naming the failed send and the error "smtp: connection refused"
Scenario: The response is identical for a failed send, a successful send and an unknown email
Given three requests: alice with a failing mailer, alice with a working mailer, and an unknown address
Then the status, the body and the response headers other than Date are the same in all three
Scenario: The log does not contain the raw reset code
Given the same failing send
Then the process log contains neither the raw code nor its hash
Rule: The invite email is validated identically in the SPA and the Go validator
Background:
Given the Go rule is the validate tag of the invite request struct checked with
validator.Validate.Struct, extended through validator.InitValidator, not by hand
And the SPA rule is the InviteUserModal pattern
And the accepted and rejected addresses are the single table below
And user@localhost is rejected in both
Scenario Outline: The same address gets the same verdict on both sides
When POST /v1/user/invite is sent with body {"email": "<email>"} by a verified user
And the same address is typed in the invite modal
Then the API answers <status>
And the modal shows "<modal>"
Examples:
| email | status | modal |
| sam.oliveira@renew.example | 201 | no error |
| Sam.Oliveira@Renew.example | 201 | no error |
| sam+news@renew.example | 201 | no error |
| sam@mail.renew.example | 201 | no error |
| user@localhost | 400 | Enter a full email address, like name@example.com. |
| sam@renew | 400 | Enter a full email address, like name@example.com. |
| sam@.renew.example | 400 | Enter a full email address, like name@example.com. |
| sam@renew..example | 400 | Enter a full email address, like name@example.com. |
| sam@renew.example. | 400 | Enter a full email address, like name@example.com. |
| sam @renew.example | 400 | Enter a full email address, like name@example.com. |
| @renew.example | 400 | Enter a full email address, like name@example.com. |
| not-an-email | 400 | Enter a full email address, like name@example.com. |
| | 400 | Enter a full email address, like name@example.com. |
Scenario: A rejected address stores nothing and sends nothing
When POST /v1/user/invite is sent with body {"email": "user@localhost"}
Then no invite was created and no email was sent
Rule: The mobile account sheet keeps its chip state and focus
Background:
Given the account chip (aria-haspopup "menu", title "Account") of docs/design/components.md
and the account menu of docs/design/screens/profile.md, shown as a sheet on mobile (390 px)
And there is no visual change: the sand fill while open is unchanged
Scenario Outline: The chip's aria-expanded tracks the sheet
Given the chip is visible and the sheet is closed
Then the chip has aria-expanded "false"
When the person activates the chip
Then the sheet is open and the chip has aria-expanded "true"
When the sheet closes by <closing action>
Then the chip has aria-expanded "false"
Examples:
| closing action |
| pressing Escape |
| tapping the backdrop |
| activating "Profile" |
Scenario Outline: Focus returns to the opener when the sheet closes
Given the sheet was opened from the chip
When the sheet closes by <closing action>
Then focus returns to the chip if it is still mounted, otherwise to the navbar toggle
Examples:
| closing action |
| pressing Escape |
| tapping the backdrop |
| activating "Profile" |
Scenario: Logging out does not try to focus a removed chip
Given the sheet is open
When the person activates "Log out"
Then the session ends and no error is thrown
Scenario: Focus is not stolen when the sheet was never opened
Given the page has just loaded
Then focus is not on the chip
Rule: userRepository.Delete deletes through the persistence model
Scenario: Deleting a user removes only that row
Given a real in-memory SQLite database with users 1, 2 and 3
When Delete is called for user 2
Then user 2 is gone and users 1 and 3 remain
And Delete goes through the persistence model, not the domain struct
+63
View File
@@ -0,0 +1,63 @@
# QA procedure: multi-user hardening
Two parts. Go tests in `test/` (package `test`, black-box). Vitest for the SPA. Every item is written so it fails first.
## Part A. Go
### A1. Atomic accept (real in-memory SQLite, no fake)
1. Open a real GORM SQLite database with a shared-cache in-memory DSN (or a `t.TempDir()` file if shared memory is flaky on Windows), migrated with the same models as `api/dependecyInjection.go`. Build the real persistence repositories, the real accept use case and the handler on a fresh `chi.NewRouter()`. `validator.InitValidator()`, `jwtauth.New("HS256", []byte("test-secret"), nil)`, `httptest`. Package-unique type names.
2. Do not use `SetMaxOpenConns(1)`: it serialises the requests and hides the race. Two connections must truly contend.
3. Seed one pending invite with the real hasher's hash of raw code `3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42`, email `lea.martin@renew.example`.
4. Start two goroutines behind a `sync.WaitGroup` and a start channel, one POST with username `lea`, one with `lea2`. Collect both recorders.
5. Assert: exactly one 200 with a `jwt` cookie; the other 404 with body exactly the invite-invalid message and no `Set-Cookie`; one user with that email; invite `accepted_at` not null.
6. Repeat 50 times, each on a fresh DB. It must fail before the change.
7. Rollback case: force the invite update to fail (a `BEFORE UPDATE` trigger with `RAISE(ABORT, ...)` on invites). POST gives 500, no user `lea`, invite still pending, no cookie.
8. Taken username: seed user `marta`, POST username `marta` gives 409 with the username-taken message, invite `accepted_at` still null.
9. Zero rows affected: a thin decorator over the real repository marks the invite accepted in SQL between lookup and update. Expect the invite-not-found sentinel via `errors.Is` and no user.
10. Re-run the existing accept-invitation cases. The case asserting `Delete` after a failing mark-accepted is rewritten to assert no user is left.
### A2. userRepository.Delete via the persistence model
11. Against the same real SQLite: create users 1 to 3, `Delete(2)`, assert row 2 gone and 1 and 3 remain. Keep the existing behaviour for a missing id.
12. If step 11 passes before the refactor, report "no failing test possible, internal refactor only". Do not fabricate a failing test.
### A3. Password reset send failure
13. Handler over the real reset-request use case, `mockUserRepo` holding alice, a mail fake failing with `errors.New("smtp: connection refused")`.
14. Capture the standard log with `log.SetOutput(&buf)`, restore with `t.Cleanup`. No parallel tests.
15. POST `{"email":"alice@example.com"}` gives 200, body exactly the generic message JSON, no `Set-Cookie`. `buf` names the send failure and `smtp: connection refused`, and contains neither the raw code nor its hash.
16. Compare failing mailer, working mailer and unknown email: same status, body and headers except `Date`.
### A4. Invite email validation (Go side)
17. Table from the feature. POST `/v1/user/invite` with a verified user's `jwt` cookie. Accepted gives 200; rejected gives 400, no invite stored, no email sent. `user@localhost` is rejected.
18. Share the table between the Go and Vitest suites through one JSON fixture so they cannot drift. Location is the coder's call.
## Part B. SPA (Vitest + Vue Test Utils, `task test:web`)
### B1. Accept screen, failed lookup
| Mock | Expected |
| --- | --- |
| GET /v1/auth/invite?c=abc123 gives 500 | Alert title "Couldn't check your invite", text "Something went wrong on the server. Try again in a moment.", button "Try again", no spinner, no form |
| gives 502 | same |
| network error | same |
| gives 404 | ResultPanel unchanged |
| gives 410 | ResultPanel unchanged |
19. Try again: click, assert a second GET and the spinner; second mock answers 200 `{email:'lea.martin@renew.example'}` and the Default state shows. A second failure shows the alert again.
20. Existing accept-screen specs pass unchanged.
### B2. Invite modal email
21. Feed each row of the shared table into the invite modal, blur, assert the error text or none and the submit state.
### B3. Mobile account sheet
22. Mount at the mobile branch, attach to `document.body`. Chip `aria-expanded` is "false", then "true" after click, then "false" after each close (Escape, backdrop, Profile), with focus on the chip, or on the navbar toggle when the drawer chip is unmounted. Log out throws nothing. Await `nextTick` and the leave transition before asserting focus.
## Visual (`task visual:check -- accept-invite`)
- The failed-lookup state has no board frame. The user approved a per-state `exception` (`approvedBy` the user) for it in `web/visual/states.ts`.
- All existing accept-invite states stay within 10 %.
- The sheet fix is not a visual change. Re-run the screens containing the sheet at mobile in light and dark to confirm nothing moved.
## Manual verification
- Unreachable SMTP host on password reset returns 200 and the server log shows the error.
- Real file SQLite: two concurrent accepts against a running binary leave one user.
- Screen reader: chip announces expanded and collapsed; focus lands on the chip after closing the sheet.
## Docs (no scenario)
- `docs/architecture.md` lacks `api/handlers/user` and `api/pkg/publicuser`; coder adds both.
+146
View File
@@ -0,0 +1,146 @@
Feature: Creating a new article from NewArticleModal
As a verified RenewCMS owner
I want NewArticleModal to take a title and a first paragraph, create the article as a draft
over POST /v1/article/, and take me straight to the editor to keep writing
So that starting a new article is a two-field, low-friction step instead of a full form, and I
land where I can actually write it
Background:
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
button, desktop header button, and the empty state's "New article" action), per
docs/design/routes.md and docs/design/screens/new-article.md; the Articles-page trigger and
the /article/new deep link are out of scope for this slice, deferred until ArticlesPage exists
And every time it opens, it starts in the Default state: title "New article", text "It's
saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title
field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter
reading "0 / 200" and help "3 to 200 characters.", an empty First paragraph textarea with
placeholder "Start writing. You'll continue in the editor." and help "You can format it and
add images in the editor.", a "Cancel" button and a "Create draft" button
And the Title field is sent as the request's title and is validated required, 3 to 200
characters, counted the same way as the CharacterCounter ([...str].length, which is rune
count, matching article_title_rules.feature's server-side rune counting)
And the First paragraph field is sent as the request's body and is validated required (at
least one character)
And each field validates on blur and again on submit; submitting while either field is
invalid sends no request, and shows both fields' errors at once if both are invalid: Title
under 3 characters shows "Use at least 3 characters." in place of its help text, with the
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can
be a single sentence." in place of its help text
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both
fields and both buttons are disabled, the confirm button reads "Creating…" with a loading
indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click
And a 201 response closes the modal, emits created(article) with the response body, and (for
this slice) HomeView.vue's created handler navigates to /article/{id}/edit and raises a
toast: color success, icon i-lucide-circle-check, title "Draft created", description
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per
docs/design/components.md's toast-id convention
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this
form's own check but still failed the server's, or any other 400 — is never shown verbatim;
it re-enables the form with both fields' current values kept, shows "Check this title and
try again." in place of the Title field's help text, and shows an alert "Something in the
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error
And a 500 response or a request that never reaches the server re-enables the form with both
fields' current values kept and shows an alert "Couldn't create the article. Your text is
still here. Try again in a moment.", with no field-level error
And "Cancel" always closes the modal immediately and discards both fields, with no
confirmation prompt, whenever the form is not disabled by an in-flight request
And a 401 response is handled entirely by useApi's existing global handling
(features/api_client_global_error_handling.feature): the session is cleared and the app
navigates to /login?redirect=<path>&reason=expired; this slice adds no draft-preservation
behavior for the modal's fields, unlike the editor's session-expiry handling
Scenario: Opening the modal shows the Default state
Given the user is on /home
When the user activates "New article"
Then NewArticleModal opens with title "New article" and the text "It's saved as a draft.
Titles can't be changed later, so check it now."
And the Title field is empty, focused, and its counter reads "0 / 200"
And the First paragraph field is empty
And "Create draft" is present but the form has not been submitted
Scenario: Validation — a title under 3 characters on blur
Given the modal is open
When the user types "Ti" into the Title field and moves focus away
Then the Title field shows the error "Use at least 3 characters." in place of its help text
And the counter reads "2 / 200" in its error style
And no request is sent
Scenario: Validation — an empty first paragraph on blur
Given the modal is open with a valid Title
When the user leaves the First paragraph field empty and moves focus away
Then the First paragraph field shows the error "Add a first paragraph. It can be a single
sentence."
And no request is sent
Scenario: Validation — submitting with both fields invalid shows both errors and sends nothing
Given the modal is open with Title "Ti" and an empty First paragraph
When the user activates "Create draft"
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows
"Add a first paragraph. It can be a single sentence."
And POST /v1/article/ is never sent
Scenario: Submitting disables the form and cannot be dismissed
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor."
When the user activates "Create draft"
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom",
"body": "Small rooms show every grout line, so we start with the floor."}
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
"Creating…" with a loading indicator
And pressing Esc or clicking the backdrop does not close the modal
Scenario: A successful create closes the modal and hands off to the editor with a toast
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom"
and First paragraph "Small rooms show every grout line, so we start with the floor.", and
"Create draft" has been activated
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
Then the modal closes
And the app navigates to /article/42/edit
And a toast appears: color success, icon i-lucide-circle-check, title "Draft created",
description "\"Choosing tiles for a small bathroom\" is saved as a draft.", id
"article-created-42"
Scenario: Title rejected — a 400 that got past this form's own check
Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by
this form's own rule) and First paragraph "Small rooms show every grout line, so we start
with the floor.", and "Create draft" has been activated
When POST /v1/article/ resolves 400
Then the modal re-enables, keeping both field values
And the Title field shows "Check this title and try again." in place of its help text
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
And the First paragraph field shows no error
Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has
been activated
When POST /v1/article/ <failure>
Then the modal re-enables, keeping both field values
And an alert reads "Couldn't create the article. Your text is still here. Try again in a
moment."
And neither field shows a field-level error
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retrying after an Error succeeds
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small
bathroom" and First paragraph "Small rooms show every grout line, so we start with the
floor." still in the fields
When the user activates "Create draft" again
Then POST /v1/article/ is sent again with the same JSON body
When it resolves 201 with the created article, id 42
Then the modal closes, the app navigates to /article/42/edit, and a toast appears: color
success, icon i-lucide-circle-check, title "Draft created", description "\"Choosing tiles
for a small bathroom\" is saved as a draft.", id "article-created-42"
Scenario: Cancel discards the fields with no confirmation
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
"Small rooms show every grout line, so we start with the floor."
When the user activates "Cancel"
Then the modal closes immediately, with no confirmation prompt
And no request is sent
+97
View File
@@ -0,0 +1,97 @@
# QA: Creating a new article from NewArticleModal
## Implementation note for coder (informational, not a QA assertion)
Only HomeView.vue is wired as a real `created` consumer in this slice: it listens for
`@created="article => { router.push(...); useToast().add(...) }"`. The Articles-page trigger and
the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
exists; don't wire a `created` handler there yet.
The confirm button's label changes from the placeholder's "Create article" to the design's
"Create draft" — this is a deliberate copy change, not a leftover.
The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon:
'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`,
per docs/design/components.md's toast-id convention (action + article id) and the Toast rules
section (success/info close after 5 s).
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `POST /v1/article/` per case; no real backend, no httptest server.
Mount HomePage (which owns NewArticleModal) inside DashboardShell with a resolved verified session,
and a test router at `/home`. Sample data throughout: title "Choosing tiles for a small bathroom",
first paragraph "Small rooms show every grout line, so we start with the floor.", created article
id 42, is_online false.
1. **Default state on open** — click "New article"; assert the modal shows title "New article",
the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200
characters.", an empty First paragraph field with its placeholder and help, "Cancel" and
"Create draft".
2. **Blur validation, Title** — type "Ti" into Title and blur; assert the error "Use at least 3
characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
`request` call.
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no
`request` call.
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft";
assert both errors show and `request` is never called.
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel
become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the
backdrop do not close the modal while unresolved.
6. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`;
assert a toast was raised with id "article-created-42", color success, icon
"i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
bathroom\" is saved as a draft."
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
assert both field values are kept, the Title field shows "Check this title and try again.",
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the
First paragraph field shows no error.
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values
are kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
moment.", and neither field shows an error.
9. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
`request` is called a second time with the same JSON body; resolve 201 with the fixture; assert
the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
(id "article-created-42") is raised.
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately
with no confirmation UI, and `request` was never called.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "New article". Run `task visual:check -- new-article` for each state
below and compare against the board frame; add each state's mock setup to `web/visual/states.ts`
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom", first
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no
time-dependent content, so no fixed `now` is needed.
- [ ] Default: empty, focused Title with counter "0 / 200", empty First paragraph, help text on
both fields, "Cancel" / "Create draft".
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters.";
First paragraph error "Add a first paragraph. It can be a single sentence."
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading
state.
- [ ] Title rejected (400): both fields filled, Title error "Check this title and try again.",
alert "Something in the form isn't valid. Check the highlighted fields."
- [ ] Error: both fields filled, alert "Couldn't create the article. Your text is still here. Try
again in a moment.", no field-level error.
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel →
Create draft; Esc closes the modal in every dismissible state and is blocked while
Submitting; the counter's live region announces only when its error/warn threshold is
crossed, not on every keystroke, per CharacterCounter's a11y spec.
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
## Manual verification
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`;
confirm the real 201/400/500 responses and that a genuinely-created article is retrievable at
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never
exercise the real Go handler or `CreateArticleUseCase`.
- **Toast placement and stacking on a live app** — confirm the "Draft created" toast's real
position, duration and stacking behavior (per docs/design/components.md's Toast rules) against
a running app, since Vitest only asserts that `useToast().add` was called with the right
arguments, not the rendered/animated toast region.
+147
View File
@@ -0,0 +1,147 @@
Feature: Nuxt UI shell foundation and router guards
As the maintainer of RenewCMS
I want the SPA to run on Nuxt UI instead of PrimeVue, with AuthShell and DashboardShell layouts,
an AppBoot skeleton, and a router guard that resolves and caches the session once per app load
So that every later screen slice can build on a themed, guarded shell instead of an unguarded router
with no layout
Background:
Given web/src/main.ts wires the app through the @nuxt/ui/vite plugin, and no longer imports
primevue, @primeuix/themes, primeicons or tailwindcss-primeui
And web/src/theme/ui.config.ts and web/src/assets/main.css hold the token files copied from
docs/design/source/tokens/ (ui.config.ts, renewcms.css), and public/favicon.svg is the file
copied from docs/design/source/brand/favicon.svg
And the router declares route meta.auth as one of "public", "session", "verified" or "any", and
meta.title for every verified route
And a global router.beforeEach guard runs on every navigation, and on the first navigation of the
app it resolves the session by calling GET /v1/auth/me once and caches the result (session or
"no session") in a session Pinia store; later navigations in the same app load reuse that
cached result instead of calling GET /v1/auth/me again
And /home, /article and /integration currently render only a trivial placeholder view inside
DashboardShell; later screen slices (Home, Articles, Integration) replace that placeholder
content with the real screens
Scenario: A public route never triggers a session check
Given the target route is /login, whose meta.auth is "public"
When the router navigates to it as the first navigation of the app
Then GET /v1/auth/me is not called
And AuthShell renders immediately, with no AppBoot skeleton shown
Scenario: The first navigation to a non-public route resolves the session and shows AppBoot meanwhile
Given the target route is /home, whose meta.auth is "verified"
And GET /v1/auth/me has not resolved yet
When the router navigates to /home as the first navigation of the app
Then DashboardShell renders with the AppBoot skeleton in place of the page content
When GET /v1/auth/me then resolves 200 with is_verified true
Then the AppBoot skeleton is replaced by the routed page's content
And the navigation is not redirected
Scenario: A second navigation reuses the cached session instead of calling GET /v1/auth/me again
Given GET /v1/auth/me has already resolved 200 with is_verified true during the first navigation
When the router navigates from /home to /article, whose meta.auth is also "verified"
Then GET /v1/auth/me is not called a second time
And no AppBoot skeleton is shown for this navigation
Scenario: A verified-only route admits a verified session
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to /article
Then the navigation completes to /article
And there is no redirect
Scenario: A verified-only route redirects an unverified session to the pending page
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /article
Then the router redirects to /register/pending
And the navigation to /article does not complete
Scenario: A session-only route admits an unverified session
Given the target route is /register/pending, whose meta.auth is "session"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /register/pending
Then the navigation completes to /register/pending
And there is no redirect
Scenario: No session redirects to login with the original path and an expired reason
Given the target route is /article/12/edit, whose meta.auth is "verified"
And GET /v1/auth/me resolves 401
When the router navigates to /article/12/edit
Then the router redirects to "/login?redirect=/article/12/edit&reason=expired"
And the navigation to /article/12/edit does not complete
Scenario Outline: The root path resolves to /home, /login or /register/pending depending on the session
Given GET /v1/auth/me resolves <response>
When the router navigates to /
Then the router redirects to <destination>
Examples:
| response | destination |
| 200 with is_verified true | /home |
| 401 | /login |
| 200 with is_verified false | /register/pending |
Scenario: A session check that fails with a server error renders the Generic error view instead of redirecting
Given the target route is /, whose meta.auth is "any"
And GET /v1/auth/me fails with a 500 response
When the router navigates to /
Then the router does not redirect anywhere
And the Generic error view renders in place, with breadcrumb "Home", title "Something went wrong",
text "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening,
restart the RenewCMS server.", a primary action "Try again" and a secondary action "Go to Home"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario: A session check that fails with a network error on a non-root verified route also renders the Generic error view
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me fails with a network error
When the router navigates to /article
Then the router does not redirect to /login or /register/pending
And DashboardShell renders the Generic error view in place of the AppBoot skeleton and the page
content, with title "Something went wrong" and a primary action "Try again"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario Outline: Each verified placeholder route renders inside DashboardShell with its own heading, breadcrumb and active nav item
Given GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to <path>
Then the route renders inside DashboardShell
And the page heading is <title>
And the breadcrumb is <title>
And the nav item for <title> has aria-current="page"
Examples:
| path | title |
| /home | "Home" |
| /article | "Articles" |
| /integration | "Integration" |
Scenario: AuthShell renders its title as the page heading and sets the document title
Given a screen renders AuthShell with title "Log in" and no description
When AuthShell mounts
Then AuthShell renders an h1 containing "Log in"
And document.title is "Log in · RenewCMS"
Scenario: AuthShell renders its notice and footer slots
Given a screen renders AuthShell with a "notice" slot containing "Your session expired." and a
"footer" slot containing a "Forgot password?" link
When AuthShell mounts
Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the four navigation destinations
Given the current route is /article
When DashboardShell mounts
Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
/home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
to /integration
And the "Articles" destination has aria-current="page"
And none of "Home", "Users" and "Integration" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false
When DashboardShell mounts
Then an offline banner is shown
When navigator.onLine becomes true
Then the offline banner is hidden
+73
View File
@@ -0,0 +1,73 @@
# QA: Nuxt UI shell foundation and router guards
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest server.
1. **Public route skips session check** — mount the router at /login; assert the mocked
GET /v1/auth/me function is never invoked, and AuthShell is rendered with no AppBoot skeleton.
2. **AppBoot shown while GET /v1/auth/me is pending** — mount the router at /home with an
unresolved GET /v1/auth/me promise; assert the AppBoot skeleton (USkeleton) is present and the
route's own content is absent; resolve the promise with 200 is_verified true; assert the skeleton
is gone and the route's content is present.
3. **Session cached across navigations** — resolve GET /v1/auth/me once (200 is_verified true),
navigate /home → /article; assert the mock was called exactly once.
4. **Verified route + verified session** — mock 200 is_verified true, navigate to /article; assert
the router's current route is /article (no redirect).
5. **Verified route + unverified session** — mock 200 is_verified false, navigate to /article; assert
the router redirects to /register/pending.
6. **Session route + unverified session** — mock 200 is_verified false, navigate to
/register/pending; assert no redirect.
7. **No session** — mock a 401, navigate to /article/12/edit; assert redirect to
/login?redirect=/article/12/edit&reason=expired.
8. **Root path outcomes** — one test per Examples row: 200 is_verified true → /home, 401 → /login,
200 is_verified false → /register/pending.
9. **Boot failure at root renders the Generic error view** — mock GET /v1/auth/me rejecting with a
500, navigate to /; assert no redirect happens, and the Generic error view renders with title
"Something went wrong" and text "This page couldn't load. Your saved work is safe. Try again, and
if it keeps happening, restart the RenewCMS server."; click "Try again"; assert
GET /v1/auth/me is called a second time.
10. **Boot failure at a non-root verified route renders the Generic error view** — mock
GET /v1/auth/me rejecting with a network error, navigate to /article; assert no redirect to
/login or /register/pending happens, DashboardShell shows the Generic error view instead of
AppBoot or page content; click "Try again"; assert GET /v1/auth/me is called a second time.
11. **Placeholder verified routes wire up shell, heading, breadcrumb and active nav** — one test per
Examples row (/home → "Home", /article → "Articles", /integration → "Integration"): mock 200
is_verified true, navigate to the path; assert the route renders inside DashboardShell, the page
heading and breadcrumb both equal the expected title, and the corresponding nav item has
aria-current="page" while the other two do not.
12. **AuthShell renders title as h1 and sets document.title** — mount AuthShell with
title="Log in"; assert an h1 with text "Log in" and document.title === "Log in · RenewCMS".
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
4 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration), and only the Articles link
has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
it hides.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Back-office.dc.html`, groups "Boot" and "Global",
plus the components.md AuthShell/DashboardShell sections.
- [ ] AuthShell at desktop (sm+): 400px UPageCard variant="outline", centered, top offset 12vh,
light and dark.
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
light and dark.
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
slideover, closes after navigating.
- [ ] DashboardShell active nav item uses the sand-fill/ink-text bend, not Nuxt UI's default
primary-tinted background.
- [ ] AppBoot skeleton visual match to the boot board, light and dark.
- [ ] Generic error view visual match to the Global board's "Generic error" state, desktop and
mobile, light and dark (breadcrumb "Home", title "Something went wrong", primary "Try again",
secondary "Go to Home").
- [ ] Keyboard: first Tab stop on a DashboardShell route is the "Skip to content" link; sidebar is
a `nav` labeled "Main"; collapse button exposes `aria-expanded`; AuthShell moves focus to its
h1 on route change; the Generic error view's title gets focus on render.
- [ ] Favicon renders as the copied favicon.svg mark in the browser tab, dark-aware.
+153
View File
@@ -0,0 +1,153 @@
Feature: Pending registration screen
As a logged-in user who has not yet verified their account
I want /register/pending to tell me where the verification link went, let me log out or cancel
my registration, and route me to the right destination no matter which of those calls
succeeds or fails
So that I always know what to do next while waiting for the emailed link, and a device that
verified elsewhere or a server error never strands me on the wrong screen
Background:
Given /register/pending is a "session" route (meta.auth "session"), rendered as PendingPage
inside AuthShell, per docs/design/routes.md; "session" only requires a logged-in account, so
both a verified and an unverified account can land on it without being redirected away by
the router guard
And PendingPage only calls DELETE /v1/auth/register and POST /v1/auth/logout
And RegisterView.vue, on a 200 from POST /v1/auth/register, writes the submitted email to
sessionStorage under the key "renewcms:pendingEmail" before navigating to /register/pending;
this is the only change this slice makes to RegisterView.vue
And on mount, PendingPage reads "renewcms:pendingEmail" from sessionStorage once: if present,
it renders the Default state with that email interpolated into the ResultPanel text; if
absent (for example, the user arrived by logging in as an unverified account rather than by
just registering), it renders the Email unknown state with the generic text
And in both cases the ResultPanel has tone "success", icon "i-lucide-mail-check", title "Check
your email", and two secondary actions, "Log out" and "Cancel registration" (color
"neutral", variant "outline"); the note line ("Opened it on another device? That works too;
you'll log in there first. Can't find it? Check your spam folder. If the link has expired,
cancel your registration and start again.") is rendered through ResultPanel's default slot
as a second paragraph below the description, per components.md's "Slot default for extras"
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and
clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to
/login, disabling both actions while the call is in flight
And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it
but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel
your registration?", description "Your account will be deleted. You can register again with
the same or a different email.", confirmLabel "Cancel registration", cancelLabel "Keep my
account" (components.md's "Other uses" line pairs title/description/confirmLabel/cancelLabel
in that order, so ConfirmDialog exposes a cancelLabel prop alongside confirmLabel even though
the compact props line only names confirmLabel), pendingLabel "Cancelling…", and tone
"danger"; its action prop performs the DELETE /v1/auth/register call
And while the dialog's action is in flight (Cancelling), both buttons are disabled, the confirm
button reads "Cancelling…", aria-busy is set, and the dialog cannot be dismissed, per
ConfirmDialog's general contract in components.md
And PendingPage's DELETE /v1/auth/register call passes { onForbidden: 'passthrough' } (a new
RequestOptions field on useApi, added by this slice, mirroring the existing onUnauthorized
field: type 'redirect' | 'passthrough', default 'redirect'; when 'passthrough' is given, the
403 branch in useApi's request() skips the "if session.session?.verified === false, push to
/register/pending" step entirely and always throws ApiError(403)) so PendingPage's own 403
handling below runs instead of the global handler bouncing it back to /register/pending
And on a 200 from DELETE /v1/auth/register, the action resolves, the dialog closes, the app
navigates to /register, and a toast (color "success", icon "i-lucide-circle-check", title
"Registration cancelled", description "You can create the owner account again.") is shown
And on a 403 from DELETE /v1/auth/register (user.ErrAlreadyVerified, since the account was
verified on another device while this tab still holds a stale unverified session), the
action first awaits session.retry() so the session store's cached verified flag is refreshed
to true — without this, the router guard's stale local state would immediately bounce a
navigation to /home straight back to /register/pending — then the dialog closes,
PendingPage replaces its panel with a ResultPanel (tone "success", icon
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
verified", description "There's nothing to cancel.") over it, exactly as the design board
draws this state, then navigates to /home after the same 800 ms Verify's Success uses
And on a 500 response or a request that never reaches the server, the action rejects, the
dialog stays open, and it shows the failure inline (per ConfirmDialog's general contract:
"the action runs inside the dialog, so errors show there instead of a toast"), re-enabling
both buttons so the user can retry or choose "Keep my account"
And a 401 from either call is handled entirely by useApi's existing global 401 handling
(redirect to /login?redirect=...&reason=expired); PendingPage adds no special case for it
Scenario: Default — the email is known from a just-completed registration
Given sessionStorage holds "renewcms:pendingEmail" set to "marta@renew.example"
When the user navigates to /register/pending
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
And its title is "Check your email"
And its text is "We sent a verification link to marta@renew.example. It expires in 2 hours."
And its note reads "Opened it on another device? That works too; you'll log in there first.
Can't find it? Check your spam folder. If the link has expired, cancel your registration and
start again."
And its actions are "Log out" and "Cancel registration", both secondary
Scenario: Email unknown — arrived by logging in as an unverified account
Given sessionStorage holds no "renewcms:pendingEmail" entry
When the user navigates to /register/pending
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
And its title is "Check your email"
And its text is "We sent a verification link to the email you registered with. It expires in 2
hours."
And its note is the same as the Default state's note
And its actions are "Log out" and "Cancel registration", both secondary
Scenario: Logging out
Given the user is on /register/pending
When the user activates "Log out"
Then both actions are disabled while POST /v1/auth/logout is in flight
When the response resolves 200
Then the session store is cleared
And the app navigates to /login
Scenario: Confirm cancel — the destructive dialog opens
Given the user is on /register/pending
When the user activates "Cancel registration"
Then a ConfirmDialog opens with title "Cancel your registration?", description "Your account
will be deleted. You can register again with the same or a different email.", a "Keep my
account" cancel button, a "Cancel registration" confirm button in tone "danger", role
"alertdialog", and focus starting on "Keep my account"
And the underlying ResultPanel is still shown behind it, unchanged
Scenario: Cancelling — the DELETE is in flight and the dialog can't be dismissed
Given the ConfirmDialog from "Confirm cancel" is open
When the user activates "Cancel registration" inside the dialog
Then both dialog buttons are disabled, the confirm button reads "Cancelling…", aria-busy is set
on the dialog, and it cannot be dismissed by Escape or an overlay click
And DELETE /v1/auth/register is sent with { onForbidden: 'passthrough' }
Scenario: Keep my account — the dialog is dismissed without deleting anything
Given the ConfirmDialog from "Confirm cancel" is open
When the user activates "Keep my account"
Then the dialog closes, emitting close(false)
And DELETE /v1/auth/register is never called
And the ResultPanel from before the dialog opened is still shown, unchanged
Scenario: Cancelled — 200 navigates to /register with a toast
Given the ConfirmDialog's action is in flight after the user confirmed
When DELETE /v1/auth/register resolves 200
Then the dialog closes
And the app navigates to /register
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario: Already verified — 403 shows the verified panel and toast, then navigates to /home
Given the ConfirmDialog's action is in flight after the user confirmed
And the session store currently holds { verified: false }
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
Then session.retry() is awaited before any navigation happens
And once it resolves the session store holds { verified: true }
And the dialog closes
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
already verified", description "There's nothing to cancel."
And after 800 ms the app navigates to /home
Scenario Outline: Cancel failed — the dialog stays open and shows the failure inline
Given the ConfirmDialog's action is in flight after the user confirmed
When DELETE /v1/auth/register <failure>
Then the dialog does not close
And the failure is shown inline in the dialog, not as a toast
And both dialog buttons are re-enabled so the user can retry "Cancel registration" or choose
"Keep my account"
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
+81
View File
@@ -0,0 +1,81 @@
# QA: Pending registration screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`DELETE /v1/auth/register`, `POST /v1/auth/logout`) and `useToast`/`useOverlay` per
case; no real backend, no httptest server. Mount PendingPage inside AuthShell with a test router and
a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]` per case.
1. **Default state** — set `sessionStorage["renewcms:pendingEmail"] = "marta@renew.example"`; mount
at /register/pending; assert a ResultPanel with tone "success", icon "i-lucide-mail-check", title
"Check your email", text "We sent a verification link to marta@renew.example. It expires in 2
hours.", the note paragraph rendered via the default slot, and two secondary actions "Log out"
and "Cancel registration".
2. **Email unknown state** — clear `sessionStorage["renewcms:pendingEmail"]`; mount at
/register/pending; assert the same ResultPanel shape with text "We sent a verification link to
the email you registered with. It expires in 2 hours." and the same note and actions.
3. **Logging out** — mount with an unresolved `POST /v1/auth/logout` mock; click "Log out"; assert
both actions are disabled; resolve the mock 200; assert the session store's `clear()` was called
and the router's current path is /login.
4. **Confirm cancel opens the dialog** — click "Cancel registration"; assert a ConfirmDialog renders
with title "Cancel your registration?", description "Your account will be deleted. You can
register again with the same or a different email.", buttons "Keep my account" and "Cancel
registration" (tone danger), `role="alertdialog"`, and initial focus on "Keep my account".
5. **Cancelling state** — with the dialog open, mock `DELETE /v1/auth/register` with an unresolved
promise, click "Cancel registration" inside the dialog; assert both dialog buttons are disabled,
the confirm button reads "Cancelling…", `aria-busy="true"` on the dialog, and the mocked call was
made with `{ onForbidden: 'passthrough' }`.
6. **Keep my account** — with the dialog open, click "Keep my account"; assert the dialog closes,
`DELETE /v1/auth/register` was never called, and the underlying ResultPanel is unchanged.
7. **Cancelled state** — confirm the dialog; mock `DELETE /v1/auth/register` resolving 200; assert
the dialog closes, the router's current path is /register, and a toast was raised with color
"success", icon "i-lucide-circle-check", title "Registration cancelled", description "You can
create the owner account again."
8. **Already verified state** — seed the session store with `{ verified: false }`, `status:'ready'`;
confirm the dialog; mock `DELETE /v1/auth/register` resolving 403; mock the session store's
`retry()` to resolve and set `session.session = { verified: true }`; assert `retry()` was awaited
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
title "Your account is already verified", description "There's nothing to cancel." while the
path is not yet /home; then assert the router reaches /home after 800 ms with no second toast.
9. **Cancel failed state** — two sub-cases: (a) a 500 response, (b) a rejected fetch. In each,
confirm the dialog, resolve/reject the mock accordingly, and assert the dialog stays open, an
inline failure indicator is shown inside it (not a toast — assert no toast was raised), and both
dialog buttons are re-enabled.
10. **`useApi` onForbidden option (unit-level, alongside the existing suite in
`api_client_global_error_handling`'s spec file)** — seed `session = { verified: false }`; mock
fetch resolving 403; call `useApi().request('/v1/auth/register', { method: 'DELETE', onForbidden:
'passthrough' })`; assert the promise rejects with `ApiError` status 403; assert the router's
current route did not change (no redirect to /register/pending); assert the session store is
unchanged. This regresses nothing in the existing onForbidden-absent (default 'redirect')
scenarios already covered by `features/api_client_global_error_handling.feature`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending". Run
`task visual:check -- pending` for each state and compare against the board frame.
- [ ] Default state: icon tile, title, text with the interpolated email, note paragraph, "Log out"
and "Cancel registration" secondary buttons — matches the board exactly.
- [ ] Email unknown state: same layout with the generic text.
- [ ] Confirm cancel state: ConfirmDialog layout, button order (Keep my account left, Cancel
registration right, danger color), matches components.md's button-order rule.
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
over the /register board.
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
captured before the 800 ms redirect (the visual setup freezes timers with `paused: true`).
- [ ] Keyboard/focus: focus starts on "Keep my account" in the dialog and Enter does not confirm;
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
title on mount; toasts are announced without stealing focus.
## Manual verification
- **Real DELETE /v1/auth/register and POST /v1/auth/logout against a running server** — Vitest only
mocks `useApi`; confirm the real 200/403 round-trips against `task dev` with a live SQLite file.
The endpoint's own status/body/cookie contract is already covered by
`features/cancel_pending_registration.qa.md`; this slice only re-verifies the screen consumes it
correctly.
- **Router-guard staleness fix in a real browser** — confirm that after the "Already verified" 403,
a hard reload of /home does not bounce back to /register/pending, since Vitest's fake session store
can't fully reproduce the router's `afterEach` re-check timing.
+442
View File
@@ -0,0 +1,442 @@
Feature: Profile page, display name and the account menu
As a verified RenewCMS user in the shared workspace
I want /profile to let me set an optional display name, see my read-only account details and
email myself a password reset link, and I want the sidebar account chip to open a menu that
leads there
So that people are named by a human name across the back-office, while logging in still uses the
username, and I always have a way to reach Profile, switch theme and log out
Background:
Given /profile is a verified route (meta.auth "verified", meta.title "Profile") rendered as
ProfilePage inside DashboardShell, per docs/design/routes.md and docs/design/screens/profile.md
And the endpoints already exist and this slice wires the SPA to them with no Go change:
GET /v1/auth/me (returns id, username, email, is_verified, display_name), PATCH /v1/user/me
(features/user_display_name.feature) and POST /v1/auth/password-reset
(features/request_password_reset.feature)
And useApi's getMe and the Session type gain email and displayName; a missing or null
display_name in a response is read as ""; the session store gains an action that replaces
the session's displayName from the display_name of a PATCH /v1/user/me response
And the PageHeader shows breadcrumb "Profile" (mobile title "Profile"), the page title
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the four
navigation items (Home, Articles, Users, Integration) has aria-current="page" on /profile
And the page is at most 720 px wide with three ProfileCards in this order: Display name,
Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and
inputs are 40 px high (44 px on mobile, 16 px text)
And the Display name card has the title "Display name", the text "Shown instead of your
username across the back-office and in the emails RenewCMS sends. Leave it empty to use your
username.", one input labelled "Display name (optional)" with placeholder "e.g. Marta Alves",
and a help row under the input: the field error on the left (icon circle-alert, 12 px, error
color) and the counter "n / 60" on the right in the mono style; there is no maxlength, so
typing past 60 is possible and shows the error
And the Display name card's footer strip (top border, --ui-bg-muted, 12 px status text on the
left, Save button on the right, 32 px high on desktop and 44 px on mobile) shows "Unsaved
changes" in the warning color while the draft is dirty and no save is in flight; otherwise
it shows "You appear as “{display name}”." when a display name is saved, or "You appear as
“{username}”, your username." when none is
And the draft is the trimmed field value (String.prototype.trim); it is dirty when it differs
from the session's displayName; it is valid when its length counted in Unicode code points
(Array.from(value).length, the same count as the API's utf8.RuneCountInString, not bytes,
not UTF-16 units and not grapheme clusters) is 60 or fewer; the counter shows that count
and turns red (error color) above 60
And Save is enabled (full opacity) while the draft is dirty and valid, or while a save is in
flight; otherwise it is disabled at 40 % opacity; pressing Enter in the field activates Save
only when Save is enabled, and does nothing otherwise
And activating Save sends PATCH /v1/user/me with JSON {"display_name": "<trimmed draft>"};
while it is in flight the field is disabled and dimmed, the button reads "Saving…" with a
loader-circle icon, and a second submit is ignored
And a 200 response replaces session.displayName with the response's display_name at once (the
server's value, not the typed one), sets the field to that value, and raises a toast:
color success, icon i-lucide-circle-check, id "display-name-saved", title "Display name
saved", description "You now appear as {display name} across RenewCMS and in emails."
And the display name is shown wherever a person is named, read from the session: the sidebar
chip and the account menu (name and initials) and the Home greeting "{greeting}, {name}",
where name is the display name when not empty and the username otherwise; the Users list
reads display_name from its own GET /v1/user/ items and is not changed by this slice
And any non-2xx or failed PATCH keeps the typed value and the dirty state; a 401 and a 403
are handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature)
And the Account card has the title "Account", the text "Set when your account was created. You
log in with your username, even after adding a display name.", and two disabled inputs with
a lock icon at the right: "Username" (the session's username) and "Email" (the session's
email), in two columns on desktop (minmax(0,1fr) minmax(0,1.4fr)) and stacked on mobile
And the Password card has the title "Password", the text "To change it, we'll email a reset
link to {email}. The link works once, within 1 hour.", a disabled input labelled "Password"
showing "••••••••••••" with a lock icon, and, beside it (stacked on mobile), a secondary
button "Send reset link" with the mail icon; the note under them exists only once a request
has been made
And activating the button sends POST /v1/auth/password-reset with JSON {"email": "<session
email>"}, with no confirmation step; while in flight the button reads "Sending…" with a
loader-circle icon and is disabled; the note is role="status", 16 px icon, 600-weight title
and the text on a soft background
And leaving /profile while the draft is dirty (a route change, including Log out) opens a
ConfirmDialog, except when the redirect is caused by a 401 handled by useApi
(authRedirectPending); leaving while the draft is not dirty, or after a successful save,
opens nothing
And the UserMenu chip in the sidebar footer no longer logs out on click: it opens the account
menu; the chip is 40 px high with a 26 px sand (#d4c5b0) initials avatar with ink text, the
name (display name, else username) with truncation, and a chevrons-up-down icon; it has
aria-haspopup="menu", aria-expanded reflecting the open state, and title "Account"
And while the menu is open, or while the current route is /profile, the chip takes --rc-sand
with #111111 text
And on desktop (lg and up) the menu is a 224 px popover above the chip aligned to its left
edge with --rc-shadow-pop; below lg the account row in the navigation drawer closes the
drawer and opens the menu as a bottom sheet with a grab handle over an --rc-overlay scrim;
Escape or a click outside closes either, and focus returns to the chip; arrow keys move
between items
And the menu content, top to bottom, is: a header (the name in semibold, and the email below
it in muted 12 px on desktop; on mobile a 36 px initials avatar, the name at 15 px semibold
and the email at 14 px muted), a "Profile" item (icon circle-user, highlighted with
--ui-bg-elevated while the route is /profile), a separator, the three checkbox items
"System", "Light" and "Dark" bound to useColorMode().preference with the current one
checked (kept from the shipped menu), a separator, and "Log out" (icon log-out); items are
32 px on desktop and 48 px at 15 px text on mobile
And activating "Profile" navigates to /profile and closes the menu (when already on /profile
it only closes); activating a theme item sets the preference and closes the menu;
activating "Log out" behaves as today: POST /v1/auth/logout in a try/finally, then
session.clear() and a navigation to /login
And avatar initials are the uppercased first Unicode character of each of the first two words
of the display name; a display name with a single word, and a username, give their first two
characters, uppercased
And in the collapsed sidebar rail the chip shows only the avatar, keeps its accessible name
"{name} — account menu" and opens the same menu
And this slice does not touch the invitation or accept-invite work, does not change the
Users page, and does not add a way to change the username, email or password inside the page
Scenario Outline: Route guards for /profile
Given the session is <session>
When the user navigates to /profile
Then <outcome>
Examples:
| session | outcome |
| resolved, verified | ProfilePage renders |
| resolved, unverified | the app redirects to /register/pending |
| resolved with no session (401 from /auth/me) | the app redirects to /login?redirect=/profile&reason=expired |
Scenario: Default — the usual first visit with no display name
Given the session is verified with id 1, username "marta", email "marta@renew.example" and no
display name
When the user navigates to /profile
Then the breadcrumb reads "Profile" (mobile title "Profile")
And the Display name field is empty, shows the placeholder "e.g. Marta Alves", and the counter
reads "0 / 60"
And the footer reads "You appear as “marta”, your username."
And Save is disabled
And the Username input shows "marta" and the Email input shows "marta@renew.example", both
disabled
And the Password card text reads "To change it, we'll email a reset link to
marta@renew.example. The link works once, within 1 hour."
And the button reads "Send reset link", no note is shown, and no request other than the
session's was sent
Scenario: Loading — opened by URL before /auth/me has returned
Given GET /v1/auth/me has not yet resolved
When the user opens /profile directly by URL
Then the breadcrumb reads "Profile", and the page title and subtitle are shown
And three skeleton cards replace the real cards (one with a title bar and one input, one with a
title bar and two inputs, one with a title bar and one input), 16 px apart, in an
aria-busy="true" container
And AppBoot is not shown
When GET /v1/auth/me then resolves 200 for the verified user "marta"
Then the skeleton cards are replaced by the Default state
Scenario: Opened from the menu, the cached user renders at once
Given the user is on /home with a resolved session for "marta"
When the user activates "Profile" in the account menu
Then the Default state renders with no skeleton, and GET /v1/auth/me is not called again
Scenario: Editing — typing turns Save on
Given the user is on /profile in the Default state
When the user types "Marta Alves" into Display name
Then the counter reads "11 / 60" and the footer reads "Unsaved changes" in the warning color
And Save is enabled
And no request has been sent
Scenario Outline: The counter counts Unicode characters after trimming
Given the user is on /profile in the Default state
When the user types <value> into Display name
Then the counter reads "<count> / 60"
And the counter is <color> and Save is <save>
Examples:
| value | count | color | save |
| "Marta Alves" | 11 | muted | enabled |
| " Marta Alves " | 11 | muted | enabled |
| 60 repetitions of "a" | 60 | muted | enabled |
| 61 repetitions of "a" | 61 | red | disabled |
| 60 repetitions of "😀" | 60 | muted | enabled |
| 61 repetitions of "😀" | 61 | red | disabled |
| "Cafe" followed by U+0301 | 5 | muted | enabled |
| 5 spaces, 60 "a", 5 spaces | 60 | muted | enabled |
Scenario: Too long — over 60 characters shows a field error and keeps Save off
Given the user is on /profile in the Default state
When the user types "Marta Alves, Head of Renovations, Brussels and Wallonia office"
Then the counter reads "62 / 60" in the error color
And the help row shows the error "Use 60 characters or fewer." with the circle-alert icon
And the field has the error border
And the footer reads "Unsaved changes" in the warning color
And Save is disabled
When the user presses Enter in the field
Then PATCH /v1/user/me is never sent
When the user shortens the value to "Marta Alves"
Then the error disappears, the counter reads "11 / 60" and Save is enabled
Scenario Outline: Dirty is measured on the trimmed draft against the saved name
Given the session's display name is "<saved>"
When the user sets Display name to <typed>, read literally as described in the column
Then Save is <save>
And the footer reads "<footer>"
Examples:
| saved | typed | save | footer |
| | 3 spaces | disabled | You appear as “marta”, your username. |
| Marta Alves | Marta Alves | disabled | You appear as “Marta Alves”. |
| Marta Alves | Marta Alves and a space | disabled | You appear as “Marta Alves”. |
| Marta Alves | nothing | enabled | Unsaved changes |
| Marta Alves | Marta, 2 spaces, Alves | enabled | Unsaved changes |
Scenario: Enter saves when Save is enabled
Given the user is on /profile and has typed "Marta Alves"
When the user presses Enter in the field
Then PATCH /v1/user/me is sent once with JSON {"display_name": "Marta Alves"}
Scenario: Saving — the field is disabled and a second submit is ignored
Given the user is on /profile and has typed " Marta Alves " and PATCH /v1/user/me has not
resolved
When the user activates Save
Then PATCH /v1/user/me is sent with JSON {"display_name": "Marta Alves"}
And the field is disabled and dimmed, and the button reads "Saving…" with a loader-circle icon
When the user presses Enter or activates Save again
Then no second request is sent
Scenario: Saved — a 200 updates the session, the chip, the menu, the footer and raises a toast
Given the user is on /profile and Save has been activated with "Marta Alves"
When PATCH /v1/user/me resolves 200 with id 1, username "marta" and display_name "Marta Alves"
Then session.displayName is "Marta Alves"
And the sidebar chip reads "Marta Alves" with initials "MA"
And the field shows "Marta Alves" and is enabled, the counter reads "11 / 60"
And the footer reads "You appear as “Marta Alves”." and Save is disabled
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
"Display name saved", description "You now appear as Marta Alves across RenewCMS and in
emails."
And no leave-page confirmation opens when the user then navigates away
Scenario: The server's value wins over what was typed
Given the user has typed " Marta Alves " and activated Save
When PATCH /v1/user/me resolves 200 with display_name "Marta Alves"
Then the field, the chip and the toast all use "Marta Alves"
Scenario: Clearing the display name goes back to the username
Given the session's display name is "Marta Alves" and the username is "marta"
When the user empties Display name and activates Save
Then PATCH /v1/user/me is sent with JSON {"display_name": ""}
And when it resolves 200 with display_name "" the sidebar chip reads "marta" with initials "MA"
And the footer reads "You appear as “marta”, your username."
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
"Display name removed", description "You now appear as marta, your username."
Scenario Outline: Save failed — a 500 or a network failure keeps the text and offers a retry
Given the user is on /profile and Save has been activated with "Marta Alves"
When PATCH /v1/user/me <failure>
Then an error alert (role="alert", icon circle-x) in the Display name card reads "Couldn't save
your display name. Your text is still here. Try again in a moment."
And the field still shows "Marta Alves" and is enabled
And the footer reads "Unsaved changes" and Save is enabled
And session.displayName is unchanged and no toast is raised
When the user activates Save again
Then the alert disappears while the request is in flight
And PATCH /v1/user/me is sent again with the same body
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: A 400 from the API
Given the user is on /profile and Save has been activated with "Marta Alves"
When PATCH /v1/user/me resolves 400 with body "<body>"
Then <effect>
Examples:
| body | effect |
| Display name must be 60 characters or fewer. | the field shows the error "Use 60 characters or fewer." and Save is disabled |
| invalid request body | the Save failed alert is shown and the text is kept |
Scenario: Leaving with unsaved changes asks for confirmation
Given the user is on /profile with the draft "Marta Alves" (dirty)
When the user activates "Users" in the navigation
Then a ConfirmDialog (neutral tone) opens, titled "Leave without saving?" with the text "Your
display name change hasn't been saved."
And its actions are "Discard changes" and "Keep editing"
When the user activates "Keep editing"
Then the dialog closes and the app stays on /profile with the draft intact
When the user tries to leave again and activates "Discard changes"
Then the app navigates to /users
And the display name saved in the session is unchanged
Scenario: No confirmation when nothing is unsaved
Given the user is on /profile in the Default state
When the user activates "Users" in the navigation
Then the app navigates to /users with no dialog
Scenario: A 401 while saving redirects without the leave dialog
Given the user is on /profile with the draft "Marta Alves"
When PATCH /v1/user/me resolves 401
Then the app redirects to /login?redirect=/profile&reason=expired
And no leave-page dialog opens
Scenario: Account menu — opened on desktop
Given the user is on /home on desktop with the session "marta", email "marta@renew.example",
no display name
When the user activates the account chip
Then the chip has aria-expanded="true" and the sand fill with ink text
And a 224 px popover opens above the chip, aligned to its left edge
And its header shows "marta" in semibold and "marta@renew.example" in muted 12 px
And below it are "Profile" (circle-user), a separator, "System", "Light", "Dark" (the
current preference checked), a separator, and "Log out" (log-out)
And "Profile" is not highlighted
Scenario: Account menu — opened from the drawer on mobile
Given the user is on /home on a viewport below lg and the navigation drawer is open
When the user activates the account row in the drawer
Then the drawer closes and a bottom sheet with a grab handle opens over the --rc-overlay scrim,
with the Home page behind it
And its header shows a 36 px avatar "MA", the name "marta" at 15 px semibold and the email
"marta@renew.example" muted
And its items are 48 px high: "Profile", a separator, "System", "Light", "Dark", a separator,
"Log out"
Scenario Outline: Closing the menu
Given the account menu is open
When <action>
Then the menu closes, the chip has aria-expanded="false", and focus is on the chip
Examples:
| action |
| the user presses Escape |
| the user clicks outside the menu |
Scenario: Menu items
Given the account menu is open on /home
When the user activates "Profile"
Then the app navigates to /profile and the menu closes
Given the account menu is open on /profile
Then "Profile" is highlighted with --ui-bg-elevated
When the user activates "Profile"
Then the menu closes and the app stays on /profile with no new request
When the user opens the menu and activates "Log out"
Then POST /v1/auth/logout is sent, then the session is cleared and the app navigates to /login
Scenario: The chip is sand while on /profile even with the menu closed
Given the user is on /profile with the menu closed
Then the chip has the --rc-sand fill and #111111 text, and aria-expanded="false"
When the user navigates to /home
Then the chip has a transparent fill
Scenario: Theme choice stays in the menu
Given the account menu is open and useColorMode().preference is "system"
When the user activates "Dark"
Then useColorMode().preference is "dark"
And the menu closes
And reopening it shows "Dark" checked and "System" not checked
Scenario Outline: Avatar initials
Given the session has username "<username>" and display name "<display>"
Then the chip and the menu header show the initials "<initials>" and the name "<shown>"
Examples:
| username | display | initials | shown |
| marta | | MA | marta |
| marta | Marta Alves | MA | Marta Alves |
| marta | Marta Alves Costa | MA | Marta Alves Costa |
| ines | Inès Duarte | ID | Inès Duarte |
| elodie | Élodie Martin | ÉM | Élodie Martin |
| marta | Marta | MA | Marta |
| m.alves | | M. | m.alves |
Scenario: The collapsed rail keeps the menu and an accessible name
Given the sidebar is collapsed to its icon rail and the display name is "Marta Alves"
Then the chip shows only the avatar "MA" and has the accessible name "Marta Alves — account menu"
When the user activates it
Then the same menu opens
Scenario Outline: The Home greeting uses the display name, else the username
Given the session has username "marta" and display name "<display>"
When the user views /home at a local hour of 9
Then the greeting's name is "<name>"
Examples:
| display | name |
| | marta |
| Marta Alves | Marta Alves |
Scenario: A saved display name appears on Home without a reload
Given the user saved the display name "Marta Alves" on /profile
When the user navigates to /home
Then the greeting's name is "Marta Alves" and the chip reads "Marta Alves"
Scenario: Reset: sending — no confirmation, the button is busy
Given the user is on /profile with the session's display name "Marta Alves"
When the user activates "Send reset link"
Then POST /v1/auth/password-reset is sent with JSON {"email": "marta@renew.example"}
And the button reads "Sending…" with a loader-circle icon and is disabled
And no note is shown, and the sidebar chip still reads "Marta Alves"
Scenario: Reset: sent — the note stays and "Send again" sends a new link
Given "Send reset link" has been activated
When POST /v1/auth/password-reset resolves 200
Then the button reads "Send again" with the mail icon and is enabled
And a success note (role="status", icon mail-check) shows the title "Reset link sent" and the
text "Check marta@renew.example. The link works once, within 1 hour."
When the user activates "Send again"
Then POST /v1/auth/password-reset is sent again with the same body
And the note is hidden while it is in flight
When it resolves 200
Then the "Reset link sent" note is shown again
When the user navigates away and back to /profile
Then the button reads "Send reset link" and no note is shown
Scenario Outline: Reset: too many requests — the button is disabled for the Retry-After window
Given "Send reset link" has been activated
When POST /v1/auth/password-reset resolves 429 with <header>
Then a warning note (role="status", icon clock-alert) shows the title "Too many requests" and
the text "You've asked for several links recently. Wait a few minutes, then try again."
And the button reads "Send reset link", is disabled and is at 40 % opacity
When <seconds> seconds elapse
Then the button is enabled and the warning note is gone
Examples:
| header | seconds |
| a Retry-After of 42 | 42 |
| a Retry-After of 300 | 300 |
| no Retry-After header | 300 |
| a Retry-After of "soon" | 300 |
| a Retry-After of 0 | 300 |
Scenario Outline: Reset: failed — a 500 or a network failure leaves the button pressable
Given "Send reset link" has been activated
When POST /v1/auth/password-reset <failure>
Then an error note (role="status", icon circle-x) shows the title "Couldn't send the link" and
the text "Check your connection and try again."
And the button reads "Send reset link" and is enabled
When the user activates it and POST /v1/auth/password-reset resolves 200
Then the note becomes "Reset link sent" and the button reads "Send again"
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Display names in the Users list
Given the session is "marta" with display name "Marta Alves"
And GET /v1/user/ resolves 200 with the 5-item Default fixture of users_screen.feature, where
user id 1 "marta" has display_name "Marta Alves" and user id 3 "ines" has display_name
"Inès Duarte"
When the user navigates to /users
Then the breadcrumb reads "Users" (mobile title "Users") and the sidebar chip reads "Marta Alves"
And the "marta" row shows "Marta Alves" with "marta" beside it in muted text and the "you" tag
and an Accepted badge
And the "ines" row shows "Inès Duarte" with "ines" beside it
And the "jonas" row shows "jonas" alone
+164
View File
@@ -0,0 +1,164 @@
# QA: Profile page, display name and the account menu
## Implementation note for coder (informational, not a QA assertion)
There is no Go change and no HTTP-level Go section: PATCH /v1/user/me, GET /v1/auth/me and POST
/v1/auth/password-reset are already covered (features/user_display_name.qa.md,
features/get_current_user.qa.md, features/request_password_reset.qa.md).
Amended specs (their Vitest tests must be updated in the same change): dashboard_shell_branding.feature
(UserMenu label, initials, groups and the collapsed aria-label), home.feature (greeting name),
plus session.spec.ts, useApi.spec.ts (getMe maps email and display_name, absent means ""),
UserMenu.spec.ts, DashboardShell.spec.ts and the HomeView spec.
The Saved toast uses `useToast().add({ id: 'display-name-saved', color: 'success', icon:
'i-lucide-circle-check', title: 'Display name saved', description: 'You now appear as {name}
across RenewCMS and in emails.' })`. The 429 countdown reuses `useRetryAfter`; the 300 s fallback
applies when `ApiError.retryAfter` is undefined or 0.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` and `getMe` per case; no real backend. Mount ProfilePage inside
DashboardShell with a resolved verified session (id 1, username "marta", email
"marta@renew.example", displayName "") and a test router at /profile. Use fake timers only for the
countdown. No test freezes the system date; the Home greeting cases mock the hour as
home.qa.md does and assert the name part only. Run the layout cases at a desktop and a
below-lg width (mock `matchMedia`).
1. **Route guards, Examples-driven** - verified renders; unverified redirects to
/register/pending; 401 redirects to /login?redirect=/profile&reason=expired.
2. **Default** - breadcrumb "Profile", title, subtitle, empty field with placeholder, "0 / 60",
footer "You appear as “marta”, your username.", Save disabled, Username and Email inputs
disabled with their values, Password text with the email, "Send reset link", no note; no
nav item has aria-current.
3. **Loading** - unresolved GET /v1/auth/me; assert three skeleton cards, aria-busy, no AppBoot;
resolve and assert Default.
4. **From the menu** - navigate from /home via "Profile"; assert no skeleton and no second
GET /v1/auth/me.
5. **Editing** - type "Marta Alves"; assert "11 / 60", "Unsaved changes", Save enabled, no request.
6. **Counter, Examples-driven** - each table row, including the 60/61 emoji rows and "Cafe" +
U+0301 (5); assert count, color class and Save state.
7. **Too long** - the 62-character value; assert "62 / 60", the error with circle-alert, "Unsaved
changes", Save disabled, Enter sends nothing; shorten and assert the error clears.
8. **Dirty rules, Examples-driven** - each row.
9. **Enter saves** - assert one PATCH with body `{"display_name":"Marta Alves"}`.
10. **Saving** - unresolved PATCH with a padded value; assert the trimmed body, disabled and dimmed
field, "Saving…", and that Enter and a second click send nothing.
11. **Saved** - resolve 200; assert session.displayName, chip "Marta Alves" with "MA", field value,
"11 / 60", footer, Save disabled, the toast arguments, and no leave dialog after navigating.
12. **Server value wins** - resolve with a trimmed display_name; assert all consumers use it.
13. **Clearing** - saved "Marta Alves", empty the field, save; assert body `{"display_name":""}`,
chip "marta", footer, and the "Display name removed" toast.
14. **Save failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the alert copy,
the kept value, the "Unsaved changes" footer, Save enabled, the session unchanged and no toast;
save again and assert the alert clears during the request and PATCH is sent again.
15. **400, Examples-driven** - `ApiError(400)` with the display-name text gives the field error;
with another body gives the Save failed alert.
16. **Leave guard** - dirty draft, navigate to /users; assert ConfirmDialog props (title, text,
"Keep editing" as confirm and "Discard changes" as cancel, neutral tone); Keep editing stays;
Discard leaves and leaves the session name unchanged. Also assert: clean draft leaves with no
dialog; a 401 during PATCH redirects with no dialog (authRedirectPending); Log out while dirty
shows the dialog.
17. **Menu, desktop** - click the chip; assert aria-haspopup, aria-expanded, title "Account", the
sand class, the header name and email, the item order (Profile, System, Light, Dark, Log out)
with the two separators, and Profile not highlighted.
18. **Menu, mobile** - below lg, open the drawer and activate the account row; assert the drawer
closed, the sheet with its grab handle, the 36 px avatar header and the same item order.
19. **Closing** - Escape and an outside click; assert closed, aria-expanded false, focus on the
chip; ArrowDown and ArrowUp move between items.
20. **Menu items** - Profile navigates and closes; on /profile it is highlighted, closes with no
navigation; Log out calls POST /v1/auth/logout, clears the session and goes to /login.
21. **Chip fill** - sand on /profile with the menu closed; not sand on /home.
22. **Theme** - activating "Dark" sets `useColorMode().preference`, closes the menu, and reopening
shows "Dark" checked.
23. **Initials, Examples-driven** - each row, including "Élodie Martin" giving "ÉM", a single
word giving two letters, and username "m.alves" giving "M." (first two characters).
24. **Collapsed rail** - avatar only, aria-label "Marta Alves — account menu", same menu.
25. **Home greeting, Examples-driven** - no display name gives "marta"; "Marta Alves" gives
"Marta Alves"; then save on /profile and navigate to /home and assert the new name. Do not
assert the greeting word or the hour.
26. **Reset: sending** - unresolved POST; assert body `{"email":"marta@renew.example"}`, "Sending…",
disabled, no note.
27. **Reset: sent and Send again** - 200 gives "Send again" and the mail-check note with the exact
copy; "Send again" hides the note during the request, sends the same body, and on 200 shows it
again; remounting resets to idle.
28. **Reset: 429, Examples-driven** - reject with `ApiError(429, <retryAfter>)`; assert the warning
note copy, disabled button reading "Send reset link"; advance fake timers by the table's
seconds minus 1 and assert still disabled, then by 1 and assert enabled with the note gone.
Rows: 42, 300, undefined (300), 0 (300).
29. **Reset: failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the error note
copy, enabled button, and that a retry resolving 200 gives the sent note.
30. **Users list** - mount UsersPage with session display name "Marta Alves" and the users_screen
Default items where marta and ines carry display names; assert the marta row shows
"Marta Alves" then "marta" and the "you" tag, ines shows "Inès Duarte" then "ines", jonas
shows alone, and the chip reads "Marta Alves".
## Visual (task visual:check -- profile, and -- home; desktop and mobile, light and dark)
Reference: `docs/design/screens/profile.md`, board group "Profile" in `docs/design/source/RenewCMS
Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each
state's setup to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
capture must be within 10 %. Base session mock: GET /v1/auth/me 200 with id 1, username
"marta", email "marta@renew.example", is_verified true, display_name "" (state-specific
overrides below).
- [ ] Account menu: /home with `now: boardNow` and the home Default fixtures, session as above,
menu opened by clicking the chip (on mobile: open the drawer, then the account row). Name
"marta", initials "MA", email "marta@renew.example". The theme items kept by user decision
are not drawn on the board, so the mobile capture is expected to exceed 10 %. The user has
approved a per-state `exception` for it, to be added with `approvedBy` only after the
real difference is measured and reported to the user with its cause.
- [ ] Loading: /profile with GET /v1/auth/me hanging; three skeleton cards, 16 px apart.
- [ ] Default, no display name: empty field, "0 / 60", the username footer, Save at 40 %.
- [ ] Editing: type "Marta Alves"; field focused; "11 / 60"; "Unsaved changes"; Save enabled.
- [ ] Too long: type "Marta Alves, Head of Renovations, Brussels and Wallonia office"; "62 / 60"
red; error "Use 60 characters or fewer."; Save at 40 %.
- [ ] Saving: type "Marta Alves", press Save with PATCH hanging; disabled dimmed field, "Saving…".
- [ ] Saved: PATCH 200 with display_name "Marta Alves"; chip "Marta Alves" "MA"; toast "Display name
saved" / "You now appear as Marta Alves across RenewCMS and in emails."
- [ ] Save failed: PATCH 500 after typing "Marta Alves"; the error alert in the card; "Unsaved changes".
- [ ] Reset: sending: session display_name "Marta Alves"; POST /v1/auth/password-reset hanging;
"Sending…".
- [ ] Reset: sent: same session; POST 200; "Send again" and the "Reset link sent" note.
- [ ] Reset: too many requests (429): POST 429 with header `Retry-After: 300`; disabled button at
40 % and the "Too many requests" note.
- [ ] Reset: failed: POST 500; the "Couldn't send the link" note.
- [ ] Display names in the Users list: /users, session display_name "Marta Alves"; GET /v1/user/
with the five board rows (sam.oliveira@renew.example pending 27 Sep 2026, p.huber@gmail.com
expired 3 Aug 2026, ines "Inès Duarte" 18 Jun 2026, jonas 2 Apr 2026, marta "Marta Alves"
12 Mar 2026 with "you"). Clock-sensitive rendering is skipped per repo rules.
- [ ] `task visual:check -- home` still PASSes for Default, Loading, Empty and Error. The session
fixture has display_name "", so the greeting name is "marta". Extend the fixture with
`email` and `display_name` (it must tolerate them being absent).
- [ ] Layout from the board frame, not components.md: content column 720 px max; cards separated by
20 px; the footer strip has the status text left and Save right (32 px high on desktop, 44 px
on mobile); the account inputs are two columns on desktop and stacked on mobile; the
password input and button are a row (button aligned to the bottom edge) on desktop and
stacked, full width, on mobile.
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the field, Save, "Send
reset link"; Escape returns focus to the chip; focus returns to the field after a failed
save), hover and active states, motion (popover and sheet, skeleton pulse 1.6 s, both
stopped under reduced motion).
- [ ] Not drawn, no visual state: the leave-page dialog, the "Display name removed" toast, the
400 variants. Vitest only (cases 13, 15, 16).
## Manual verification
- **Real round trips against `task dev`.** Vitest only mocks `useApi`. Save a display name and
confirm the chip, menu, Home greeting and Users list show it after a reload; clear it and
confirm the username returns; confirm login still works only with the username.
- **Unicode limit.** Paste 60 and 61 emoji and a combining-mark name; the client counter and
the API (`utf8.RuneCountInString`) agree at the boundary.
- **Real reset email** (needs real or sandboxed SMTP, `SMTP_HOST` and related vars). "Send reset
link" delivers a mail to the account's own address, the link ends on the reset-confirm screen,
and a completed reset invalidates the current session (`token_version`). "Send again"
invalidates the older link.
- **Real 429.** Exceed `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within `AUTH_RATE_LIMIT_WINDOW_SECONDS` and
confirm the button re-enables when the real Retry-After elapses. The limiter budget is shared
with login.
- **API quirk, out of scope.** POST /v1/auth/password-reset returns 200 even if the mail cannot be
sent (the handler discards the use-case error), so with SMTP down the page still shows "Reset
link sent". Reported, not fixed here.
- **Mobile sheet and toasts on a real device.** The drawer-to-sheet handoff, scrim, safe-area
padding, and the toast placement (bottom center on mobile).
- **Theme persistence.** The choice made from the menu survives a reload.
+112
View File
@@ -0,0 +1,112 @@
Feature: Register screen
As the first person to set up a fresh RenewCMS install
I want a /register screen that creates the single owner account via POST /v1/auth/register,
validates the same rules the API enforces before submitting, and tells me plainly when
someone already owns this site or the server failed
So that I can create my account once, land on the verification-pending page, and never see a
confusing or silent failure
Background:
Given /register is a public route (meta.auth "public") rendered as RegisterPage inside
AuthShell, per docs/design/routes.md
And RegisterPage only calls POST /v1/auth/register; it never calls GET /v1/auth/registration
And the form has a Username field (placeholder "e.g. marta", a live "N / 20" character
counter, help text "Letters and numbers, 3 to 20 characters. You'll use it to log in."), an
Email field (placeholder "you@example.com", help text "We'll send a verification link
here."), and a Password field (a password input with a show/hide toggle and a live
checklist item "At least 8 characters" that is unmet/error until the rule is satisfied, then
turns met/green)
And client-side validation runs on blur of each field and again on submit, before any request
is sent, using the same rules as the API: Username must be 3 to 20 characters (the API has
no character-class constraint beyond length, so the client checks length only, matching the
help text's length claim but not attempting to enforce "letters and numbers" client-side),
Email must be a well-formed email address, and Password must be at least 8 characters
And a failing Username shows "Use 3 to 20 characters."; a failing Email shows "Enter a full
email address, like name@example.com."; a failing Password leaves the checklist item in its
unmet/error state
And a valid submit calls POST /v1/auth/register with { username, email, password }
And on a 200 response, the jwt cookie is set by the server and the app navigates to
/register/pending without re-resolving the session first
And a 403 response means someone already verified an owner account (user.ErrRegistrationClosed
on the backend) and the whole form is replaced by a ResultPanel
And a 500 response, or a request that never reaches the server, is surfaced as a server/network
error; the fields keep whatever the user typed and the form re-enables
And a 400 response is not expected once client-side validation passes, since the client checks
the same rules as the API; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register
Then the heading is "Create the owner account"
And the description is "This creates the single admin account for this site. Once it's
verified, registration closes."
And the Username field shows placeholder "e.g. marta" with no value and counter "0 / 20"
And the Email field shows placeholder "you@example.com" with no value
And the Password field shows no value, with a show/hide toggle and the checklist item
"At least 8 characters" in its unmet state
And the submit button reads "Create account"
And the footer reads "Already set up?" with a "Log in" link to /login
Scenario: Validation errors — checked on blur and on submit
Given the user is on /register
When the user types "ma" into Username and blurs the field
Then the Username counter reads "2 / 20" in its error state and the error "Use 3 to 20
characters." is shown
When the user types "marta@renew" into Email and blurs the field
Then the Email field shows the error "Enter a full email address, like name@example.com."
When the user types "lime" into Password and blurs the field
Then the checklist item "At least 8 characters" stays in its error state
When the user submits with these same values
Then POST /v1/auth/register is never called
And all three errors from above are still shown
Scenario: Filled, valid — every rule is met
Given the user is on /register
When the user types "marta" into Username, "marta@renew.example" into Email, and
"limewash2026" into Password, and reveals the password with the show/hide toggle
Then the Username counter reads "5 / 20" with no error, and the help text "Letters and
numbers, 3 to 20 characters. You'll use it to log in." is shown
And the Email field shows no error, with the help text "We'll send a verification link here."
And the Password field shows "limewash2026" as plain text and the checklist item "At least 8
characters" turns to its met/green state
And the submit button reads "Create account" and is enabled
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register has not yet resolved
Then the Username, Email and Password fields are disabled
And the submit button reads "Creating account…" and shows a loading state
Scenario: Registration closed — the form is replaced by a ResultPanel
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 403
Then the form, its fields and the footer are no longer shown
And a ResultPanel with tone "info" and icon "user-x" is shown instead, with title "This site
already has an owner" and text "Log in instead."
And its single action is a primary button "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login
Scenario Outline: Server or network error — the form is kept and re-enabled
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register <failure>
Then an error alert reads "Couldn't create the account" with description "Something went
wrong on the server. Try again in a moment."
And the Username field still shows "marta", the Email field still shows "marta@renew.example",
and the Password field still shows "limewash2026"
And the Username, Email and Password fields are re-enabled
And the submit button reads "Create account" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: A successful registration goes straight to the pending page
Given the user is on /register with Username "marta", Email "marta@renew.example", and
Password "limewash2026"
When the user submits and POST /v1/auth/register resolves 200 with a jwt cookie
Then the app navigates to /register/pending
+73
View File
@@ -0,0 +1,73 @@
# QA: Register screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/register` only — RegisterPage never calls
`GET /v1/auth/registration`); no real backend, no httptest server. Mount RegisterPage inside
AuthShell with a test router.
1. **Default state** — mount at /register; assert heading "Create the owner account", description
"This creates the single admin account for this site. Once it's verified, registration
closes.", Username placeholder "e.g. marta" with no value and counter "0 / 20", Email
placeholder "you@example.com" with no value, Password with no value and the checklist item
"At least 8 characters" in its unmet state, submit button "Create account", and the footer
"Already set up? Log in" linking to /login.
2. **Validation errors state** — mount at /register; set Username to "ma" and blur; assert counter
"2 / 20" in error and the error "Use 3 to 20 characters."; set Email to "marta@renew" and blur;
assert the error "Enter a full email address, like name@example.com."; set Password to "lime"
and blur; assert the checklist item stays in its error state; submit; assert the mocked
`POST /v1/auth/register` was never called and all three errors are still shown.
3. **Filled, valid state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; toggle password visibility on; assert counter "5 / 20" with no error and the
help text restored, no Email error, Password rendered as plain text, the checklist item in its
met/green state, and the submit button enabled and reading "Create account".
4. **Submitting state** — set Username "marta", Email "marta@renew.example", Password
"limewash2026"; mock `POST /v1/auth/register` with an unresolved promise; submit; assert
Username, Email and Password are disabled and the submit button reads "Creating account…" with
a loading indicator.
5. **Registration closed state** — set the same valid values; mock `POST /v1/auth/register`
resolving 403; submit; assert the form and footer are no longer rendered; assert a ResultPanel
with title "This site already has an owner", text "Log in instead.", and a single primary
action "Go to log in"; click it and assert the router's current path is /login.
6. **Server or network error state** — two sub-cases: (a) a 500 response, (b) a rejected fetch
(network error). In each, set Username "marta", Email "marta@renew.example", Password
"limewash2026", submit, and assert an error alert with title "Couldn't create the account" and
description "Something went wrong on the server. Try again in a moment."; assert Username,
Email and Password still show their values; assert the three fields are re-enabled; assert the
submit button reads "Create account" and is no longer loading.
7. **Successful registration navigates to pending** — mock `POST /v1/auth/register` resolving 200;
set the same valid values; submit; assert the router's current path is /register/pending, and
that no `GET /v1/auth/me` call was made by RegisterPage itself.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Register". Run
`task visual:check -- register` for each state and compare against the board frame.
- [ ] Default state: heading, description, Username with counter and help text, Email with help
text, Password with show/hide toggle and the unmet checklist item, "Create account" button,
"Already set up? Log in" footer — matches the board exactly.
- [ ] Validation errors state: Username counter and error, Email error, and the Password checklist
item's error styling all render inline per components.md.
- [ ] Filled, valid state: the checklist item's met/green transition and the revealed password
text render correctly.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Registration closed state: the ResultPanel (info tone, user-x icon tile, title, text, single
primary "Go to log in" action) fully replaces the form and footer.
- [ ] Server error state: error alert (circle-x icon, no close button per the Log in screen's
precedent) above the heading, fields retaining their values, form re-enabled.
- [ ] Keyboard/focus: Tab order is Username → Email → Password → show/hide toggle → submit; when
the Registration closed ResultPanel appears, focus moves to its title and it is
`role="status"`; the server error alert is `role="alert"` and does not steal focus from
wherever the user was; the checklist item's live state change is not visually jarring
(no layout shift) as it flips from unmet to met.
## Manual verification
- **Real POST /v1/auth/register against a running server** — Vitest only mocks `useApi`; confirm
the real 403 (`user.ErrRegistrationClosed`) and a real 200 round-trip with the actual jwt cookie
against `task dev` with a live SQLite file, since this slice's automated tests never exercise the
actual Go handler, the `CreateUser` use case, or cookie issuance.
- **Real navigation to /register/pending after a live registration** — confirm the route guard
reads the freshly set cookie correctly, since Vitest never resolves a real session against
GET /v1/auth/me on that navigation.
@@ -28,8 +28,8 @@ Feature: Registration is only open while no verified account exists, and it send
both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer
sends mail and no longer holds a SendMailUseCase or PublicURL itself
And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls
mail.Repository.Send with the user's email, template "mailValidation"
(internal/infrastructure/mailer/templates/mailValidation.html, which renders a link from
mail.Repository.Send with the user's email, template "verification"
(internal/infrastructure/mailer/templates/verification.html, which renders a link from
"{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public
URL and whose VerificationCode is the raw (unhashed) code
And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the
@@ -45,7 +45,7 @@ Feature: Registration is only open while no verified account exists, and it send
email "alice@example.com"
Then the response status is 200
And a jwt cookie is set
And exactly one email was sent to "alice@example.com" using template "mailValidation"
And exactly one email was sent to "alice@example.com" using template "verification"
And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its
stored hash
@@ -100,7 +100,7 @@ Feature: Registration is only open while no verified account exists, and it send
When CreateUser is called with username "carol", password "hunter2example12",
email "carol@example.com"
Then the call succeeds and returns a user with IsVerified false
And exactly one email was sent to "carol@example.com" using template "mailValidation"
And exactly one email was sent to "carol@example.com" using template "verification"
Scenario: The create-user use case rolls back the user when the mail repository fails
Given an empty user.Repository fake
+99
View File
@@ -0,0 +1,99 @@
Feature: Removing an image from an article
As an authenticated RenewCMS user managing the CMS
I want to delete an already-uploaded image by its id
So that a removed image's file and row are actually gone, and a delete on
an unknown article or an unknown image answers 404 the same way every
other article write does
Background:
Given internal/infrastructure/useCases.DeleteImageUseCase.DeleteImage(imageId)
already exists and calls image.Repository.Delete(imageId), which returns
image.ErrImageNotFound for an unknown id and, on the real ImageRepository,
also removes the uploaded file from UPLOAD_DIR, per
features/delete_article_cascades_images.feature
And this slice changes POST /v1/article/{id}/image's success response
(api/handlers/image.Handler.PostImage, features/upload_article_image.feature)
to also return the created image's numeric id, so the 201 body becomes
{"id": ..., "location": ...}; this lets a client address the image later
through this slice's DELETE route
And this slice mounts DELETE /v1/article/{articleId}/image/{imageId} in
api/router.go, in the same jwtauth Verifier + Authenticator group as
POST .../image
And this slice adds a DeleteImage method to api/handlers/image.Handler,
grows NewHandler's signature to also take DeleteImageUseCase, and wires
it in api/router.go
And DeleteImage parses articleId and imageId from the URL, answering 400
with apperr.IdUint32ErrorMessage if either isn't a valid uint32
And DeleteImage first calls GetArticleUseCase.GetArticle(articleId);
errors.Is(err, article.ErrArticleNotFound) answers 404 with
apperr.ArticleNotFoundErrorMessage; this only confirms the article id
in the URL names a real article, and does not verify that the image id
belongs to that article's Images slice (a shallow existence check on
both ids, not an ownership check)
And DeleteImage then calls DeleteImageUseCase.DeleteImage(imageId);
errors.Is(err, image.ErrImageNotFound) answers 404 with the new
apperr.ImageNotFoundErrorMessage = "Image not found"; any other error
falls back to the existing generic 500
And on success DeleteImage answers 204 with an empty body, the same shape
as DELETE /v1/article/{id}
Scenario: Removing an image from an article it belongs to succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the response body is empty
And the image repository no longer contains an image with id 10
Scenario: Removing an image from an unknown article answers 404 and never touches the image repository
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/9999/image/10 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Delete is never called
And the image repository still contains the image with id 10
Scenario: Removing an unknown image from an existing article answers 404
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
And the image repository has no image with id 9999
When the client sends DELETE /v1/article/1/image/9999 with that cookie
Then the response status is 404
And the response body is the text "Image not found"
Scenario: Removing an image id that belongs to a different article still succeeds, because the check is shallow
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice is empty
And the article repository contains a second article with id 2 whose
Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the image repository no longer contains an image with id 10
Scenario: Removing an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 without a cookie
Then the response status is 401
And the image repository still contains the image with id 10
Scenario Outline: A malformed id in the URL answers 400 before either repository is touched
Given a valid jwt cookie for a user with id 1
When the client sends DELETE /v1/article/<article_id>/image/<image_id> with that cookie
Then the response status is 400
And the response body is the text "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
Examples:
| article_id | image_id |
| abc | 10 |
| 1 | abc |
+56
View File
@@ -0,0 +1,56 @@
# QA procedure: remove_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockImageRepo` as-is.
3. Wire `useCases.NewDeleteImageUseCase(imageRepo)` (already used by
`delete_article_cascades_images.qa.md`'s suite) alongside the existing
use cases.
4. Build `image.NewHandler(...)` with its grown constructor including the
delete use case, matching whatever parameter order the coder settles on.
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount `DELETE /{id}/image/{imageId}` alongside the existing article
routes under the authenticated group, mirroring `api/router.go` after
this slice.
7. Mint a valid jwt cookie for user id 1 as in `upload_article_image.qa.md`.
## HTTP checks
8. Seed `articleRepo.articles[1]` with `Images: []*image.Image{{ID: 10}}`
and `imageRepo.images[10] = image.Image{ID: 10}`. Send
`DELETE /v1/article/1/image/10` with the valid cookie. Assert status
204, empty body; assert `imageRepo.images` no longer has key 10.
9. Empty `articleRepo`; seed `imageRepo.images[10]`. Send
`DELETE /v1/article/9999/image/10`. Assert 404, body
`"Article not found\n"`; assert `imageRepo.images` still has key 10
(Delete never called — track via a call-count field on the fake, the
same way the existing fakes already track `Create`).
10. Seed `articleRepo.articles[1]`; empty `imageRepo`. Send
`DELETE /v1/article/1/image/9999`. Assert 404, body
`"Image not found\n"`.
11. Seed `articleRepo.articles[1]` (empty Images) and `articleRepo.articles[2]`
with `Images: []*image.Image{{ID: 10}}`; seed `imageRepo.images[10]`.
Send `DELETE /v1/article/1/image/10`. Assert 204; assert
`imageRepo.images` no longer has key 10 — asserts the shallow-check
contract from the feature's fourth scenario.
12. Seed as in step 8. Send `DELETE /v1/article/1/image/10` with **no**
cookie. Assert 401; assert `imageRepo.images` still has key 10.
13. Examples-driven: send `DELETE /v1/article/abc/image/10` and
`DELETE /v1/article/1/image/abc` with the valid cookie. Assert 400,
body `"The server expects the ID to be in the format of an unsigned
32-bit integer (uint32).\n"` for each.
## Also touched by this slice: upload_article_image's 201 body
14. Extend `upload_article_image.qa.md`'s existing 201-body assertion to
also decode and check the `id` field equals the newly created image's
`ID` in `imageRepo.images`.
## Manual verification (not exercised by the Go test)
- Real DELETE against a real SQLite file and real UPLOAD_DIR: confirm the
file is actually removed from disk and the `article_images` join row is
gone, the same way `upload_article_image.qa.md`'s manual item covers the
POST side.
- SPA-visible behaviour: covered separately by
`features/editor_image_removal.qa.md`.
- No SMTP involvement.
+5 -5
View File
@@ -39,8 +39,8 @@ Feature: Anyone can request a password reset, and the response never reveals whe
most recently requested code and expiration are ever valid; a user who requests a reset twice
can only use the second code, and the first one no longer matches once overwritten
And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's
email, template "passwordReset" (the already-existing
internal/infrastructure/mailer/templates/passwordReset.html, which renders a link from
email, template "password-reset" (the already-existing
internal/infrastructure/mailer/templates/password-reset.html, which renders a link from
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a
MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is
the raw (unhashed) code, and whose Email is the user's email
@@ -69,7 +69,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
Then the response status is 200
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
And exactly one email was sent to "alice@example.com" using template "passwordReset"
And exactly one email was sent to "alice@example.com" using template "password-reset"
And that email's data has VerificationCode equal to the raw code stored in the repository's
hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com"
@@ -116,7 +116,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"}
Then the response status is 200
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
And exactly one email was sent to "bob@example.com" using template "passwordReset"
And exactly one email was sent to "bob@example.com" using template "password-reset"
Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern
When the client sends POST /v1/auth/password-reset with body <body>
@@ -136,7 +136,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
Then the call returns nil
And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3,
a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour)
And exactly one email was sent to "alice@example.com" using template "passwordReset" whose data
And exactly one email was sent to "alice@example.com" using template "password-reset" whose data
carries the same raw code that was hashed, and Host "https://cms.example.com"
Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer
+132
View File
@@ -0,0 +1,132 @@
Feature: Password reset confirm screen
As a user who opened the reset link from their email
I want /register/reset/validate?c=CODE&email=EMAIL to let me choose a new password via POST
/v1/auth/password-reset/confirm, checking length and match in the browser first, and to tell
me plainly when the link is incomplete, the code is wrong or expired, or the server failed
So that finishing a password reset is a single form from the email, with the same neutral
"invalid or expired" message whether the code was wrong, reused, or the account doesn't exist
Background:
Given /register/reset/validate is a public route (meta.auth "public") rendered as
ResetConfirmPage inside AuthShell, per docs/design/routes.md
And ResetConfirmPage only calls POST /v1/auth/password-reset/confirm
And on mount, ResetConfirmPage reads the "c" and "email" query parameters once: if either is
absent or an empty string, no request is ever made and the Missing parameters state renders
immediately instead of the form
And when both are present, the form has a read-only Email field (value from the "email" query
parameter, help text "From your reset link."), a New password field (password input with
show/hide, a live checklist item "At least 8 characters" that starts unmet/gray, turns
error/red only after a failed submit, and turns met/green once satisfied), and a Confirm new
password field (password input with show/hide, a live checklist item "Passwords match"
with the same unmet/error/met states)
And New password's length is checked on submit only: fewer than 8 characters leaves its
checklist item in its error state and blocks the request
And Confirm new password's match against New password is checked on blur of that field and
again on submit: a mismatch shows the error "The passwords don't match." and leaves the
"Passwords match" checklist item in its error state, and blocks the request
And a submit that passes both checks calls POST /v1/auth/password-reset/confirm with
{ email, code: c, password: <New password> }; the Confirm new password value is never sent
And on a 200 response, the app navigates to "/login?reason=password-changed", whose
rendering is covered by features/login_screen.feature's "Password changed" scenario, not
re-specified here
And a 403 response (per features/reset_password.feature, covering an unknown email, a wrong
code, an already-used code, or an expired code alike) replaces the whole form with a
ResultPanel
And a 500 response, or a request that never reaches the server, is surfaced with the app's
generic network/server-error alert — icon "i-lucide-circle-x", title "Couldn't reach
RenewCMS", description "Check your connection and try again.", the same copy LoginPage and
RegisterPage already use for the same failure; the form is not replaced, both password
fields keep whatever the user typed, and the form re-enables, since
docs/design/screens/reset-password.md has no state of its own for this response and this
is the app's generic pattern for it (per the user's decision on this slice)
Scenario: Default — both parameters present, an untouched form
When the user navigates to "/register/reset/validate?c=abc123&email=marta%40renew.example"
Then the heading is "Choose a new password"
And the description is "Pick something you haven't used here before. You'll log in with it
next."
And the Email field shows "marta@renew.example", read-only, with help "From your reset link."
And the New password field shows no value, with checklist item "At least 8 characters" in its
unmet state
And the Confirm new password field shows no value, with checklist item "Passwords match" in
its unmet state
And the submit button reads "Change password"
Scenario: Too short — fewer than 8 characters blocks submission
Given the user is on the reset-confirm form with New password "lime" and Confirm new
password "lime"
When the user submits
Then POST /v1/auth/password-reset/confirm is never called
And the New password checklist item "At least 8 characters" shows its error state
And the Confirm new password checklist item "Passwords match" shows its met state, since both
fields hold the same value
Scenario: Mismatch — caught on blur and on submit
Given the user is on the reset-confirm form with New password "limewash2026"
When the user types "limewash2025" into Confirm new password and blurs the field
Then the Confirm new password field shows the error "The passwords don't match." and its
checklist item "Passwords match" shows its error state
When the user submits with these same values
Then POST /v1/auth/password-reset/confirm is never called
And the same mismatch error is still shown
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on the reset-confirm form with New password "limewash2026" and Confirm new
password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm has not yet resolved
Then the New password and Confirm new password fields are disabled
And the submit button reads "Changing password…" and shows a loading state
Scenario: Success — a 200 hands off to the log-in screen
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm resolves 200
Then the app navigates to "/login?reason=password-changed"
Scenario: Invalid or expired — a 403 replaces the form with a ResultPanel
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm resolves 403 with the
apperr.PasswordResetFailedErrorMessage body
Then the form is no longer shown
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This reset
link has expired", text "Reset links work once, for 1 hour. Request a new one and use it
within the hour."
And its actions are a primary "Request a new link" and a secondary "Back to log in"
When the user activates "Request a new link"
Then the app navigates to /register/reset/request
When instead the user activates "Back to log in"
Then the app navigates to /login
Scenario Outline: Server or network error — the form keeps its values and re-enables
Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with
New password "limewash2026" and Confirm new password "limewash2026"
When the user submits and POST /v1/auth/password-reset/confirm <failure>
Then an error alert reads "Couldn't reach RenewCMS" with description "Check your connection
and try again."
And the form is still shown, not replaced by a ResultPanel
And the New password field still shows "limewash2026" and the Confirm new password field
still shows "limewash2026", both re-enabled
And the submit button reads "Change password" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario Outline: Missing parameters — either missing parameter blocks the request
When the user navigates to <path>
Then no request is made
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link
is incomplete", text "Part of the reset link is missing. Open it from your email again, or
request a new one."
And its single action is a primary "Request a new link"
When the user activates "Request a new link"
Then the app navigates to /register/reset/request
Examples:
| path |
| "/register/reset/validate?email=marta%40renew.example" |
| "/register/reset/validate?c=abc123" |
| "/register/reset/validate" |
+83
View File
@@ -0,0 +1,83 @@
# QA: Password reset confirm screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset/confirm`) per case; no real backend, no httptest
server. Mount ResetConfirmPage inside AuthShell with a test router whose current route starts at
the query string each case needs.
1. **Default state** — mount at
"/register/reset/validate?c=abc123&email=marta%40renew.example"; assert heading "Choose a new
password", description, Email "marta@renew.example" read-only with help "From your reset
link.", New password empty with checklist "At least 8 characters" unmet, Confirm new password
empty with checklist "Passwords match" unmet, submit "Change password".
2. **Too short state** — from state 1, set New password and Confirm new password both to "lime";
submit; assert `POST /v1/auth/password-reset/confirm` was never called; assert "At least 8
characters" shows its error state and "Passwords match" shows its met state.
3. **Mismatch state** — from state 1, set New password to "limewash2026", type "limewash2025"
into Confirm new password and blur; assert the error "The passwords don't match." and the
"Passwords match" checklist error state; submit; assert the call was never made and the same
error is still shown.
4. **Submitting state** — from state 1, set both password fields to "limewash2026"; mock the call
with an unresolved promise; submit; assert both fields are disabled and the submit button
reads "Changing password…" with a loading indicator.
5. **Success state** — from state 1, set both password fields to "limewash2026"; mock the call
resolving 200; submit; assert the router's current path is "/login?reason=password-changed"
(the rendering of that page is covered by `login_screen.qa.md`, not re-asserted here beyond
the path/query).
6. **Invalid or expired state** — from state 1, set both password fields to "limewash2026"; mock
the call resolving 403 with the `apperr.PasswordResetFailedErrorMessage` body; submit; assert
the form is no longer rendered; assert a ResultPanel (error, link-2-off, "This reset link has
expired", the text above) with actions "Request a new link" (primary) and "Back to log in"
(secondary); click each in separate sub-cases and assert the router's current path is
/register/reset/request and /login respectively.
7. **Server or network error state** — two sub-cases (500 response, rejected fetch) from state 1,
with both password fields set to "limewash2026"; submit; assert an error alert with icon
"i-lucide-circle-x", title "Couldn't reach RenewCMS", description "Check your connection and
try again." (the same copy `login_screen.qa.md` and `register_screen.qa.md` already assert
for their own server/network cases); assert the form is still rendered (no ResultPanel);
assert both password fields still read "limewash2026" and are re-enabled; assert the submit
button reads "Change password" and is no longer loading.
8. **Missing parameters state** — three sub-cases, one per Examples row (missing "c", missing
"email", missing both); mount at the row's path; assert no request was made; assert a
ResultPanel (error, link-2-off, "This link is incomplete", the text above) with a single
primary action "Request a new link"; click it and assert the router's current path is
/register/reset/request.
| path |
|----------------------------------------------------------|
| /register/reset/validate?email=marta%40renew.example |
| /register/reset/validate?c=abc123 |
| /register/reset/validate |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Reset password". Run
`task visual:check -- reset-password` for each state.
- [ ] Default state: read-only Email field styling, both checklist items in their unmet/gray
state, matches the board exactly.
- [ ] Too short state: New password's checklist item in its error/red state while Confirm new
password's stays met/green, since both fields match.
- [ ] Mismatch state: inline field error under Confirm new password plus its checklist error
state.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Invalid or expired state: ResultPanel with both actions, primary first (components.md's
safe/primary-on-top rule for stacked mobile buttons).
- [ ] Server or network error state: error alert (circle-x icon, no close button) above the
heading, matching Login's/Register's own server-error alert styling exactly, since this
screen has no board state of its own for it; fields retain their values. This state is not
covered by `task visual:check`'s per-state board comparison (no board frame exists for it);
note that gap in the run's report rather than skipping it silently.
- [ ] Missing parameters state: ResultPanel with a single primary action.
- [ ] Keyboard/focus: Tab order is New password → show/hide → Confirm new password → show/hide →
submit; the ResultPanel's title receives focus on both the Invalid-or-expired and
Missing-parameters states; the mismatch error and the server/network alert are announced
without stealing focus from the field or the heading, respectively.
## Manual verification
- **Real POST /v1/auth/password-reset/confirm against a running server** — Vitest only mocks
`useApi`; confirm the real 200/403 round-trip (including the actual bcrypt code check, 1-hour
expiry, and single-use clearing) against `task dev` with a live SQLite file. The endpoint's own
contract is already covered by `features/reset_password.qa.md`; this slice only re-verifies the
screen consumes it correctly.
+85
View File
@@ -0,0 +1,85 @@
Feature: Password reset request screen
As a RenewCMS user who forgot their password
I want a /register/reset/request screen that emails a reset link via POST
/v1/auth/password-reset, validates the email client-side before sending, and never reveals
whether the address has an account
So that I can ask for a reset link with the same neutral confirmation regardless of whether my
account exists, and see plainly when I'm asking too often or the server failed
Background:
Given /register/reset/request is a public route (meta.auth "public") rendered as
ResetRequestPage inside AuthShell, per docs/design/routes.md
And ResetRequestPage only calls POST /v1/auth/password-reset
And the form has a single Email field (placeholder "you@example.com")
And client-side validation runs on submit only, before any request is sent: Email must be a
well-formed email address (the same pattern RegisterPage already uses), and a failing Email
shows "Enter a full email address, like name@example.com."
And a valid submit calls POST /v1/auth/password-reset with { email }
And every response the API gives — whether the email belongs to an account or not — is the
same 200 with the same body, per features/request_password_reset.feature, so the screen
shows the same Sent state either way
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed
as a live countdown on the submit button, the same mechanism LoginPage's own 429 handling
uses, and the Email field is disabled for the same duration
And a 500 response, or a request that never reaches the server, is surfaced as a
server/network error; the field keeps whatever the user typed and the form re-enables
And a 400 response is not expected once client-side validation passes, since the client
checks the same required,email rule the API enforces; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register/reset/request
Then the heading is "Reset your password"
And the description is "Enter the email for your account and we'll send you a link to choose
a new password."
And the Email field shows placeholder "you@example.com" with no value
And the submit button reads "Send reset link"
And the footer reads "Back to log in" linking to /login
Scenario: Validation error — an invalid email blocks the request
Given the user is on /register/reset/request
When the user types "marta@renew" into Email and submits
Then POST /v1/auth/password-reset is never called
And the Email field shows the error "Enter a full email address, like name@example.com."
Scenario: Submitting — the field and button are disabled while the request is in flight
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset has not yet resolved
Then the Email field is disabled
And the submit button reads "Sending…" and shows a loading state
Scenario: Sent — any 200 shows the same neutral confirmation
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 200
Then the form is replaced by a ResultPanel with tone "success", icon "i-lucide-mail-check",
title "Check your email", text "If an account exists for that email, a password reset link
has been sent.", and note "The link expires in 1 hour."
And its single action is a secondary "Back to log in"
When the user activates "Back to log in"
Then the app navigates to /login
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 429 with a Retry-After of 58
seconds
Then an info alert reads "Too many requests" with description "Wait a moment before asking
for another link."
And the Email field still shows "marta@renew.example"
And the submit button reads "Try again in 0:58" and is disabled
And the Email field is disabled while the countdown runs
When 58 seconds elapse
Then the submit button re-enables and reads "Send reset link"
And the Email field is editable again
Scenario Outline: Server or network error — the field keeps its value and re-enables
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset <failure>
Then an error alert reads "Couldn't send the link" with description "Check your connection
and try again."
And the Email field still shows "marta@renew.example" and is re-enabled
And the submit button reads "Send reset link" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
+59
View File
@@ -0,0 +1,59 @@
# QA: Password reset request screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset`) per case; no real backend, no httptest server.
Mount ResetRequestPage inside AuthShell with a test router.
1. **Default state** — mount at /register/reset/request; assert heading "Reset your password",
description, Email placeholder "you@example.com" with no value, submit "Send reset link",
footer "Back to log in" linking to /login.
2. **Validation error state** — set Email to "marta@renew", submit; assert the mocked
`POST /v1/auth/password-reset` was never called; assert the error "Enter a full email
address, like name@example.com."
3. **Submitting state** — set Email "marta@renew.example"; mock the call with an unresolved
promise; submit; assert the Email field is disabled and the submit button reads "Sending…"
with a loading indicator.
4. **Sent state** — set Email "marta@renew.example"; mock the call resolving 200; submit; assert
the form is replaced by a ResultPanel (success, mail-check, "Check your email", the neutral
text, note "The link expires in 1 hour.") with a single secondary action "Back to log in";
click it and assert the router's current path is /login.
5. **Too many attempts state** — set Email "marta@renew.example"; mock the call resolving 429
with `Retry-After: 58`; submit; assert an info alert ("Too many requests", "Wait a moment
before asking for another link."); assert Email still reads "marta@renew.example"; assert the
submit button reads "Try again in 0:58" and is disabled; assert the Email field is disabled;
advance fake timers 58s; assert the button re-enables and reads "Send reset link" and the
field is editable again.
6. **Server or network error state** — two sub-cases (500 response, rejected fetch) with Email
"marta@renew.example"; submit; assert an error alert ("Couldn't send the link", "Check your
connection and try again."); assert Email still reads "marta@renew.example" and is re-enabled;
assert the submit button reads "Send reset link" and is no longer loading.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Forgot password". Run
`task visual:check -- forgot-password` for each state.
- [ ] Default state: heading, description, Email field, "Send reset link" button, "Back to log
in" footer link, matches the board exactly.
- [ ] Validation error state: the inline field error renders with the same styling as
Register's/Login's own field errors.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Sent state: ResultPanel (mail-check icon) with its single secondary action, note text below
the description.
- [ ] Too many attempts state: info alert (timer icon), disabled field and disabled button with
the live "Try again in m:ss" countdown text.
- [ ] Server error state: error alert (circle-x icon, no close button), field retains its value.
- [ ] Keyboard/focus: Tab order is Email → submit; the alert (when present) is announced via
`role="status"` (info) or `role="alert"` (error) without re-announcing on re-render; the
ResultPanel's title receives focus on the Sent state.
## Manual verification
- **Real POST /v1/auth/password-reset against a running server** — Vitest only mocks `useApi`;
confirm the real 200/429/500 round-trip (including the actual rate limiter and Retry-After
header) against `task dev` with a live SQLite file. The endpoint's own contract is already
covered by `features/request_password_reset.qa.md`; this slice only re-verifies the screen
consumes it correctly.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift against a
real elapsed 58-second wait, since Vitest fake timers only prove the logic.
+10 -9
View File
@@ -15,13 +15,13 @@ Feature: Runtime settings are read once at startup
CORS_ALLOWED_ORIGINS on every router build,
api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every
Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send
reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call,
and internal/infrastructure/persistence.ImageRepository.Create and
.Delete read UPLOAD_DIR on every call
reads SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT
on every call, and internal/infrastructure/persistence.ImageRepository.Create
and .Delete read UPLOAD_DIR on every call
And this slice introduces internal/infrastructure/config.Settings, a
plain struct with fields Environment, CorsAllowedOrigins ([]string),
PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct
(Email, Password, Host, Port)
(Login, Sender, Password, Host, Port)
And cmd/server/main.go's initEnvVariables still loads the .env file and
applies the same defaults and required-var checks as before, then
builds exactly one config.Settings value from the process environment,
@@ -34,9 +34,9 @@ Feature: Runtime settings are read once at startup
the upload dir, and the router's logging and CORS setup receive the
environment string and the CORS origins list as parameters
And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT,
CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT
and UPLOAD_DIR appears only inside cmd/server/main.go and
internal/infrastructure/config
CORS_ALLOWED_ORIGINS, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD,
SMTP_HOST, SMTP_PORT and UPLOAD_DIR appears only inside
cmd/server/main.go and internal/infrastructure/config
And a component built from a config.Settings value keeps behaving
according to the values it was built with even if the process
environment changes afterwards, because it holds the captured values,
@@ -109,8 +109,9 @@ Feature: Runtime settings are read once at startup
Secure attribute set to true
Scenario: The mailer is constructed from injected SMTP settings, not the environment
Given a config.Settings value with Smtp.Email "notify@example.com",
Smtp.Host "smtp.example.com" and Smtp.Port 587
Given a config.Settings value with Smtp.Login "smtp-login@example.com",
Smtp.Sender "notify@example.com", Smtp.Host "smtp.example.com" and
Smtp.Port 587
And internal/infrastructure/mailer.NewMailRepository is called with that
Smtp settings value and no SMTP_* environment variable set
Then constructing the MailRepository does not read any SMTP_*
+6 -4
View File
@@ -83,9 +83,10 @@ Package: `test` (black-box).
constructor signature changes.
## Mailer
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email:
"notify@example.com", Host: "smtp.example.com", Port: 587, Password:
"unused-in-test"})` with no `SMTP_*` environment variable set (do not
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Login:
"smtp-login@example.com", Sender: "notify@example.com", Host:
"smtp.example.com", Port: 587, Password: "unused-in-test"})` with no
`SMTP_*` environment variable set (do not
call `t.Setenv` for any `SMTP_*` name in this test). Assert construction
does not panic and does not error — this is the only automated
assertion available, since exercising `Send` requires a real SMTP dialog
@@ -95,7 +96,8 @@ Package: `test` (black-box).
## Manual verification (not exercised by the Go test)
- Real SMTP send: run `task dev` (or a built binary) with a real `.env`
populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a
populated with SMTP_LOGIN/SMTP_SENDER/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT,
trigger a
flow that calls `SendMailUseCase.SendMail`, and confirm the message
actually leaves via the injected host/port/credentials, and that changing
those env vars requires a process restart to take effect (there is no
@@ -0,0 +1,114 @@
Feature: The two transactional email templates are replaced by the design's ready-to-use files
As the maintainer of RenewCMS
I want the verification and password-reset emails to use the exact Go html/template files from
docs/design/source/emails/, renamed to match the design's naming, instead of the old ad hoc
mailValidation.html and passwordReset.html files
So that account-verification and password-reset emails match the visual spec (bulletproof button,
preheader, dark-mode overrides), and the template name used at the call site reflects the file it
now loads
Background:
Given internal/infrastructure/mailer/templates/mailValidation.html is removed and replaced by
internal/infrastructure/mailer/templates/verification.html, copied verbatim from
docs/design/source/emails/verification.html, still driven by a Go struct with a Host field and
a VerificationCode field (the existing MailValidationData shape in CreateUser.go is unchanged)
And internal/infrastructure/mailer/templates/passwordReset.html is removed and replaced by
internal/infrastructure/mailer/templates/password-reset.html, copied verbatim from
docs/design/source/emails/password-reset.html, still driven by a Go struct with a Host field, a
VerificationCode field and an Email field (the existing MailPasswordResetData shape in
RequestPasswordReset.go is unchanged)
And MailRepository.Send (internal/infrastructure/mailer/mailRepository.go) is unchanged code: it
already loads "templates/" + templateName + ".html" via template.ParseFS, so it now resolves
the templateName "verification" to the new verification.html file and "password-reset" to the
new password-reset.html file, purely because of the rename, with no change to Send's own logic
And CreateUserUseCase.CreateUser (internal/infrastructure/useCases/CreateUser.go) now calls
mail.Repository.Send with template "verification" instead of the old "mailValidation"; nothing
else about that call changes: the receiver address, and the Host/VerificationCode data, are as
before
And RequestPasswordResetUseCase.RequestPasswordReset
(internal/infrastructure/useCases/RequestPasswordReset.go) now calls mail.Repository.Send with
template "password-reset" instead of the old "passwordReset"; nothing else about that call
changes: the receiver address, and the Host/VerificationCode/Email data, are as before
And the verification email's link is still built as
"{{.Host}}/register/validate?c={{.VerificationCode}}" and the password-reset email's link is
still built as
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}", matching both the
old templates and the use cases that populate them, so no handler, use case or route changes
as a result of this slice
And MailRepository (internal/infrastructure/mailer/mailRepository.go) gains an unexported render
step, e.g. render(templateName string, data any) (subject string, body string, err error),
that Send calls before dialing; render selects the subject by templateName ("Verify your
RenewCMS account" for "verification", "Reset your RenewCMS password" for "password-reset",
matching docs/design/emails.md's suggested subjects exactly) and parses/executes the
corresponding templates/<templateName>.html file, returning its rendered body; Send then uses
that subject and body to build and dial the message exactly as before, and any ParseFS or
Execute error from render is returned by Send unchanged, with the same log.Println call sites
as today
And an unrecognized templateName still reaches template.ParseFS, which fails to find
"templates/<templateName>.html" and returns its usual error; render does not special-case
unknown names beyond the two known subjects, so an unknown templateName's subject falls back to
the same literal "RenewCMS | Action required" used before this slice, and Send still returns
the ParseFS error without attempting to dial
And the two feature files that already assert a template-name string literal,
registration_gate_and_verification_email.feature and request_password_reset.feature, are
updated in this same slice so every occurrence of "mailValidation" reads "verification" and
every occurrence of "passwordReset" reads "password-reset"; their behaviour is otherwise
unchanged
Scenario: Registering a user sends the verification email under its new template name
Given the user repository is empty
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
email "alice@example.com"
Then the response status is 200
And exactly one email was sent to "alice@example.com" using template "verification"
And that email's data has Host and VerificationCode populated exactly as before this slice
Scenario: Requesting a password reset sends the reset email under its new template name
Given the user repository contains a user "alice" with email "alice@example.com"
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
Then the response status is 200
And exactly one email was sent to "alice@example.com" using template "password-reset"
And that email's data has Host, VerificationCode and Email populated exactly as before this slice
Scenario: render() produces the verification email's subject, link and copy
Given a real *mailer.MailRepository
When its render step is called with templateName "verification" and data whose Host is
"https://cms.example.com" and whose VerificationCode is "abc123"
Then the returned subject is "Verify your RenewCMS account"
And the returned body contains the link "https://cms.example.com/register/validate?c=abc123"
And the returned body contains the button text "Verify my account"
And the returned body contains the text "This link expires in 2 hours."
Scenario: render() produces the password-reset email's subject, link and copy
Given a real *mailer.MailRepository
When its render step is called with templateName "password-reset" and data whose Host is
"https://cms.example.com", whose VerificationCode is "abc123" and whose Email is
"alice@example.com"
Then the returned subject is "Reset your RenewCMS password"
And the returned body contains the link
"https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com"
And the returned body contains the button text "Choose a new password"
And the returned body contains the text "This link expires in 1 hour and works once."
Scenario: render() falls back to the old default subject for an unrecognized template name
Given a real *mailer.MailRepository
When its render step is called with templateName "does-not-exist" and any data
Then the returned subject is "RenewCMS | Action required"
And an error is returned because templates/does-not-exist.html cannot be found
And the returned body is empty
Scenario: A template execution failure is returned without sending
Given a real *mailer.MailRepository
And data that causes Execute to fail for a known templateName (e.g. a type without the fields
the template references)
When its render step is called with that templateName and that data
Then render returns an error and MailRepository.Send never reaches DialAndSend
Scenario Outline: The old template files no longer exist under their previous names
Then the file internal/infrastructure/mailer/templates/<old file> does not exist on disk
Examples:
| old file |
| mailValidation.html |
| passwordReset.html |
@@ -0,0 +1,71 @@
# QA procedure: transactional email templates
## Automated (Go, `test/`)
This slice touches a use-case call site (a string literal) and two static template files; it has
no new HTTP surface of its own, so no new handler/router wiring is exercised here. Reuse the
existing fakes already in `test/` (`mockMailRepo` from `test/mail_test.go`, the fake `user.Repository`
used by the registration and password-reset tests) rather than building new ones.
1. **Registration sends "verification".**
In the existing registration test file (or its replacement after this slice), build
`CreateUserUseCase` over an empty fake `user.Repository`, a real hasher, and `mockMailRepo`, call
`CreateUser` with username `alice`, password `hunter2example12`, email `alice@example.com`, assert
the call succeeds, then assert `mockMailRepo.SentEmails` has exactly one entry whose `Template`
field equals `"verification"` (not `"mailValidation"`).
2. **Password reset sends "password-reset".**
Build `RequestPasswordResetUseCase` over a fake `user.Repository` seeded with a user whose email is
`alice@example.com`, a real hasher, `mockMailRepo` and a public URL, call `RequestPasswordReset("alice@example.com")`,
assert it returns `nil`, then assert `mockMailRepo.SentEmails` has exactly one entry whose
`Template` field equals `"password-reset"` (not `"passwordReset"`).
3. **render() produces the right subject, link and copy for each known template.**
Construct a real `mailer.NewMailRepository(config.SmtpSettings{})` (SMTP fields are irrelevant
since `DialAndSend` is never called here) and call its render step directly (test lives in
package `test`, so this exercises the exported behavior surface of a real `*mailer.MailRepository`
without opening a network connection):
- `render("verification", <data with Host "https://cms.example.com", VerificationCode "abc123">)`
→ subject `"Verify your RenewCMS account"`, body contains
`https://cms.example.com/register/validate?c=abc123`, `Verify my account`,
`This link expires in 2 hours.`
- `render("password-reset", <data with Host "https://cms.example.com", VerificationCode "abc123",
Email "alice@example.com">)` → subject `"Reset your RenewCMS password"`, body contains
`https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com`,
`Choose a new password`, `This link expires in 1 hour and works once.`
(`html/template` auto-escapes `Email` in the query string, turning `@` into `%40`; assert the
escaped form, matching what `MailRepository.Send` will actually produce.)
3b. **render() fallback and failure paths.**
- `render("does-not-exist", nil)` returns an error (template file not found) and the returned
subject is still the old default `"RenewCMS | Action required"`, with an empty body.
- A `render(<known templateName>, <data that fails Execute>)` call returns an error; assert
`MailRepository.Send` with the same inputs returns an error too and never gets to
`DialAndSend` (no network dependency needed to prove this: a failing `render` return is
sufficient to prove `Send` short-circuits before dialing, since `Send`'s existing structure
calls render, then returns immediately on its error).
4. **Old template files are gone.**
`os.Stat("internal/infrastructure/mailer/templates/mailValidation.html")` and the `passwordReset.html`
equivalent each return an error satisfying `os.IsNotExist`.
5. **Existing slice tests still pass with updated literals.**
Run the full suite (`task test`) and confirm the tests backing
`registration_gate_and_verification_email.feature` and `request_password_reset.feature` still pass
now that their scenario text and any Go assertions inside their test files use `"verification"` and
`"password-reset"` instead of the old strings. If those test files still contain the old literals
as Go string constants (not just prose in the `.feature` files), they need the same rename; point
this out to `coder` rather than silently doing a code edit here.
## Manual verification (not covered by the automated Go tests above)
- **Rendered appearance in an actual mail client.** The bulletproof button (table-cell background +
padded link), the hidden preheader text, and the `prefers-color-scheme: dark` overrides can only be
judged by opening the rendered HTML in a real or preview mail client (or the board file
`docs/design/source/RenewCMS Emails.dc.html` next to a manually triggered send through a real SMTP
server) — this is not something `os.ReadFile` + `html/template.Execute` can verify. Confirm both
light and dark rendering for both templates.
- **Real SMTP delivery end to end.** Sending an actual email via `SMTP_HOST`/`SMTP_PORT` and eyeballing
the arrived message (subject line, sender name "RenewCMS <...>", clickable button, monospace
fallback URL) is a manual item; nothing here exercises `gomail.Dialer.DialAndSend`.
+8 -3
View File
@@ -37,9 +37,12 @@ Feature: Uploading an image to an article
back to a generic 500
And PostImage reads the uploaded file from the multipart form field
"file"
And on success PostImage answers 201 with a JSON body {"location": ...}
holding the created image's public path, e.g.
"/static/uploadedImages/<stored-filename>"
And on success PostImage answers 201 with a JSON body
{"id": ..., "location": ...} holding the created image's numeric id
and public path, e.g. "/static/uploadedImages/<stored-filename>"
And this slice (features/remove_article_image.feature) adds the "id"
field to that body; the id lets a client later address the image
through DELETE /v1/article/{id}/image/{imageId}
Scenario: Uploading an allowed image type to an existing article succeeds
Given a valid jwt cookie for a user with id 1
@@ -51,6 +54,8 @@ Feature: Uploading an image to an article
Then the response status is 201
And the response body is JSON with a "location" field whose value is
"/static/uploadedImages/before.png"
And the response body's "id" field equals the new image's id in the
image repository
And the image repository contains a new image whose Path is
"/static/uploadedImages/before.png"
And the article repository's article with id 1 has that new image's id
+242
View File
@@ -0,0 +1,242 @@
Feature: A verified user can set an optional display name that replaces their username for display only
As one of several admins with equal access to a RenewCMS workspace
I want to give my account an optional display name, up to 60 characters, and have the API
return it wherever my own account is returned
So that people are named by a human name instead of a username, while logging in still uses
the username and no secret field ever reaches a response
Background:
Given internal/domain/user gains the field DisplayName, json "display_name", with no gorm tag,
and the sentinel user.ErrInvalidDisplayName
And user.Repository gains UpdateDisplayName(userId uint32, displayName string) (User, error),
returning user.ErrUserNotFound when the user does not exist
And the persistence model stores the display name in a column whose default is the empty
string, so rows created before this slice still load with DisplayName ""
And a new use case, UpdateUserProfile, exposes UpdateDisplayName(userId uint32, displayName
string) (user.User, error) and depends only on user.Repository
And UpdateUserProfile trims the value with strings.TrimSpace, then counts what remains with
utf8.RuneCountInString (runes, not bytes), returns user.ErrInvalidDisplayName without calling
the repository when the count is above 60, and otherwise hands the trimmed value to
UpdateDisplayName; an empty or whitespace-only value therefore stores "" and clears the name
And api/pkg/apperr defines DisplayNameErrorMessage as "Display name must be 60 characters or
fewer."
And PATCH /v1/user/me is served by a new handler in api/handlers/user/, mounted by a new
/v1/user router in api/router.go inside a group carrying jwtauth.Verifier,
jwtauth.Authenticator, middleware.RequireCurrentToken and middleware.RequireVerifiedUser, in
that order, so an unauthenticated caller is refused with 401, a stale token with 401
apperr.UnauthorizedErrorMessage, and an unverified caller with 403
apperr.VerificationRequiredErrorMessage before the handler runs
And the request body is JSON {"display_name": <string>}; the request struct field is a
*string tagged validate:"required" and is checked with validator.Validate.Struct; a body that
is not valid JSON, a display_name that is not a string, or a missing or null display_name
answers 400
And the handler maps user.ErrInvalidDisplayName to 400 with body
apperr.DisplayNameErrorMessage, user.ErrUserNotFound to 401 apperr.UnauthorizedErrorMessage,
and any other error to 500 "Internal server error", all as plain text from http.Error
And the public user struct and its converter move out of api/handlers/auth into a shared
package under api/pkg, imported by both the auth and user handlers, so neither handler
imports the other
And on success the response is 200 and the body is the updated user in that public shape:
exactly id, username, email, is_verified, created_at, updated_at and display_name
And GET /v1/auth/me and POST /v1/auth/verify use that same public shape, so display_name is
present in both, as "" when unset
And password, password_reset_code, verification_code, verification_expiration,
password_reset_expiration and token_version never appear in any response
(features/user_response_field_hygiene.feature)
And POST /v1/auth/login still authenticates with the username only; display_name is neither
accepted nor matched there
And changing the display name does not change token_version, so existing sessions stay valid
Scenario: A verified user sets a display name
Given a user repository containing user id 7, username "marta", email "marta@renew.example",
is_verified true, DisplayName "", TokenVersion 0
And a valid jwt cookie for user id 7 encoding token_version 0
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"}
Then the response status is 200
And the response body is a JSON object with exactly the keys id, username, email, is_verified,
created_at, updated_at and display_name
And the response body's id is 7, username "marta", display_name "Marta Alves"
And the user repository now holds DisplayName "Marta Alves" for user id 7
And the user repository still holds username "marta"
Scenario Outline: The value is trimmed before it is stored and returned
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": <sent>}
Then the response status is 200
And the response body's display_name is <returned>
And the user repository holds DisplayName <returned> for user id 7
Examples:
| sent | returned |
| " Marta Alves " | "Marta Alves" |
| "\tMarta Alves\n" | "Marta Alves" |
| "Marta Alves" | "Marta Alves" |
Scenario Outline: An empty or whitespace-only value clears the display name
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
"Marta Alves"
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": <sent>}
Then the response status is 200
And the response body's display_name is ""
And the response body's username is "marta"
And the user repository holds DisplayName "" for user id 7
Examples:
| sent |
| "" |
| " " |
| "\t \n" |
Scenario Outline: The limit is 60 Unicode characters, counted as runes
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and a display_name of <count> repetitions
of <unit>
Then the response status is <status>
And <effect>
Examples:
| count | unit | status | effect |
| 60 | "a" | 200 | the repository holds that 60-character value |
| 61 | "a" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" |
| 60 | "😀" | 200 | the repository holds that 60-rune value (240 bytes) |
| 61 | "😀" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" |
| 60 | "é" | 200 | the repository holds that 60-rune value (120 bytes) |
Scenario: The limit applies after trimming, not before
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and a display_name made of 5 spaces,
60 "a" characters and 5 spaces (70 characters in total)
Then the response status is 200
And the response body's display_name is the 60 "a" characters
Scenario: Control characters inside the name are kept
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Ma\nrta"}
Then the response status is 200
And the response body's display_name is "Ma\nrta"
Scenario: A rejected value leaves the existing display name untouched
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
"Marta Alves"
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and a display_name of 61 "a" characters
Then the response status is 400
And the repository's UpdateDisplayName is never called
And the user repository still holds DisplayName "Marta Alves" for user id 7
Scenario Outline: A malformed body is refused with 400
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
"Marta Alves"
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and <body>
Then the response status is 400
And the repository's UpdateDisplayName is never called
And the user repository still holds DisplayName "Marta Alves" for user id 7
Examples:
| body |
| the raw text not json |
| {} |
| {"display_name": null} |
| {"display_name": 42} |
| {"displayName": "Marta"} |
Scenario: The password, token version and login username are unaffected by a display name
Given a user repository containing user id 7, username "marta", is_verified true, a stored
password hash, DisplayName "", TokenVersion 0
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"}
Then the response status is 200
And the stored password hash for user id 7 is unchanged
And the stored TokenVersion for user id 7 is unchanged
And the same jwt cookie still gets 200 from GET /v1/auth/me
Scenario: GET /v1/auth/me returns the display name once set
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
"Marta Alves"
And a valid jwt cookie for user id 7
When the client sends GET /v1/auth/me with that cookie
Then the response status is 200
And the response body has display_name "Marta Alves" and username "marta"
Scenario: GET /v1/auth/me returns an empty display_name, not a missing key, when none is set
Given a user repository containing user id 8, username "bob", is_verified false, DisplayName ""
And a valid jwt cookie for user id 8
When the client sends GET /v1/auth/me with that cookie
Then the response status is 200
And the response body is a JSON object with exactly the keys id, username, email, is_verified,
created_at, updated_at and display_name
And the response body's display_name is ""
Scenario: Logging in still uses the username only
Given a user repository containing verified user "marta" with a known password and DisplayName
"Ines Duarte"
When the client sends POST /v1/auth/login with username "Ines Duarte" and the correct password
Then the response status is 403
And the response body is apperr.LogsErrorMessage
When the client sends POST /v1/auth/login with username "marta" and the correct password
Then the response status is 200
Scenario: An unverified user is refused
Given a user repository containing user id 8, username "bob", is_verified false, DisplayName ""
And a valid jwt cookie for user id 8
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Bob"}
Then the response status is 403
And the response body is the apperr.VerificationRequiredErrorMessage text
And the user repository still holds DisplayName "" for user id 8
Scenario: No jwt cookie is refused
Given a user repository containing user id 7, username "marta", is_verified true
When the client sends PATCH /v1/user/me with no jwt cookie and body {"display_name": "Marta"}
Then the response status is 401
And the repository's UpdateDisplayName is never called
Scenario: A stale jwt is refused
Given a user repository containing user id 7, username "marta", is_verified true, TokenVersion 1
And a jwt cookie for user id 7 encoding token_version 0
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
Then the response status is 401
And the response body is the apperr.UnauthorizedErrorMessage text
And the repository's UpdateDisplayName is never called
Scenario: A jwt minted for a since-deleted account is refused
Given a user repository containing no user with id 999
And a valid jwt cookie for user id 999
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
Then the response status is 401
And the response body is the apperr.UnauthorizedErrorMessage text
Scenario: A repository failure while saving answers 500
Given a user repository containing user id 7, username "marta", is_verified true
And the user repository fails on its next call to UpdateDisplayName
And a valid jwt cookie for user id 7
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
Then the response status is 500
And the response body is the text "Internal server error"
Scenario: The user is deleted between authentication and the save
Given a user repository whose UpdateDisplayName returns user.ErrUserNotFound for user id 7
And a valid jwt cookie for user id 7 that passes the middleware
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
Then the response status is 401
And the response body is the apperr.UnauthorizedErrorMessage text
Scenario Outline: The use case enforces the same rule without HTTP
Given a fake user repository containing user id 7 with DisplayName "Old"
When UpdateUserProfile.UpdateDisplayName is called for user id 7 with <input>
Then the result is <outcome>
Examples:
| input | outcome |
| " Marta " | the user with DisplayName "Marta" |
| "" | the user with DisplayName "" |
| " " | the user with DisplayName "" |
| 60 "😀" runes | the user with those 60 runes |
| 61 "😀" runes | user.ErrInvalidDisplayName (errors.Is), repository not called |
| "Marta" for unknown user id 99 | user.ErrUserNotFound (errors.Is) |
+127
View File
@@ -0,0 +1,127 @@
# QA procedure: user_display_name
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
following the router wiring pattern in `test/get_current_user_http_test.go`. No live server, no real
SQLite file, no real SMTP. Files: `test/user_display_name_test.go` (use case and HTTP).
## Test infrastructure additions needed first
1. Add `UpdateDisplayName(userId uint32, displayName string) (user.User, error)` to `mockUserRepo`:
call `m.fail.trigger("UpdateDisplayName")` first, return `user.ErrUserNotFound` when the id is
absent, otherwise store `DisplayName` on the map entry and return it. Add fields
`updateDisplayNameCalls int` and `lastUpdateDisplayName string`.
2. Use `failNext("UpdateDisplayName")` for the 500 case. For the "deleted between auth and save"
case, add `failNextWith("UpdateDisplayName", user.ErrUserNotFound)` (as planned in
`features/article_title_rules.qa.md` step 1), or seed the user, then delete it from the fake after
the middleware step via a hook.
3. Any other type implementing `user.Repository` in `test/` must gain the method so the package
compiles.
## Setup shared by every HTTP case
4. Call `validator.InitValidator()`.
5. `getUser := useCases.NewGetUserUseCase(userRepo)`, `update :=
useCases.NewUpdateUserProfileUseCase(userRepo)`, `userHandler := user.NewHandler(update)` (import
`RenewCMS/api/handlers/user` under an alias that does not collide with the domain `user`
package), `meHandler := auth.NewMeHandler(getUser)`.
6. Fresh `chi.NewRouter()` mirroring `api/router.go`, with `token := jwtauth.New("HS256",
[]byte("test-secret"), nil)`:
```go
r.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Patch("/me", userHandler.UpdateMe)
})
r.Route("/v1/auth", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
```
Add the public `/v1/auth/login` route (real `auth.NewLoginHandler` over the fakes, as in the
existing login tests) for the login case. The handler method names above are indicative; use
whatever the handlers expose.
7. Mint cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
uint32(v)})` set as the `jwt` cookie.
8. Decode success bodies into `map[string]json.RawMessage` (not `user.User`) so key sets can be
asserted. Error bodies are plain text from `http.Error` and carry a trailing `"\n"`.
## Use-case checks (fake repo, no HTTP)
9. `useCases.NewUpdateUserProfileUseCase(repo)` with user id 7 seeded, `DisplayName: "Old"`:
- `" Marta "` -> `DisplayName "Marta"` returned and stored.
- `""` and `" "` -> `DisplayName ""` stored (cleared).
- `strings.Repeat("😀", 60)` -> success. `strings.Repeat("😀", 61)` -> `errors.Is(err,
user.ErrInvalidDisplayName)`, `updateDisplayNameCalls == 0`, stored value still `"Old"`.
- Unknown id 99 -> `errors.Is(err, user.ErrUserNotFound)`.
- Repository failure via `failNext("UpdateDisplayName")` -> the error is returned unchanged.
## HTTP checks
10. Seed user 7 (`marta`, `marta@renew.example`, `IsVerified: true`, `DisplayName: ""`,
`TokenVersion: 0`) with a non-empty `Password` hash. `PATCH /v1/user/me`
`{"display_name":"Marta Alves"}` -> `200`. Body keys are exactly `{id, username, email,
is_verified, created_at, updated_at, display_name}`. `display_name` is `"Marta Alves"`,
`username` is `"marta"`. The repo holds `"Marta Alves"`. The stored `Password` and
`TokenVersion` are unchanged. The same cookie then gets `200` from `GET /v1/auth/me`.
11. Trim table: `" Marta Alves "` and `"\tMarta Alves\n"` -> `"Marta Alves"`; `"Marta Alves"`
keeps its interior spaces.
12. Clear table (seed `DisplayName: "Marta Alves"`): `""`, `" "`, `"\t \n"` -> `200`, response and
repo `display_name ""`, `username` still `"marta"`.
13. Length table (seed `""`): 60 x `"a"` -> `200`; 61 x `"a"` -> `400` with body
`apperr.DisplayNameErrorMessage + "\n"`; 60 x `"😀"` -> `200`; 61 x `"😀"` -> `400`; 60 x `"é"`
-> `200`. Assert `updateDisplayNameCalls == 0` on every `400` and that the stored value is
unchanged.
14. Trim-before-count: `strings.Repeat(" ", 5) + strings.Repeat("a", 60) + strings.Repeat(" ", 5)`
-> `200`, returned value is the 60 `"a"`.
15. Control characters: `{"display_name":"Ma\nrta"}` -> `200`, returned and stored value is
`"Ma\nrta"`.
16. Malformed table (seed `DisplayName: "Marta Alves"`): raw text `not json`, `{}`,
`{"display_name":null}`, `{"display_name":42}`, `{"displayName":"Marta"}` -> each `400`,
`updateDisplayNameCalls == 0`, stored value unchanged. Do not assert an exact body for these.
17. `GET /v1/auth/me` for user 7 with `DisplayName "Marta Alves"` -> `200`, `display_name` is
`"Marta Alves"`. For user 8 (`IsVerified: false`, `DisplayName ""`) -> key set is exactly the
seven keys and `display_name` is `""`, not absent.
18. Login: seed verified user `marta` with a stub-hashed password and `DisplayName: "Ines Duarte"`.
`POST /v1/auth/login` with username `"Ines Duarte"` -> `403`, body
`apperr.LogsErrorMessage + "\n"`. With username `"marta"` -> `200`. Confirm the login handler's
real status for an unknown username before asserting `403`.
19. Unverified user 8 with matching cookie -> `PATCH` -> `403`, body
`apperr.VerificationRequiredErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
20. No cookie -> `401` (do not assert the body; produced by `jwtauth.Authenticator`),
`updateDisplayNameCalls == 0`.
21. User 7 with `TokenVersion: 1`, cookie encoding `token_version 0` -> `401`, body
`apperr.UnauthorizedErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
22. Cookie for user 999 that does not exist -> `401`, same body.
23. `failNext("UpdateDisplayName")` (generic error) with a valid verified user -> `500`, body
`"Internal server error\n"`.
24. Repository returns `user.ErrUserNotFound` from `UpdateDisplayName` only (middleware passes) ->
`401`, body `apperr.UnauthorizedErrorMessage + "\n"`.
25. Sweep for secrets: on every `200` PATCH body and on `GET /v1/auth/me`, reuse
`assertNoForbiddenKeys` from `test/user_response_field_hygiene_test.go`. Add `token_version` to
that helper's forbidden keys if it does not check it yet.
## Edits to existing tests required by the six to seven field change
26. `test/user_response_field_hygiene_test.go` (`expectedKeys` in the verify test, around line 127):
add `"display_name"`. The count check then expects seven. The seeded user 7 has no display
name, so also assert `display_name` decodes to `""`.
27. `test/get_current_user_http_test.go` decodes into the public user struct; update its import to
the shared package. Step 17 is the authoritative shape check for `/me`.
## Manual verification (not exercised by the Go test)
- Start an existing dev SQLite file created before this slice, boot the server so `AutoMigrate`
runs, and confirm `GET /v1/auth/me` for the pre-existing user returns `display_name: ""` and that
login still works. Then `PATCH`, restart, and confirm the value persisted.
- Confirm by inspection that the `internal/domain/user` struct has no `gorm` tag on `DisplayName`,
the GORM tag with the empty-string default sits in `persistence/models`, `persistence/mappers`
maps both directions, and the model list in `api/dependecyInjection.go` needs no new entry (same
model, new column).
- Confirm that the new handler package constructs nothing and only receives `UpdateUserProfile`
via `NewHandler(...)`, that `dependecyInjection.go` wires the use case into the `*UseCases`
container, and that neither handler package imports the other.
- No SMTP or real embedded frontend is involved. The SPA is out of scope.
+8 -7
View File
@@ -7,11 +7,11 @@ Feature: API responses never expose a user's secret fields
credential or a still-valid reset/verification secret
Background:
Given the public shape of a user in any API response is exactly six fields: id, username,
email, is_verified, created_at and updated_at
And password, password_reset_code, verification_code, verification_expiration and
password_reset_expiration never appear in any response body, under any key name, regardless
of their value or whether they are set
Given the public shape of a user in any API response is exactly seven fields: id, username,
email, is_verified, created_at, updated_at and display_name
And password, password_reset_code, verification_code, verification_expiration,
password_reset_expiration and token_version never appear in any response body, under any key
name, regardless of their value or whether they are set
Scenario: A successful verification response carries only public fields
Given a user repository containing user id 7, username "alice", email "alice@example.com",
@@ -21,10 +21,11 @@ Feature: API responses never expose a user's secret fields
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 200
And the response body is a JSON object with exactly the keys id, username, email,
is_verified, created_at and updated_at
is_verified, created_at, updated_at and display_name
And the response body does not contain the keys password, password_reset_code,
verification_code, verification_expiration or password_reset_expiration
verification_code, verification_expiration, password_reset_expiration or token_version
And the response body's is_verified is true
And the response body's display_name is ""
Scenario: A rejected verification attempt never echoes any user field
Given a user repository containing user id 7, username "alice", is_verified false, whose
+5 -4
View File
@@ -21,10 +21,11 @@ Package: `test` (black-box), extending `test/account_verification_http_test.go`'
user id 7. Decode the response body into `map[string]json.RawMessage` (not into `user.User`)
and assert:
- the key set is exactly `{"id", "username", "email", "is_verified", "created_at",
"updated_at"}`;
"updated_at", "display_name"}`;
- none of `password`, `password_reset_code`, `verification_code`,
`verification_expiration`, `password_reset_expiration` is present;
- `is_verified` unmarshals to `true`.
`verification_expiration`, `password_reset_expiration`, `token_version` is present;
- `is_verified` unmarshals to `true`;
- `display_name` unmarshals to `""`.
5. Rejected verify: same seed, `{"code":"wrong-code"}` → assert status `403` and that the raw
response body bytes, treated as a string, contain none of the substrings `password`,
`verification_code`, `password_reset_code`, `verification_expiration`,
@@ -55,6 +56,6 @@ Package: `test` (black-box), extending `test/account_verification_http_test.go`'
render; confirm by hand that no SPA code reads `password`, `verification_code` or
`password_reset_code` off the verify response, since removing them from the wire format could
otherwise silently break a screen that isn't covered by this Go test suite.
- Confirm the new response type lives in `api/handlers/auth` (not a change to
- Confirm the public response type lives in the shared package under `api/pkg` (not a change to
`internal/domain/user/userDomain.go`'s json tags), per the project's stated preference to keep
the domain struct's tags intact and shape the public response in the handler layer instead.
+260
View File
@@ -0,0 +1,260 @@
Feature: Users screen and InviteUserModal
As a verified RenewCMS user in the shared workspace
I want /users to list everyone who has an account or an open invite, and let me invite someone
by email from a modal without leaving the page
So that a teammate can sign in with their own account, and I can always see who is in the
workspace and whether their invite is still open
Background:
Given /users is a verified route (meta.auth "verified") rendered as UsersPage inside
DashboardShell, per docs/design/routes.md and docs/design/screens/users.md
And UsersPage calls GET /v1/user/ once per navigation, and InviteUserModal sends POST
/v1/user/invite; both endpoints already exist (features/invitations.feature) and this slice
wires the SPA to them unchanged, with no Go change
And the list items keep the API shape: kind "user" (id, username, display_name, status
"accepted", created_at) or kind "invite" (id, email, status "pending" or "expired",
created_at); the list is used in the order the API returns it (created date, newest first),
and rows are keyed by kind and id, because a user and an invite can share a numeric id
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
"Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
Articles, Users, Integration in that order; "Users" has aria-current="page" on /users; the
"four navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
dashboard_shell_branding.feature are amended accordingly
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
And the page header is the title "Users" with the subtitle "Everyone who can sign in and work
on your articles." and, on desktop (md and up), a primary "Invite user" button (icon
user-plus) at the right of the header
And below md, "Invite user" is instead a full-width 48 px button (icon user-plus) pinned 16 px
from the left, right and bottom of the screen with the popover shadow, and it is hidden
while InviteUserModal is open
And the list is rendered as UsersTable (desktop, md and up: columns Username, Invite status,
Created, grid minmax(0,1fr) 160px 160px, 36 px header, 44 px rows, an arrow-down icon on the
Created header) or UserCardList (below md: 68 px minimum row height, the name line, then the
InviteStatusBadge and "Created {date}"), both fed by the same items array; rows have no
actions and are not clickable
And a user row's name cell shows the display name first (medium weight, highlighted) with the
username beside it in muted text when display_name is not empty, otherwise the username
alone (medium weight, highlighted); an invite row shows the email address in regular weight,
toned; an accepted user is never shown by email
And the row whose kind is "user" and whose id equals the session user's id (from GET
/v1/auth/me) carries a muted mono "you" tag after the name
And InviteStatusBadge shows icon and word, never color alone: Accepted (circle-check, success
soft), Pending (mail, warning soft), Expired (clock-alert, transparent, muted, with an
accented border)
And the Created column shows the date as "27 Sep 2026", or "Today" when it is the current day;
the fixture below uses absolute dates only
And skeleton rows (5) show only after 150 ms of loading, so a fast response never flashes them
And the Default fixture holds 5 items, newest first: invite id 2 "sam.oliveira@renew.example"
pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" expired 3 Aug 2026; user id 3 "ines"
with display_name "Inès Duarte" accepted 18 Jun 2026; user id 2 "jonas" accepted 2 Apr 2026;
user id 1 "marta" accepted 12 Mar 2026; the session user is id 1 "marta"
And InviteUserModal is a UModal opened from either "Invite user" button; every time it opens it
starts in its default state: title "Invite a user", text "We'll email them a link to choose
a username and password. The link expires after 3 days.", an empty, focused Email field
with placeholder "name@example.com", a "Cancel" button and a "Send invite" button
And the Email field validates on blur and again on submit, then on every input once it shows
an error; an empty value or one that is not a full email address shows "Enter a full email
address, like name@example.com." under the field, sends no request, and moves focus to the
field on a failed submit; the rule mirrors the API's email validation
And activating "Send invite" with a valid address sends POST /v1/user/invite with JSON
{"email": "<value as typed>"}; while the request is in flight the field and "Cancel" are
disabled, the confirm button reads "Sending…" with a loading indicator, and the modal cannot
be dismissed by Cancel, Esc or a backdrop click
And a 201 response closes the modal, inserts the response invite (kind "invite", status
"pending") as the first row of the list without a refetch, removes any other invite row for
the same address (the server replaced an expired one), draws the new row with the
--ui-bg-elevated background until the next load or navigation, and raises a toast: color
success, icon i-lucide-circle-check, id "invite-sent-{id}", title "Invite sent", description
"{email} has 3 days to accept."
And a 409 response never shows a request failure alert: it re-enables the form with the
address kept and shows the field error "This address already has an account." when the
response body is that text, or "There's already a pending invite for this address." when the
body is that text; any other 409 body shows "This address already has an account."
And a 400 response that got past this form's own check re-enables the form with the address
kept and shows the alert "Something in the form isn't valid. Check the highlighted fields."
with the field error "Enter a full email address, like name@example.com."
And a 429 response (the auth rate limiter) re-enables the form with the address kept and shows
the alert "Too many invites. Wait a moment, then try again." above the buttons, with no field
error; this state is not drawn in docs/design, so it has no visual state and is covered by
Vitest only
And a 500 response (including the API's "Couldn't send the invite. Try again in a moment.") or
a request that never reaches the server re-enables the form with the address kept and shows
the alert "Couldn't send the invite. Try again in a moment." above the buttons, with no
field error; the body text is never shown verbatim
And "Cancel" closes the modal immediately, discards the field and sends nothing, whenever the
form is not disabled by an in-flight request; focus returns to the button that opened it
And a 401 and a 403 (unverified) on either endpoint are handled entirely by useApi's global
handling (features/api_client_global_error_handling.feature)
And this slice does not build the accept-invite screen (/invite/accept), does not add row
actions (remove, promote, revoke or resend), and does not change how display names look in
the Profile group's "Display names in the Users list" state
Scenario: Default — the list loads newest first, with display name, "you" tag and status badges
Given GET /v1/user/ resolves 200 with the 5-item Default fixture and the session user is "marta"
When the user navigates to /users
Then the breadcrumb reads "Users" (mobile title "Users")
And the header shows the title "Users", the subtitle and, on desktop, "Invite user"
And the rows appear in exactly the fixture's order
And the first row shows "sam.oliveira@renew.example" (toned), a Pending badge and "27 Sep 2026"
And the second row shows "p.huber@gmail.com", an Expired badge and "3 Aug 2026"
And the third row shows "Inès Duarte" first with "ines" beside it in muted text, an Accepted
badge and "18 Jun 2026"
And the fifth row shows "marta" with the "you" tag, an Accepted badge and "12 Mar 2026"
And no row shows an email address for an accepted user
And below md the same rows render as cards, each with the name line, then the badge and
"Created {date}"
Scenario: Navigation — "Users" sits between Articles and Integration
Given the user is on /users
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration in that order
And "Users" has the users icon, links to /users and has aria-current="page"
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
Given GET /v1/user/ has not yet resolved
When the user navigates to /users
Then nothing is drawn in place of the list for the first 150 ms
And after 150 ms 5 skeleton rows replace the list, and the list container is aria-busy="true"
When GET /v1/user/ then resolves 200 with the Default fixture
Then the skeleton rows are replaced by the Default state's rows
Scenario: Only you — nobody has been invited yet
Given GET /v1/user/ resolves 200 with only user id 1 "marta", accepted 12 Mar 2026, and the
session user is "marta"
When the user navigates to /users
Then the list has one row: "marta" with the "you" tag, an Accepted badge and "12 Mar 2026"
And under the list the hint reads "Invite someone so they can sign in with their own account
instead of sharing yours."
And "Invite user" is still available
Scenario Outline: Error — a 500 or a network failure replaces the list with an alert and a retry
Given GET /v1/user/ <failure>
When the user navigates to /users
Then an error alert with no close button shows the title "Couldn't load users" and the text
"Check your connection and try again.", with an action "Try again" (icon rotate-cw)
And no table or cards are shown
When the user activates "Try again"
Then the Loading state shows and GET /v1/user/ is sent again
When it then resolves 200 with the Default fixture
Then the Default state's rows replace the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Invite: default — opening the modal
Given the Default state has loaded
When the user activates "Invite user"
Then InviteUserModal opens with title "Invite a user" and the text "We'll email them a link to
choose a username and password. The link expires after 3 days."
And the Email field is empty, focused, with placeholder "name@example.com"
And "Cancel" and "Send invite" are present, and nothing has been sent
And the list stays visible behind the modal, and below md the pinned "Invite user" button is hidden
Scenario Outline: Invite: validation — an invalid address is refused before any request
Given the modal is open
When the user types "<value>" into Email and <trigger>
Then the field shows the error "Enter a full email address, like name@example.com."
And POST /v1/user/invite is never sent
Examples:
| value | trigger |
| sam.oliveira@renew | moves focus away |
| not-an-email | activates "Send invite" |
| | activates "Send invite" |
Scenario: Invite: validation — the error clears as soon as the address is fixed
Given the modal shows the Email error for "sam.oliveira@renew"
When the user changes the value to "sam.oliveira@renew.example"
Then the error disappears
Scenario: Invite: sending — the form is locked and cannot be dismissed
Given the modal is open with Email "lea.martin@renew.example"
When the user activates "Send invite"
Then POST /v1/user/invite is sent with JSON {"email": "lea.martin@renew.example"}
And the Email field and "Cancel" are disabled and the confirm button reads "Sending…" with a
loading indicator
And pressing Esc or clicking the backdrop does not close the modal
Scenario: Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast
Given the Default state has loaded, and the modal is open with Email "lea.martin@renew.example"
with "Send invite" activated
When POST /v1/user/invite resolves 201 with id 3, kind "invite", email
"lea.martin@renew.example", status "pending", created_at now, expires_at 3 days later
Then the modal closes and focus returns to "Invite user"
And GET /v1/user/ is not called again
And the list has 6 rows, the first being "lea.martin@renew.example" with a Pending badge and
"Today", drawn with the --ui-bg-elevated background
And the other 5 rows are unchanged and follow it in their previous order
And a toast appears: color success, icon i-lucide-circle-check, id "invite-sent-3", title
"Invite sent", description "lea.martin@renew.example has 3 days to accept."
Scenario: Invite sent to an address whose invite had expired replaces the old row
Given the Default state has loaded, with the Expired row "p.huber@gmail.com"
When the user invites "p.huber@gmail.com" and POST /v1/user/invite resolves 201
Then the list holds exactly one row for "p.huber@gmail.com", first, with a Pending badge
And the Expired row is gone
Scenario: Invite sent into the Only-you state keeps a coherent list
Given the Only-you state has loaded
When an invite for "lea.martin@renew.example" is sent and resolves 201
Then the list shows the new Pending row above the "marta" row
And the Only-you hint is no longer shown
Scenario Outline: Invite: already a user (409) — the field error depends on the response text
Given the modal is open with Email "<email>", and "Send invite" has been activated
When POST /v1/user/invite resolves 409 with body "<body>"
Then the modal re-enables, keeping "<email>" in the field
And the field shows the error "<error>"
And no request-failure alert is shown
Examples:
| email | body | error |
| jonas@renew.example | This address already has an account. | This address already has an account. |
| sam.oliveira@renew.example | There's already a pending invite for this address. | There's already a pending invite for this address. |
Scenario: A 400 that got past this form's own check
Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated
When POST /v1/user/invite resolves 400
Then the modal re-enables, keeping the address
And the field shows "Enter a full email address, like name@example.com."
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
Scenario: Invite: rate limited — a 429 shows a dialog alert and keeps the address
Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated
When POST /v1/user/invite resolves 429 with body apperr.TooManyRequestsErrorMessage
Then the modal re-enables, keeping "lea.martin@renew.example" in the field
And an alert above the buttons reads "Too many invites. Wait a moment, then try again."
And the field shows no error
And the list is unchanged
When the user activates "Send invite" again and POST /v1/user/invite resolves 201
Then the modal closes, the Pending row appears on top and the "Invite sent" toast is raised
Scenario Outline: Invite: failed — a 500 or a network failure keeps the address and offers a retry
Given the modal is open with Email "lea.martin@renew.example", and "Send invite" has been activated
When POST /v1/user/invite <failure>
Then the modal re-enables, keeping "lea.martin@renew.example" in the field
And an alert above the buttons reads "Couldn't send the invite. Try again in a moment."
And the field shows no error
And the list is unchanged
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 500 response whose body is "Couldn't send the invite. Try again in a moment." |
| never reaches the server |
Scenario: Retrying after a failure succeeds
Given the modal shows the failed alert for "lea.martin@renew.example"
When the user activates "Send invite" again
Then POST /v1/user/invite is sent again with the same JSON body
When it resolves 201
Then the modal closes, the Pending row appears on top and the "Invite sent" toast is raised
Scenario: Cancel discards the address with no confirmation
Given the modal is open with Email "lea.martin@renew.example"
When the user activates "Cancel"
Then the modal closes immediately, with no confirmation prompt, and no request is sent
And reopening the modal shows an empty Email field in its default state
+112
View File
@@ -0,0 +1,112 @@
# QA: Users screen and InviteUserModal
## Implementation note for coder (informational, not a QA assertion)
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
Go change, so it has no HTTP-level Go section.
The shell specs were amended to four destinations (Home, Articles, Users, Integration):
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
Vitest tests must be updated in the same change.
The Invite sent toast uses `useToast().add({ id: 'invite-sent-{id}', color: 'success', icon:
'i-lucide-circle-check', title: 'Invite sent', description: '{email} has 3 days to accept.' })`.
The list items are keyed by `kind` and `id`.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/user/` and `POST /v1/user/invite` per case; no real backend.
Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta") and
a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is
id 3, email "lea.martin@renew.example". No test freezes the system date. The specs only assert on
the fixture's absolute dates; the sent row's "Today" label is not asserted anywhere (see the Visual
part). Run each layout case at a desktop and a below-md width (mock `matchMedia`).
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order,
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
the fixture's absolute dates; assert no accepted row shows an email.
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration in
that order, and "Users" carries aria-current="page" on /users.
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
assert 5 skeleton rows and `aria-busy="true"`; resolve and assert the Default rows.
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
copy, the "Try again" action, no close button and no rows; click "Try again"; assert `request`
is called again, then resolve the Default fixture and assert its rows.
6. **Invite: default** — click "Invite user"; assert title, text, an empty focused Email with its
placeholder, "Cancel", "Send invite"; below md assert the pinned button is hidden while open.
7. **Invite: validation, Examples-driven** — "sam.oliveira@renew" then blur; "not-an-email" then
submit; empty then submit; assert the field error, no `request` call, and focus on the field.
Assert that fixing the value clears the error.
8. **Invite: sending** — valid address, submit; assert one `POST /v1/user/invite` with body
`{"email":"lea.martin@renew.example"}`, the field and "Cancel" disabled, "Sending…", and
Escape and backdrop click not closing the modal.
9. **Invite sent** — resolve 201 with the invite fixture; assert the modal closes, `GET /v1/user/`
was not called a second time, the list has 6 rows with the new Pending row first and the
others unchanged, the first row has the `--ui-bg-elevated` background class, and the toast
was raised with id "invite-sent-3", color success, icon "i-lucide-circle-check", title
"Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert nothing
about the "Today" label.
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
one row for that address, first and Pending.
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
12. **409, Examples-driven** — reject with `ApiError(409)` carrying each of the two texts; assert
the matching field error, the address kept, no alert. Also assert an unknown 409 body falls
back to "This address already has an account.".
13. **400 got through** — reject with `ApiError(400)`; assert the alert and the field error.
14. **Rate limited (429)** — reject with `ApiError(429)` carrying `apperr.TooManyRequestsErrorMessage`
as body; assert the alert "Too many invites. Wait a moment, then try again." above the buttons
(the raw body is not shown), the address kept, the form re-enabled, no field error and the
list unchanged; submit again, resolve 201, and assert the closed modal, the row and the toast.
15. **Failed, Examples-driven** — `ApiError(500)`, `ApiError(500)` with the API's own text as body,
`NetworkError`; assert the alert "Couldn't send the invite. Try again in a moment." (shown
once, not the raw body), the address kept, no field error, the list unchanged.
16. **Retry after failure** — from 15, submit again; assert `request` is called a second time with
the same body; resolve 201 and assert the closed modal, the row and the toast.
17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty
field on reopening, and focus back on the opening button.
## Visual (task visual:check -- users, desktop and mobile, light and dark)
Reference: `docs/design/screens/users.md`, board group "Users" in `docs/design/source/RenewCMS
Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each
state's mock to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
capture of every non-skipped state must be within 10 %, for both viewports and both themes.
- [ ] Default: the 5 board rows (`sam.oliveira@renew.example`, `p.huber@gmail.com`, `ines` with
"Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026,
2 Apr 2026, 12 Mar 2026; session user `marta`.
- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture.
- [ ] Only you: the single `marta` row plus the hint.
- [ ] Error: the alert and "Try again", no table or cards; the header button is still drawn.
- [ ] Invite: default: modal over the Default list, empty focused Email; the mobile pinned button hidden.
- [ ] Invite: validation: value `sam.oliveira@renew`, error copy.
- [ ] Invite: sending: value `lea.martin@renew.example`, disabled field, "Sending…".
- [ ] Invite: already a user (409): value `jonas@renew.example`, error "This address already has an account.".
- [ ] Invite: failed: value `lea.martin@renew.example`, alert "Couldn't send the invite. Try again in a moment.".
- [ ] skip: clock-sensitive. Invite sent: `skip: 'clock-sensitive'` in `web/visual/states.ts`
("Today"). It prints SKIP, and is never investigated or date-frozen. Its Vitest coverage is case 9.
- [ ] Not drawn, no visual state: the pending-invite 409 variant and the 429 alert. They are
covered by Vitest only (cases 12 and 14); do not add them to `web/visual/states.ts`.
- [ ] Layout, taken from the board frame (`AppFrame.dc.html`), not `components.md`: desktop header
row is title block left and the 32 px "Invite user" button right, aligned to the bottom of
the row; on mobile the button is 16 px from left, right and bottom, 48 px high, 18 px icon.
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches "Invite user", the modal
focus starts on Email, then Cancel, then "Send invite", focus is trapped, Esc closes it in
every dismissible state and is blocked while Sending, focus returns to the opening button);
hover and active on the buttons; motion (240 ms modal, skeleton pulse 1.6 s, both stopped
under reduced motion).
## Manual verification
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. Invite a real address and
confirm: the 201 row, the 409 texts for an existing account and a live pending invite, the
replacement of an expired invite, the 429 after exceeding `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within
`AUTH_RATE_LIMIT_WINDOW_SECONDS`, and the list order after a reload.
- **Real invitation email** — needs real or sandboxed SMTP (`SMTP_HOST` and the related env
vars): the mail arrives and its link `{PUBLIC_URL}/invite/accept?c=...` carries the code. The
link will not resolve in the SPA, because the accept screen is out of scope.
- **Toast placement and stacking on a live app** — bottom right on desktop and above the pinned
button on mobile.
- **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label
against the real clock, since visual:check skips this state.
+203
View File
@@ -0,0 +1,203 @@
Feature: Verify account screen
As a user who just clicked the verification link in their email
I want /register/validate?c=CODE to submit that code automatically, land me on my dashboard once
it's accepted, tell me plainly when it's wrong or expired, and let me log in inline if I opened
the link somewhere I'm not logged in
So that verifying my account is a single click from my inbox, whatever device or session state I
open the link in
Background:
Given /register/validate is a "public" route (meta.auth "public"), rendered as VerifyPage
inside AuthShell, per docs/design/routes.md; the router's beforeEach never runs session.check
for a public route, so VerifyPage starts with no assumption about the session
And VerifyPage only calls POST /v1/auth/verify, DELETE /v1/auth/register, and POST
/v1/auth/logout (and, inline, POST /v1/auth/login on a 401)
And on mount, VerifyPage reads the "c" query parameter once: if it is absent or an empty
string, no request is ever made and the Missing code state renders immediately; otherwise the
Verifying state renders and POST /v1/auth/verify is called immediately with { code: c }
And every call to POST /v1/auth/verify (the initial one and the post-login retry) passes
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' } (the same onForbidden
RequestOptions field added to useApi by features/pending_screen.feature, plus the existing
onUnauthorized field used the same way getMe() already uses it), so a 401 renders the inline
log-in state instead of the global redirect to /login, and a 403 renders the Invalid-or-
expired state instead of the global "unverified session" redirect
And Verifying and No-session-retrying both render the same full-card spinner: a centered
"i-lucide-loader-2" icon with a spin animation, the title "Verifying your account…" and the
line "This only takes a moment.", as the design board's AuthCard spinner draws it,
role="status", replacing AuthShell's whole card body with no title header shown
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
draws it, while concurrently awaiting session.retry() (needed so /home's route guard sees the
now-verified session instead of whatever stale state this tab last cached) and an 800 ms
timer; once both settle, the app navigates to /home
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
and a note (rendered via the default slot) "Cancel your registration and start over to get a
new link.", with a primary action "Cancel registration and start over" and a secondary action
"Log out"; unlike Pending's cancel flow, this button opens no ConfirmDialog (verify.md's state
list has no confirm step for it) and calls DELETE /v1/auth/register directly, passing
{ onForbidden: 'passthrough' } for the same reason Pending's own DELETE call does
And "Cancel registration and start over" resolving 200 navigates to /register and shows the
same toast Pending's own Cancelled state shows (color "success", icon "i-lucide-circle-check",
title "Registration cancelled", description "You can create the owner account again."),
reusing that copy since it is the same underlying event
And "Cancel registration and start over" failing — with a 403 (already verified elsewhere, so
there is nothing left to cancel), a 500, or a request that never reaches the server — shows an
alert on the same ResultPanel screen via AuthShell's #notice slot, reusing Register's existing
server-error pattern: color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment.",
variant "soft", not closable, role "alert"; the ResultPanel and its two actions stay exactly
as they were so the user can try again or choose "Log out" instead; no redirect happens for
the 403 case here, unlike Pending's own already-verified handling
And "Log out" (on the Invalid-or-expired state) behaves exactly as it does on Pending: it calls
POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and clears the jwt
cookie, then VerifyPage calls session.clear() and navigates to /login, disabling both actions
while the call is in flight
And on a 401, VerifyPage replaces the spinner with an inline log-in form: AuthShell title "Log
in to finish verifying", description "You opened this link somewhere you're not logged in.
Log in here and we'll verify your account right after.", a Username field (placeholder "Your
username") and a Password field (password input with a show/hide toggle), and a submit button
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
unlike LoginView
And the inline form's submit button is enabled, as the design board draws it; on submit,
LoginView's own validation runs first (Username under 3 characters shows "Use at least 3
characters.", an empty Password shows "Enter your password.", and nothing is sent), and a
valid form calls POST /v1/auth/login with { username, password }
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
follows the 200/403 handling above
And a 403 (wrong credentials), 429, or 500/network failure from that inline login call reuses
LoginView's existing alert copy for the same statuses (wrongCredentialsAlert, with LoginView's
exact title/description; tooManyAttemptsAlert with the same retryAfter countdown behavior as
LoginView; serverErrorAlert), shown via AuthShell's #notice slot, since verify.md specifies no
separate copy for this embedded form and states.md's HTTP-status table assigns this copy
generically to "Log in" contexts
And while that inline login is submitting, both fields are disabled and the submit button reads
"Logging in…" with a loading indicator, mirroring LoginView's submitting state
Scenario: Verifying — the code submits automatically on load
When the user navigates to "/register/validate?c=abc123"
Then the full-card spinner is shown with the text "Verifying your account…"
And POST /v1/auth/verify is called once, immediately, with body {"code": "abc123"}
Scenario: Success — 200 refreshes the session and navigates to /home with a toast
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 200
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
account is verified", text "Taking you to your dashboard…", and no actions
And a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your
account is verified", description "Welcome to RenewCMS."
And session.retry() is awaited
And after 800 ms and once session.retry() has resolved, the app navigates to /home
Scenario: Invalid or expired — 403 offers a way to start over
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 403
Then a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This
verification link doesn't work", text "It may have expired (links last 2 hours) or already
been used.", and note "Cancel your registration and start over to get a new link."
And its actions are a primary "Cancel registration and start over" and a secondary "Log out"
Scenario: Cancel registration and start over — success
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
Then DELETE /v1/auth/register is sent directly, without any confirmation dialog, with
{ onForbidden: 'passthrough' }
When it resolves 200
Then the app navigates to /register
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario Outline: Cancel registration and start over — failure shows an alert, not a redirect
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
And DELETE /v1/auth/register <failure>
Then an alert is shown with color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment."
And the ResultPanel and its two actions ("Cancel registration and start over", "Log out") are
still shown, unchanged, so the user can retry or log out instead
And no navigation happens
Examples:
| failure |
| resolves 403 with the apperr.AlreadyVerifiedErrorMessage body |
| fails with a 500 response |
| never reaches the server |
Scenario: Log out from the Invalid-or-expired state
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Log out"
Then POST /v1/auth/logout is sent
When it resolves 200
Then the session store is cleared
And the app navigates to /login
Scenario: No session — the inline log-in form replaces the spinner
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 401
Then the spinner is replaced by a form titled "Log in to finish verifying" with description
"You opened this link somewhere you're not logged in. Log in here and we'll verify your
account right after."
And it has a Username field (placeholder "Your username") and a Password field with a show/hide
toggle, and a submit button "Log in and verify"
And there is no "Forgot password?" link and no registration-open footer
Scenario: No session — empty fields are caught on submit, as on Log in
Given the inline log-in form from "No session" is shown
Then the submit button is enabled
When the user submits with both fields empty
Then Username shows "Use at least 3 characters." and Password shows "Enter your password."
And POST /v1/auth/login is not called
Scenario: No session — successful login retries verification with the same code
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 200
Then the full-card spinner is shown again with the text "Verifying your account…"
And POST /v1/auth/verify is sent again, with body {"code": "abc123"} and the same passthrough
options as the first attempt
Scenario: No session — wrong credentials during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "wrong-password"
And POST /v1/auth/login resolves 403
Then an error alert is shown with title "That username and password don't match" and
description "Check both and try again. Passwords are case-sensitive."
And the Password field is cleared and refocused
And POST /v1/auth/verify is not retried
Scenario: No session — too many login attempts
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 429 with Retry-After 30
Then an info alert is shown with title "Too many attempts" and description "For your security,
wait a moment before trying again."
And the submit button reads "Try again in 0:30" and it and both fields are disabled until the
countdown ends, mirroring LoginView
Scenario Outline: No session — server or network error during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login <failure>
Then an error alert is shown with title "Couldn't reach RenewCMS" and description "Check your
connection and try again."
And the fields keep their values and are re-enabled
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: Missing code — no request is made
When the user navigates to "/register/validate" with no "c" query parameter
Then no request is made
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link is
incomplete", text "The verification code is missing. Open the link from your email again, or
copy the full address into your browser."
And its single action is a secondary "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more