Compare commits

...
117 changed files with 11956 additions and 610 deletions

No files matched your search

+22 -2
View File
@@ -3,6 +3,7 @@ package api
import ( import (
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
@@ -11,6 +12,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -33,7 +35,12 @@ type UseCases struct {
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase DeleteImageUseCase *useCases.DeleteImageUseCase
@@ -43,19 +50,26 @@ type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
MailRepo domainMail.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 { func getDb(dbName string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error()) 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 { if err != nil {
panic("Unable to open the database: " + err.Error()) 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()) panic("Failed to migrate database: " + err.Error())
} }
@@ -67,6 +81,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp), MailRepo: mailer.NewMailRepository(settings.Smtp),
} }
} }
@@ -88,7 +103,12 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
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), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(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 { func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion}) _, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil { if err != nil {
return err return err
} }
http.SetCookie(*w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "jwt", Name: "jwt",
Value: tokenString, Value: tokenString,
Expires: time.Now().Add(24 * time.Hour), Expires: time.Now().Add(24 * time.Hour),
Secure: h.IsProduction, Secure: isProduction,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
}) })
return nil 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) { func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials var credentials LoginCredentials
+2 -1
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
@@ -37,7 +38,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(newPublicUser(loadedUser)) body, err := json.Marshal(publicuser.New(loadedUser))
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+2 -22
View File
@@ -3,13 +3,13 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"time"
) )
type VerificationHandler struct { type VerificationHandler struct {
@@ -22,26 +22,6 @@ type VerifyCredentials struct {
Code string `json:"code" validate:"required"` 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 { func NewVerificationHandler(verify *useCases.VerifyUserUseCase, cancel *useCases.CancelRegistrationUseCase, isProduction bool) *VerificationHandler {
return &VerificationHandler{ return &VerificationHandler{
VerifyUseCase: verify, VerifyUseCase: verify,
@@ -84,7 +64,7 @@ func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(newPublicUser(verifiedUser)) body, err := json.Marshal(publicuser.New(verifiedUser))
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+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)
}
+7
View File
@@ -5,6 +5,7 @@ const BodyErrorMessage = "The request cannot be processed due to a mismatch in t
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)." 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 ArticleNotFoundErrorMessage = "Article not found"
const ImageNotFoundErrorMessage = "Image 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 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 RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later." const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
@@ -15,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 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 PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later." 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 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 var Validate *validator.Validate
func InitValidator() { func InitValidator() {
Validate = validator.New() Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
}
func isInviteEmail(field validator.FieldLevel) bool {
return inviteEmailPattern.MatchString(field.Field().String())
} }
+29
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/image" "RenewCMS/api/handlers/image"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"encoding/json" "encoding/json"
@@ -61,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase) meHandler := auth.NewMeHandler(container.GetUserUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus) r.Get("/registration", loginHandler.RegistrationStatus)
@@ -76,6 +78,12 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset) r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
}) })
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword) 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.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
@@ -87,6 +95,26 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
return r 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 { func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
@@ -99,6 +127,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container)) 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)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r return r
+7
View File
@@ -21,6 +21,13 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
Nothing under `internal/` imports `api/`. 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 ## Frontend ↔ backend
``` ```
+3 -3
View File
@@ -27,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. | | `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. | | `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). | | `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_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 two routes above. | | `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 `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). | | `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)). Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
+6 -2
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 | | `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 | | `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 | | `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). | | `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 ## Screens
@@ -30,10 +30,14 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `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` | 22 | `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` | | [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` | | [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
## Other boards ## Other boards
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit). - `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the 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). - `source/RenewCMS Directions.dc.html`: the three brand directions (history only).
+59 -7
View File
@@ -6,7 +6,12 @@
|---|---|---|---|---|---| |---|---|---|---|---|---|
| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 | | PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor |
| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | 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 | | 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 | | 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, Register (403) | | ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration | | CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration | | EndpointCard | 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 | | ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell | | OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route | | AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
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 ## 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 ### 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 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. 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. 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.
@@ -370,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. 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 ## Themed primitives
Extracted from `source/RenewCMS Components Primitives.dc.html`. 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/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/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`. Previews: `source/RenewCMS Emails.dc.html`.
## Transactional emails ## 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/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} | | `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · POST /{id}/image · DELETE /{id} |
| `/integration` | DashboardShell | verified | IntegrationPage | none | | `/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 | | `/: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. 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."
+1 -1
View File
@@ -4,7 +4,7 @@
- **API:** GET /v1/article/ - **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). - **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 ## 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).
+87 -5
View File
@@ -16,8 +16,9 @@
</sc-for> </sc-for>
<div style="flex:1"></div> <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="{{ 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> </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> </sc-if>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column"> <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}"> <sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
@@ -41,7 +42,7 @@
<sc-if value="{{ isHome }}"> <sc-if value="{{ isHome }}">
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:{{ gap }};max-width:1040px"> <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.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 }}"> <sc-if value="{{ s.home.stats }}">
<div style="display:grid;grid-template-columns:{{ cols3 }};gap:12px"> <div style="display:grid;grid-template-columns:{{ cols3 }};gap:12px">
@@ -195,6 +196,66 @@
</div> </div>
</sc-if> </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 }}"> <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> <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> </sc-if>
@@ -202,6 +263,8 @@
</div> </div>
</div> </div>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><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 }}"> <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 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"> <div role="dialog" aria-modal="true" style="width:{{ modalW }};background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column">
@@ -273,7 +336,7 @@ class Component extends DCLogic {
const s = this.props.s || {} const s = this.props.s || {}
const m = !!this.props.mobile const m = !!this.props.mobile
const k = s.kind || 'home' 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 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 crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
const L = s.list || {} const L = s.list || {}
@@ -325,6 +388,20 @@ class Component extends DCLogic {
stageH: m ? '240px' : '340px', pillFlex: m ? '1 1 0' : '0 0 auto', groupW: m ? '100%' : 'max-content' 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 toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
const endpoints = [ 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()" }, { 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()" },
@@ -336,11 +413,16 @@ class Component extends DCLogic {
const edReady = k === 'editor' && !e.loading && !e.notfound const edReady = k === 'editor' && !e.loading && !e.notfound
const edTitle = e.title || 'How we price a bathroom renovation' const edTitle = e.title || 'How we price a bathroom renovation'
return { return {
cr, 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, desk: !m, mob: m, notBoot: !s.boot,
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row', fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
mLead: s.mLead || 'menu', mLead: s.mLead || 'menu',
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', 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))', 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', 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', 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',
+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 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"> <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> <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: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" 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: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> </div>
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="2"> <sc-for list="{{ rows }}" as="r" hint-placeholder-count="2">
<section style="display:flex;flex-direction:column;gap:20px"> <section style="display:flex;flex-direction:column;gap:20px">
@@ -33,13 +33,14 @@
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script> <script type="text/x-dc" data-dc-script>
class Component extends DCLogic { class Component extends DCLogic {
state = { v: '', r: '' } state = { v: '', r: '', i: '' }
componentDidMount() { componentDidMount() {
fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v })) fetch('emails/verification.html').then(x => x.text()).then(v => this.setState({ v }))
fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r })) fetch('emails/password-reset.html').then(x => x.text()).then(r => this.setState({ r }))
fetch('emails/invitation.html').then(x => x.text()).then(i => this.setState({ i }))
} }
fill(t) { 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) { frames(t) {
if (!t) return [] if (!t) return []
@@ -50,7 +51,8 @@ class Component extends DCLogic {
renderVals() { renderVals() {
return { rows: [ return { rows: [
{ title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) }, { title: 'Verification email', vars: '{{.Host}} · {{.VerificationCode}} → /register/validate?c=', frames: this.frames(this.state.v) },
{ title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) } { title: 'Password reset email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} → /register/reset/validate?c=&email=', frames: this.frames(this.state.r) },
{ title: 'Invitation email', vars: '{{.Host}} · {{.VerificationCode}} · {{.Email}} · {{.InvitedBy}} → /invite/accept?c=', frames: this.frames(this.state.i) }
] } ] }
} }
} }
+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: '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: '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: '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: '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/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/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: '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/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: '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' } { 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', 'AuthShell', 'public', 'RegisterPage', 'POST /v1/auth/register'],
['/register/pending', 'AuthShell', 'session', 'PendingPage', 'DELETE /v1/auth/register · POST /v1/auth/logout'], ['/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)'], ['/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/request', 'AuthShell', 'public', 'ResetRequestPage', 'POST /v1/auth/password-reset'],
['/register/reset/validate', 'AuthShell', 'public', 'ResetConfirmPage', 'POST /v1/auth/password-reset/confirm'], ['/register/reset/validate', 'AuthShell', 'public', 'ResetConfirmPage', 'POST /v1/auth/password-reset/confirm'],
['/home', 'DashboardShell', 'verified', 'HomePage', 'GET /v1/article/'], ['/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'], ['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'], ['PublishControl', 'UButton', 'online: boolean, dirty: boolean, pending?: string, disabled?', 'publish, unpublish', 'none', 'Editor'],
['ArticleDetails', 'UCard (desktop) / USlideover (mobile)', 'article: Article', 'copy-url', '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'], ['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'], ['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, Register (403)'], ['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Accept invite, Register (403)'],
['CodeBlock', 'custom (shiki) + UButton', 'code: string, lang: string, label?', 'none', 'none', 'Integration'], ['CodeBlock', 'custom (shiki) + UButton', 'code: string, lang: string, label?', 'none', 'none', 'Integration'],
['EndpointCard', 'UCard, UBadge, UTabs', "method: 'GET', path, description, examples: { fetch, curl, response }", 'none', 'none', 'Integration'], ['EndpointCard', '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'], ['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('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('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('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('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('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']]), 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', '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', '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', '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'], ['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', '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', 'Row or editor action', '"That article no longer exists." The row is removed', 'Toast'],
@@ -167,6 +173,7 @@ class Component extends DCLogic {
const pending = [ 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/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/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: '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: '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.' } { 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.' }
+38 -4
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 } { 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 = [ 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: '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: '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: '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: '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: '' } } { 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) { start(key) {
const articles = this.base() 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 === 'first') return { ...common, route: 'register', regOpen: true, articles: [] }
if (key === 'edit') return { ...common, route: 'login', regOpen: false, articles } if (key === 'edit') return { ...common, route: 'login', regOpen: false, articles }
if (key === 'reset') 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 === '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 } } 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') } 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) { act(a) {
const s = this.state 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 === '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 (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) if (s.modal) return this.modalAct(a)
const r = s.route const r = s.route
@@ -221,6 +236,7 @@ class Component extends DCLogic {
} }
return 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 === '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 === 'm-lead' && r !== 'editor') return this.setState({ mnav: true })
if (a.startsWith('open-')) return this.openEditor(Number(a.slice(5))) if (a.startsWith('open-')) return this.openEditor(Number(a.slice(5)))
@@ -279,6 +295,21 @@ class Component extends DCLogic {
} }
return 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 (m.type === 'delete') {
if (a === 'modal-cancel' || a === 'modal-close') return close() if (a === 'modal-cancel' || a === 'modal-close') return close()
if (a === 'modal-confirm') { if (a === 'modal-confirm') {
@@ -338,6 +369,8 @@ class Component extends DCLogic {
const T = (name, crumb) => ({ nav: name, crumbs: crumb }) 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 === '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 === '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 === 'integration') return { kind: 'integration', ...T('integration', ['Integration']), mTitle: 'Integration', copied: s.copied }
if (s.route === 'editor') { if (s.route === 'editor') {
const a = this.art(s.cur) || {}, e = s.ed || {} const a = this.art(s.cur) || {}, e = s.ed || {}
@@ -349,6 +382,7 @@ class Component extends DCLogic {
const m = this.state.modal const m = this.state.modal
if (!m) return null 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 === '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') { if (m.type === 'delete') {
const a = this.art(m.id) || { t: '', imgs: 0 } 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' const imgs = a.imgs === 0 ? '' : a.imgs === 1 ? ' and its image' : ' and its ' + a.imgs + ' images'
@@ -387,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 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 [dw, dh] = mobile ? [390, 844] : [1440, 900]
const navKey = s.route === 'editor' ? 'articles' : s.route 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 + ')' })) const toasts = s.toasts.map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
return { return {
rootRef: this.rootRef, stageRef: this.stageRef, rootRef: this.rootRef, stageRef: this.stageRef,
devices: devs, modes, flows, hint, tools, devices: devs, modes, flows, hint, tools,
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }), hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
mobile, isAuth, isApp, isEmail, mobile, isAuth, isApp, isEmail,
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', authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account', emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px', devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
@@ -18,7 +18,7 @@
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px"> <div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px"> <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> <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>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"> <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"> <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 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 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 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 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 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' } 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: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' },
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } } { name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } }
] }, ] },
{ label: '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: [ { 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: '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.' })] }, { ...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.' })] },
@@ -95,6 +95,18 @@ class Component extends DCLogic {
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } }) 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 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 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 [ 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: [ { 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 }) withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true })
@@ -156,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', 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.' }) 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: [ { 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: '', 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' } }), 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>
+41 -2
View File
@@ -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) | | 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` | Default | Page load | [integration](screens/integration.md) |
| Integration | `/integration` | Copied | Copy clicked (2 s) | [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 ## 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 | 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 | 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 | | 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 | 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 | Row or editor action | "That article no longer exists." The row is removed | Toast |
| 404 | Unknown route | "Page not found" | ErrorView | | 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 | | 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 | Save | "Save failed" + Retry. Changes are kept | SaveStatus |
| 500 | Publish / unpublish | "Couldn't publish" / "Couldn't unpublish" + Retry | Toast | | 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 | 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 | | 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 | | network | Anywhere | "Couldn't reach RenewCMS. Check your connection and try again." Offline: banner | Alert / toast / banner |
## API the design marked pending ## 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.
+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`.
+21 -18
View File
@@ -17,15 +17,17 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> the shell renders today <span class="font-semibold">RenewCMS</span> the shell renders today
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
to the sidebar's own collapsed state, with items Home, Articles and Integration 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 And the sidebar's #footer slot renders a UserMenu component, not the plain
<span>Log out</span> text the shell renders today <span>Log out</span> text the shell renders today
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a 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 username ghost UButton block containing a UAvatar and the session's name (the display name when not
And the UAvatar's initials are the first two characters of the username, upper-cased (for empty, else the username)
example "marta" gives "MA"), and the avatar is aria-hidden because the username is shown And the UAvatar's initials follow features/profile_page.feature (the first letters of the
next to it first two words of a display name, the first two characters of a single word or of a
And UserMenu's dropdown groups, top to bottom, are: an account label (the username); 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 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 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), And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
@@ -53,15 +55,16 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
Then the sidebar returns to its :default-size="14" expanded width Then the sidebar returns to its :default-size="14" expanded width
And aria-expanded reflects the expanded state And aria-expanded reflects the expanded state
Scenario: UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out Scenario: UserMenu lists the header, Profile, color-mode checkboxes, then Log out
Given the session's username is "marta" 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 When the user activates the UserMenu trigger in the sidebar footer
Then the dropdown opens with a UAvatar showing initials "MA" and the trigger's own username Then the menu opens with a UAvatar showing initials "MA" and the trigger's own name text
text "marta" "marta"
And the first group is the account label showing "marta" And the first group is the header showing "marta" and "marta@renew.example"
And the second group is a checkbox group with items System, Light and Dark, and exactly the 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 item matching the current useColorMode().preference is checked
And the third and final group contains only "Log out", separated from the color-mode group 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 Scenario Outline: Selecting a color-mode item updates the preference and its checked state
Given the user has opened the UserMenu dropdown Given the user has opened the UserMenu dropdown
@@ -91,11 +94,11 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text 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 reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
regression regression
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the username text And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the name text
next to it is hidden next to it is hidden
And the UserMenu trigger keeps an accessible name that includes the username (an aria-label 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 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 tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
docs/design/components.md's DashboardShell a11y note docs/design/components.md's DashboardShell a11y note
And activating the collapsed UserMenu trigger still opens the same dropdown, with the same And activating the collapsed UserMenu trigger still opens the same menu, with the same
account label, color-mode checkbox group and Log out item as the expanded state header, Profile, color-mode checkbox group and Log out item as the expanded state
+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 without being throttled like a login attempt
Background: Background:
Given the existing PublicUser struct and newPublicUser(u user.User) converter in Given the public user struct and its converter, shared by the auth and user handlers from a
api/handlers/auth/verificationHandler.go, exactly the JSON shape POST /v1/auth/verify already package under api/pkg (features/user_display_name.feature), exactly the JSON shape
returns: id, username, email, is_verified, created_at, updated_at 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) 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 built with NewMeHandler(container.GetUserUseCase), the same GetUserUseCase every other
protected handler already gets from api/dependecyInjection.go; no new use case or domain protected handler already gets from api/dependecyInjection.go; no new use case or domain
+4 -3
View File
@@ -18,8 +18,9 @@ Feature: Home screen
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site 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 reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
link "Open the guide" navigating to /integration link "Open the guide" navigating to /integration
And a greeting reading "{greeting}, {username}" sits above the subtitle in every state And a greeting reading "{greeting}, {name}" sits above the subtitle in every state
(Loading, Default, Empty and Error alike); it is computed from the session's username and (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 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 is already resolved by the app-boot flow before HomePage ever renders, and so never has its
own loading or error state own loading or error state
@@ -109,7 +110,7 @@ Feature: Home screen
second request made for either second request made for either
Scenario Outline: The greeting matches the hour of day Scenario Outline: The greeting matches the hour of day
Given the session's username is "marta" Given the session's username is "marta" and it has no display name
And the device's local hour is <hour> And the device's local hour is <hour>
When the user views /home When the user views /home
Then the greeting above the subtitle reads "<greeting>, marta" Then the greeting above the subtitle reads "<greeting>, marta"
+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`.
+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.
+5 -4
View File
@@ -130,13 +130,14 @@ Feature: Nuxt UI shell foundation and router guards
Then the alert above the title contains "Your session expired." Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the three navigation destinations Scenario: DashboardShell renders the four navigation destinations
Given the current route is /article Given the current route is /article
When DashboardShell mounts When DashboardShell mounts
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles" Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
linking to /article, and "Integration" linking to /integration /home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
to /integration
And the "Articles" destination has aria-current="page" And the "Articles" destination has aria-current="page"
And neither "Home" nor "Integration" has aria-current set And none of "Home", "Users" and "Integration" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false Given navigator.onLine is false
+1 -1
View File
@@ -41,7 +41,7 @@ Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest se
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert 13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified. both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert 14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link 4 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration), and only the Articles link
has aria-current="page". has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert 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 the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
+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.
+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 credential or a still-valid reset/verification secret
Background: Background:
Given the public shape of a user in any API response is exactly six fields: id, username, Given the public shape of a user in any API response is exactly seven fields: id, username,
email, is_verified, created_at and updated_at email, is_verified, created_at, updated_at and display_name
And password, password_reset_code, verification_code, verification_expiration and And password, password_reset_code, verification_code, verification_expiration,
password_reset_expiration never appear in any response body, under any key name, regardless password_reset_expiration and token_version never appear in any response body, under any key
of their value or whether they are set name, regardless of their value or whether they are set
Scenario: A successful verification response carries only public fields Scenario: A successful verification response carries only public fields
Given a user repository containing user id 7, username "alice", email "alice@example.com", 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"} When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 200 Then the response status is 200
And the response body is a JSON object with exactly the keys id, username, email, 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, 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 is_verified is true
And the response body's display_name is ""
Scenario: A rejected verification attempt never echoes any user field Scenario: A rejected verification attempt never echoes any user field
Given a user repository containing user id 7, username "alice", is_verified false, whose 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`) user id 7. Decode the response body into `map[string]json.RawMessage` (not into `user.User`)
and assert: and assert:
- the key set is exactly `{"id", "username", "email", "is_verified", "created_at", - 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`, - none of `password`, `password_reset_code`, `verification_code`,
`verification_expiration`, `password_reset_expiration` is present; `verification_expiration`, `password_reset_expiration`, `token_version` is present;
- `is_verified` unmarshals to `true`. - `is_verified` unmarshals to `true`;
- `display_name` unmarshals to `""`.
5. Rejected verify: same seed, `{"code":"wrong-code"}` → assert status `403` and that the raw 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`, response body bytes, treated as a string, contain none of the substrings `password`,
`verification_code`, `password_reset_code`, `verification_expiration`, `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 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 `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. 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 `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. 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.
+43
View File
@@ -0,0 +1,43 @@
package invite
import (
"errors"
"time"
)
var ErrEmailHasAccount = errors.New("email already has an account")
var ErrPendingInvite = errors.New("pending invite already exists")
var ErrInviteNotFound = errors.New("invite not found")
var ErrInviteEmailNotSent = errors.New("invite email not sent")
var ErrInviteInvalid = errors.New("invite invalid")
var ErrInviteExpired = errors.New("invite expired")
const Lifetime = 3 * 24 * time.Hour
type Status string
const (
StatusPending Status = "pending"
StatusExpired Status = "expired"
StatusAccepted Status = "accepted"
)
type Invite struct {
ID uint32 `json:"id"`
Email string `json:"email"`
HashedCode string `json:"hashed_code"`
ExpiresAt time.Time `json:"expires_at"`
InvitedBy uint32 `json:"invited_by"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedAt time.Time `json:"created_at"`
}
func (i Invite) StatusAt(now time.Time) Status {
if i.AcceptedAt != nil {
return StatusAccepted
}
if i.ExpiresAt.After(now) {
return StatusPending
}
return StatusExpired
}
@@ -0,0 +1,13 @@
package invite
import (
"RenewCMS/internal/domain/user"
"time"
)
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
Delete(id uint32) error
AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser user.User) (user.User, error)
}
+3
View File
@@ -13,6 +13,8 @@ var ErrVerificationCodeExpired = errors.New("verification code expired")
var ErrAlreadyVerified = errors.New("user already verified") var ErrAlreadyVerified = errors.New("user already verified")
var ErrInvalidPasswordResetCode = errors.New("invalid password reset code") var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
var ErrPasswordResetCodeExpired = errors.New("password reset code expired") var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken")
type User struct { type User struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
@@ -20,6 +22,7 @@ type User struct {
Password string `json:"password"` Password string `json:"password"`
PasswordResetCode string `json:"password_reset_code"` PasswordResetCode string `json:"password_reset_code"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
VerificationCode string `json:"verification_code"` VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"` VerificationExpiration time.Time `json:"verification_expiration"`
@@ -14,4 +14,5 @@ type Repository interface {
UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (User, error) UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (User, error)
ClearVerificationCode(userId uint32) (User, error) ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error) ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error)
} }
@@ -24,14 +24,25 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
const defaultSubject = "RenewCMS | Action required" const defaultSubject = "RenewCMS | Action required"
func (m MailRepository) Render(templateName string, data any) (string, string, error) { type subjectProvider interface {
subject := defaultSubject Subject() string
}
func subjectFor(templateName string, data any) string {
if provider, ok := data.(subjectProvider); ok {
return provider.Subject()
}
switch templateName { switch templateName {
case "verification": case "verification":
subject = "Verify your RenewCMS account" return "Verify your RenewCMS account"
case "password-reset": case "password-reset":
subject = "Reset your RenewCMS password" return "Reset your RenewCMS password"
} }
return defaultSubject
}
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := subjectFor(templateName, data)
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html") tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
if err != nil { if err != nil {
@@ -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>
@@ -0,0 +1,30 @@
package mappers
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/models"
)
func InviteToDomain(m models.Invite) invite.Invite {
return invite.Invite{
ID: m.ID,
Email: m.Email,
HashedCode: m.HashedCode,
ExpiresAt: m.ExpiresAt,
InvitedBy: m.InvitedBy,
AcceptedAt: m.AcceptedAt,
CreatedAt: m.CreatedAt,
}
}
func InviteToModel(i invite.Invite) models.Invite {
return models.Invite{
ID: i.ID,
Email: i.Email,
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
}
@@ -26,6 +26,7 @@ func UserToDomain(m models.User) user.User {
Password: m.Password, Password: m.Password,
PasswordResetCode: nullableStringToDomain(m.PasswordResetCode), PasswordResetCode: nullableStringToDomain(m.PasswordResetCode),
Email: m.Email, Email: m.Email,
DisplayName: m.DisplayName,
IsVerified: m.IsVerified, IsVerified: m.IsVerified,
VerificationCode: nullableStringToDomain(m.VerificationCode), VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration, VerificationExpiration: m.VerificationExpiration,
@@ -42,6 +43,7 @@ func UserToModel(a user.User) models.User {
Password: a.Password, Password: a.Password,
PasswordResetCode: DomainToNullableString(a.PasswordResetCode), PasswordResetCode: DomainToNullableString(a.PasswordResetCode),
Email: a.Email, Email: a.Email,
DisplayName: a.DisplayName,
IsVerified: a.IsVerified, IsVerified: a.IsVerified,
VerificationCode: DomainToNullableString(a.VerificationCode), VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration, VerificationExpiration: a.VerificationExpiration,
@@ -0,0 +1,13 @@
package models
import "time"
type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -10,6 +10,7 @@ type User struct {
Password string `gorm:"not null"` Password string `gorm:"not null"`
PasswordResetCode *string `gorm:"unique"` PasswordResetCode *string `gorm:"unique"`
Email string `gorm:"unique;not null"` Email string `gorm:"unique;not null"`
DisplayName string `gorm:"not null;default:''"`
IsVerified bool `gorm:"default=false;not null"` IsVerified bool `gorm:"default=false;not null"`
VerificationCode *string `gorm:"unique"` VerificationCode *string `gorm:"unique"`
VerificationExpiration time.Time VerificationExpiration time.Time
@@ -0,0 +1,66 @@
package persistence
import (
domainInvite "RenewCMS/internal/domain/invite"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
type InviteRepository struct {
db *gorm.DB
}
func NewInviteRepository(db *gorm.DB) *InviteRepository {
return &InviteRepository{db}
}
func (i *InviteRepository) Create(invite domainInvite.Invite) (domainInvite.Invite, error) {
model := mappers.InviteToModel(invite)
model.ID = 0
if err := i.db.Create(&model).Error; err != nil {
return domainInvite.Invite{}, err
}
return mappers.InviteToDomain(model), nil
}
func (i *InviteRepository) GetAll() []domainInvite.Invite {
var invites []entity.Invite
i.db.Model(&entity.Invite{}).Find(&invites)
var domainInvites []domainInvite.Invite
for _, localInvite := range invites {
domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite))
}
return domainInvites
}
func (i *InviteRepository) Delete(id uint32) error {
result := i.db.Delete(&entity.Invite{}, id)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) {
createdModel := mappers.UserToModel(newUser)
err := i.db.Transaction(func(tx *gorm.DB) error {
marked := tx.Model(&entity.Invite{}).
Where("id = ? AND accepted_at IS NULL", inviteID).
Update("accepted_at", acceptedAt)
if err := deleteOrNotFound(marked, domainInvite.ErrInviteNotFound); err != nil {
return err
}
return tx.Create(&createdModel).Error
})
if err != nil {
return domainUser.User{}, err
}
return mappers.UserToDomain(createdModel), nil
}
var _ domainInvite.Repository = &InviteRepository{}
@@ -33,6 +33,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
Password: user.Password, Password: user.Password,
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode), PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email, Email: user.Email,
IsVerified: user.IsVerified,
VerificationCode: mappers.DomainToNullableString(user.VerificationCode), VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration, VerificationExpiration: user.VerificationExpiration,
}) })
@@ -48,7 +49,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
} }
func (u *UserRepository) Delete(id uint32) error { func (u *UserRepository) Delete(id uint32) error {
result := u.db.Delete(&domainUser.User{}, id) result := u.db.Delete(&entity.User{}, id)
return deleteOrNotFound(result, domainUser.ErrUserNotFound) return deleteOrNotFound(result, domainUser.ErrUserNotFound)
} }
@@ -169,4 +170,20 @@ func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (do
return mappers.UserToDomain(localUser), nil return mappers.UserToDomain(localUser), nil
} }
func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (domainUser.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
}
localUser.DisplayName = displayName
err = u.db.Save(&localUser).Error
if err != nil {
return domainUser.User{}, err
}
return mappers.UserToDomain(localUser), nil
}
var _ domainUser.Repository = &UserRepository{} var _ domainUser.Repository = &UserRepository{}
@@ -0,0 +1,86 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"errors"
"strings"
"time"
)
type AcceptInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
}
type AcceptInviteCommand struct {
Code string
Username string
Password string
}
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher) *AcceptInviteUseCase {
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher}
}
func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.User, error) {
now := time.Now()
pending, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now)
if err != nil {
return user.User{}, err
}
if a.emailHasAccount(pending.Email) {
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
return user.User{}, err
}
return user.User{}, invite.ErrEmailHasAccount
}
if err := a.ensureUsernameFree(command.Username); err != nil {
return user.User{}, err
}
hashedPassword, err := a.hasher.Hash(command.Password)
if err != nil {
return user.User{}, err
}
createdUser, err := a.inviteRepository.AcceptWithUser(pending.ID, now, user.User{
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
})
if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid
}
if err != nil {
return user.User{}, err
}
return createdUser, nil
}
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range a.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
return true
}
}
return false
}
func (a *AcceptInviteUseCase) ensureUsernameFree(username string) error {
_, err := a.userRepository.GetByUsername(username)
if err == nil {
return user.ErrUsernameTaken
}
if errors.Is(err, user.ErrUserNotFound) {
return nil
}
return err
}
@@ -0,0 +1,128 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const inviteTemplateName = "invitation"
type CreateInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
}
type CreateInviteCommand struct {
Email string
InvitedBy uint32
}
type MailInviteData struct {
Host string
VerificationCode string
Email string
InvitedBy string
}
func (d MailInviteData) Subject() string {
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
return inviter + " invited you to RenewCMS"
}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL}
}
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
email := strings.ToLower(command.Email)
now := time.Now()
inviter, err := g.userRepository.Get(command.InvitedBy)
if err != nil {
return invite.Invite{}, err
}
if g.emailHasAccount(email) {
return invite.Invite{}, invite.ErrEmailHasAccount
}
if err := g.discardOpenInvite(email, now); err != nil {
return invite.Invite{}, err
}
rawCode := uuid.NewString()
hashedCode, err := g.hasher.Hash(rawCode)
if err != nil {
return invite.Invite{}, err
}
createdInvite, err := g.inviteRepository.Create(invite.Invite{
Email: email,
HashedCode: hashedCode,
ExpiresAt: now.Add(invite.Lifetime),
InvitedBy: command.InvitedBy,
CreatedAt: now,
})
if err != nil {
return invite.Invite{}, err
}
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
Host: g.publicURL,
VerificationCode: rawCode,
Email: email,
InvitedBy: inviterName(inviter),
}); err != nil {
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr)
}
return invite.Invite{}, sendErr
}
return createdInvite, nil
}
func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range g.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
return true
}
}
return false
}
func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error {
for _, existing := range g.inviteRepository.GetAll() {
if existing.Email != email {
continue
}
switch existing.StatusAt(now) {
case invite.StatusPending:
return invite.ErrPendingInvite
case invite.StatusExpired:
if err := g.inviteRepository.Delete(existing.ID); err != nil {
return err
}
}
}
return nil
}
func inviterName(inviter user.User) string {
if inviter.DisplayName != "" {
return inviter.DisplayName
}
return inviter.Username
}
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"time"
)
type GetInviteUseCase struct {
inviteRepository invite.Repository
hasher hash.Hasher
}
func NewGetInviteUseCase(inviteRepository invite.Repository, hasher hash.Hasher) *GetInviteUseCase {
return &GetInviteUseCase{inviteRepository, hasher}
}
func (g *GetInviteUseCase) GetInvite(code string) (invite.Invite, error) {
return findUsableInvite(g.inviteRepository, g.hasher, code, time.Now())
}
func findUsableInvite(inviteRepository invite.Repository, hasher hash.Hasher, code string, now time.Time) (invite.Invite, error) {
if code == "" {
return invite.Invite{}, invite.ErrInviteInvalid
}
for _, candidate := range inviteRepository.GetAll() {
if candidate.AcceptedAt != nil {
continue
}
if hasher.Compare(candidate.HashedCode, code) != nil {
continue
}
if candidate.StatusAt(now) == invite.StatusExpired {
return invite.Invite{}, invite.ErrInviteExpired
}
return candidate, nil
}
return invite.Invite{}, invite.ErrInviteInvalid
}
@@ -0,0 +1,68 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"sort"
"time"
)
const (
EntryKindUser = "user"
EntryKindInvite = "invite"
)
type ListUsersAndInvitesUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
}
type DirectoryEntry struct {
ID uint32
Kind string
Username string
DisplayName string
Email string
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, DirectoryEntry{
ID: existingUser.ID,
Kind: EntryKindUser,
Username: existingUser.Username,
DisplayName: existingUser.DisplayName,
Status: string(invite.StatusAccepted),
CreatedAt: existingUser.CreatedAt,
})
}
for _, existingInvite := range g.inviteRepository.GetAll() {
status := existingInvite.StatusAt(now)
if status == invite.StatusAccepted {
continue
}
entries = append(entries, DirectoryEntry{
ID: existingInvite.ID,
Kind: EntryKindInvite,
Email: existingInvite.Email,
Status: string(status),
CreatedAt: existingInvite.CreatedAt,
})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].CreatedAt.After(entries[j].CreatedAt)
})
return entries
}
@@ -0,0 +1,25 @@
package useCases
import (
"RenewCMS/internal/domain/user"
"strings"
"unicode/utf8"
)
const maxDisplayNameRunes = 60
type UpdateUserProfileUseCase struct {
userRepository user.Repository
}
func NewUpdateUserProfileUseCase(userRepository user.Repository) *UpdateUserProfileUseCase {
return &UpdateUserProfileUseCase{userRepository}
}
func (u *UpdateUserProfileUseCase) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
trimmed := strings.TrimSpace(displayName)
if utf8.RuneCountInString(trimmed) > maxDisplayNameRunes {
return user.User{}, user.ErrInvalidDisplayName
}
return u.userRepository.UpdateDisplayName(userId, trimmed)
}
+307
View File
@@ -0,0 +1,307 @@
package test
import (
"RenewCMS/api/handlers/auth"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const atomicAcceptRuns = 50
type atomicPlainHasher struct{}
func (atomicPlainHasher) Hash(value string) (string, error) {
return "hashed:" + value, nil
}
func (atomicPlainHasher) Compare(hashed string, value string) error {
if hashed == "hashed:"+value {
return nil
}
return fmt.Errorf("hash mismatch")
}
type atomicInviteRepoDecorator struct {
invite.Repository
afterGetAll func()
}
func (d *atomicInviteRepoDecorator) GetAll() []invite.Invite {
all := d.Repository.GetAll()
d.afterGetAll()
return all
}
type atomicLookupBarrier struct {
mu sync.Mutex
arrived int
parties int
open chan struct{}
}
func newAtomicLookupBarrier(parties int) *atomicLookupBarrier {
return &atomicLookupBarrier{parties: parties, open: make(chan struct{})}
}
func (b *atomicLookupBarrier) wait() {
b.mu.Lock()
b.arrived++
if b.arrived == b.parties {
close(b.open)
}
b.mu.Unlock()
select {
case <-b.open:
case <-time.After(5 * time.Second):
}
}
func withLookupBarrier(parties int) func(db *gorm.DB, repo invite.Repository) invite.Repository {
barrier := newAtomicLookupBarrier(parties)
return func(db *gorm.DB, repo invite.Repository) invite.Repository {
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: barrier.wait}
}
}
type atomicAcceptFixture struct {
db *gorm.DB
router *chi.Mux
}
func openRealSqlite(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "atomic.db"))
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Repository) invite.Repository) *atomicAcceptFixture {
t.Helper()
validator.InitValidator()
db := openRealSqlite(t)
users := persistence.NewUserRepository(db)
var invites invite.Repository = persistence.NewInviteRepository(db)
if wrap != nil {
invites = wrap(db, invites)
}
hasher := atomicPlainHasher{}
handler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher),
jwtauth.New("HS256", []byte("test-secret"), nil),
true,
)
router := chi.NewRouter()
router.Post("/v1/auth/invite/accept", handler.AcceptInvite)
seedRealUser(t, db, models.User{Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true})
seedRealInvite(t, db)
return &atomicAcceptFixture{db: db, router: router}
}
func seedRealUser(t *testing.T, db *gorm.DB, u models.User) models.User {
t.Helper()
if err := db.Create(&u).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return u
}
func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite {
t.Helper()
pending := models.Invite{
Email: acceptEmail,
HashedCode: "hashed:" + acceptCode,
ExpiresAt: time.Now().Add(48 * time.Hour),
InvitedBy: 1,
}
if err := db.Create(&pending).Error; err != nil {
t.Fatalf("seed invite: %v", err)
}
return pending
}
func (f *atomicAcceptFixture) accept(username string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/invite/accept", bytes.NewBufferString(acceptBody(acceptCode, username, acceptPassword)))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *atomicAcceptFixture) usersWithEmail(email string) int64 {
var count int64
f.db.Model(&models.User{}).Where("email = ?", email).Count(&count)
return count
}
func (f *atomicAcceptFixture) usersNamed(username string) int64 {
var count int64
f.db.Model(&models.User{}).Where("username = ?", username).Count(&count)
return count
}
func (f *atomicAcceptFixture) storedInvite() models.Invite {
var stored models.Invite
f.db.First(&stored)
return stored
}
func (f *atomicAcceptFixture) acceptConcurrently(usernames ...string) []*httptest.ResponseRecorder {
recorders := make([]*httptest.ResponseRecorder, len(usernames))
start := make(chan struct{})
var wg sync.WaitGroup
for index, username := range usernames {
wg.Add(1)
go func() {
defer wg.Done()
<-start
recorders[index] = f.accept(username)
}()
}
close(start)
wg.Wait()
return recorders
}
func assertOneWinnerOneLoser(t *testing.T, f *atomicAcceptFixture, recorders []*httptest.ResponseRecorder) {
t.Helper()
winners, losers := 0, 0
for _, rec := range recorders {
switch {
case rec.Code == http.StatusOK && jwtCookieOf(rec) != nil:
winners++
case rec.Code == http.StatusNotFound && rec.Body.String() == inviteInvalidBody && jwtCookieOf(rec) == nil:
losers++
default:
t.Errorf("unexpected response %d %q cookie=%v", rec.Code, rec.Body.String(), jwtCookieOf(rec))
}
}
if winners != 1 || losers != 1 {
t.Errorf("expected one winner and one loser, got %d and %d", winners, losers)
}
if count := f.usersWithEmail(acceptEmail); count != 1 {
t.Errorf("expected exactly one user for the invited email, got %d", count)
}
if f.storedInvite().AcceptedAt == nil {
t.Errorf("invite must have accepted_at set")
}
}
func TestAcceptInvitationAtomic(t *testing.T) {
t.Run("Two concurrent accepts of one code create exactly one user", func(t *testing.T) {
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
})
t.Run("The concurrency test is repeatable", func(t *testing.T) {
for run := range atomicAcceptRuns {
t.Run(fmt.Sprintf("run %d", run), func(t *testing.T) {
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
})
}
})
t.Run("A used code loses at the conditional update, not at the earlier lookup", func(t *testing.T) {
f := newAtomicAcceptFixture(t, func(db *gorm.DB, repo invite.Repository) invite.Repository {
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: func() {
db.Exec("UPDATE invites SET accepted_at = ?", time.Now())
}}
})
rec := f.accept("lea")
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
t.Errorf("expected 404 %q, got %d %q", inviteInvalidBody, rec.Code, rec.Body.String())
}
if jwtCookieOf(rec) != nil {
t.Errorf("no jwt cookie may be set")
}
if f.usersNamed("lea") != 0 {
t.Errorf("no user may be inserted for the losing accept")
}
})
t.Run("A failed user insert leaves the invite pending", func(t *testing.T) {
f := newAtomicAcceptFixture(t, nil)
rec := f.accept("marta")
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
t.Errorf("expected 409 %q, got %d %q", usernameTakenBody, rec.Code, rec.Body.String())
}
if f.storedInvite().AcceptedAt != nil {
t.Errorf("invite must stay pending")
}
})
t.Run("A user insert rejected by the database leaves the invite pending", func(t *testing.T) {
f := newAtomicAcceptFixture(t, nil)
seedRealUser(t, f.db, models.User{Username: "leam", Password: "x", Email: "someone@renew.example"})
if err := f.db.Exec("CREATE TRIGGER users_reject_insert BEFORE INSERT ON users WHEN NEW.username = 'lea' BEGIN SELECT RAISE(ABORT, 'insert refused'); END").Error; err != nil {
t.Fatalf("trigger: %v", err)
}
rec := f.accept("lea")
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
t.Errorf("expected 500 without cookie, got %d", rec.Code)
}
if f.storedInvite().AcceptedAt != nil {
t.Errorf("invite must stay pending after the insert failed")
}
if f.usersNamed("lea") != 0 {
t.Errorf("no user lea may exist")
}
})
t.Run("A failed invite update leaves no user behind", func(t *testing.T) {
f := newAtomicAcceptFixture(t, nil)
if err := f.db.Exec("CREATE TRIGGER invites_frozen BEFORE UPDATE ON invites BEGIN SELECT RAISE(ABORT, 'invites are frozen'); END").Error; err != nil {
t.Fatalf("trigger: %v", err)
}
rec := f.accept("lea")
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
t.Errorf("expected 500 without cookie, got %d", rec.Code)
}
if f.usersNamed("lea") != 0 {
t.Errorf("no user lea may exist")
}
if f.storedInvite().AcceptedAt != nil {
t.Errorf("invite must stay pending")
}
})
}
+642
View File
@@ -0,0 +1,642 @@
package test
import (
"RenewCMS/api/handlers/auth"
userhandler "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
acceptCode = "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
acceptEmail = "lea.martin@renew.example"
acceptPassword = "limewash2026"
acceptedInviteMsg = `{"message":"Invite accepted! HTTPonly jwt cookie created"}`
inviteInvalidBody = apperr.InviteInvalidErrorMessage + "\n"
inviteExpiredBody = apperr.InviteExpiredErrorMessage + "\n"
usernameTakenBody = apperr.UsernameTakenErrorMessage + "\n"
emailHasAccountBody = apperr.EmailHasAccountErrorMessage + "\n"
)
type acceptInviteFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
t.Helper()
validator.InitValidator()
f := &acceptInviteFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
f.invites.users = f.users
getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser)
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
registerHandler := auth.NewLoginHandler(
f.token,
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL),
useCases.NewAuthenticateUserUseCase(f.users, f.hasher),
true,
)
f.router = chi.NewRouter()
f.router.Route("/v1/auth", func(r chi.Router) {
r.Post("/register", registerHandler.Register)
r.Get("/registration", registerHandler.RegistrationStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Get("/invite", acceptHandler.GetInvite)
r.Post("/invite/accept", acceptHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", listHandler.ListUsers)
})
return f
}
func (f *acceptInviteFixture) seedWithCode(base invite.Invite, code string) invite.Invite {
base.HashedCode = "hashed:" + code
return f.invites.seed(base)
}
func (f *acceptInviteFixture) seedPending() invite.Invite {
return f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
}
func (f *acceptInviteFixture) do(method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.RemoteAddr = remoteAddr + ":4321"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *acceptInviteFixture) get(target string) *httptest.ResponseRecorder {
return f.do(http.MethodGet, target, "", nil, "203.0.113.1")
}
func (f *acceptInviteFixture) accept(body string) *httptest.ResponseRecorder {
return f.do(http.MethodPost, "/v1/auth/invite/accept", body, nil, "203.0.113.1")
}
func acceptBody(code, username, password string) string {
return `{"code": "` + code + `", "username": "` + username + `", "password": "` + password + `"}`
}
func jwtCookieOf(rec *httptest.ResponseRecorder) *http.Cookie {
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == "jwt" {
return cookie
}
}
return nil
}
func (f *acceptInviteFixture) userNamed(username string) (user.User, bool) {
for _, u := range f.users.users {
if u.Username == username {
return u, true
}
}
return user.User{}, false
}
func assertNoAcceptSideEffects(t *testing.T, f *acceptInviteFixture, rec *httptest.ResponseRecorder, usersBefore int) {
t.Helper()
if len(f.users.users) != usersBefore {
t.Errorf("no user may be created, have %d want %d", len(f.users.users), usersBefore)
}
if jwtCookieOf(rec) != nil {
t.Errorf("no jwt cookie may be set")
}
for _, stored := range f.invites.invites {
if stored.AcceptedAt != nil {
t.Errorf("no invite may be accepted, got %+v", stored)
}
}
}
func TestAcceptInvitationHttp(t *testing.T) {
t.Run("The invited email is read from a valid code", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "email")
if decodedString(t, decoded, "email") != acceptEmail {
t.Errorf("unexpected body %s", rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("expected no-store, got %q", rec.Header().Get("Cache-Control"))
}
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("A link that does not work is reported without saying why", func(t *testing.T) {
cases := []struct {
name, query string
status int
body string
}{
{"unknown code", "?c=not-a-known-code", http.StatusNotFound, inviteInvalidBody},
{"accepted code", "?c=accepted-code", http.StatusNotFound, inviteInvalidBody},
{"accepted and expired code", "?c=accepted-expired-code", http.StatusNotFound, inviteInvalidBody},
{"empty code", "?c=", http.StatusNotFound, inviteInvalidBody},
{"no query", "", http.StatusNotFound, inviteInvalidBody},
{"expired code", "?c=expired-code", http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
acceptedAt := time.Now().Add(-time.Hour)
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
acceptedExpired := expiredInvite("old@renew.example")
acceptedExpired.AcceptedAt = &acceptedAt
f.seedWithCode(acceptedExpired, "accepted-expired-code")
f.seedWithCode(expiredInvite("p.huber@gmail.com"), "expired-code")
rec := f.get("/v1/auth/invite" + tc.query)
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
})
}
})
t.Run("Accepting creates a verified user, logs them in and closes the invite", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedWithCode(pendingInvite("Lea.Martin@Renew.example"), acceptCode)
stored.Email = "lea.martin@renew.example"
f.invites.invites[stored.ID] = stored
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusOK || rec.Body.String() != acceptedInviteMsg {
t.Fatalf("expected 200 message, got %d: %s", rec.Code, rec.Body.String())
}
created, ok := f.userNamed("lea")
if !ok {
t.Fatalf("user lea must exist")
}
if created.Email != acceptEmail || !created.IsVerified || created.VerificationCode != "" || created.DisplayName != "" {
t.Errorf("unexpected user %+v", created)
}
if created.Password == acceptPassword || created.Password != "hashed:"+acceptPassword {
t.Errorf("password must be stored hashed, got %q", created.Password)
}
if got := f.invites.invites[stored.ID]; got.AcceptedAt == nil || got.StatusAt(time.Now()) != invite.StatusAccepted {
t.Errorf("invite must be accepted, got %+v", got)
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
cookie := jwtCookieOf(rec)
if cookie == nil || !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
t.Fatalf("expected an HttpOnly Secure jwt cookie on /, got %+v", cookie)
}
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
}
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims, _ := decoded.AsMap(context.Background())
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
t.Errorf("unexpected claims %v", claims)
}
})
t.Run("The new account works like any other account", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
cookie := jwtCookieOf(f.accept(acceptBody(acceptCode, "lea", acceptPassword)))
me := f.do(http.MethodGet, "/v1/auth/me", "", cookie, "203.0.113.1")
list := f.do(http.MethodGet, "/v1/user/", "", cookie, "203.0.113.1")
if me.Code != http.StatusOK || list.Code != http.StatusOK {
t.Fatalf("expected 200 and 200, got %d and %d", me.Code, list.Code)
}
items := decodeList(t, list.Body.Bytes())
var leaStatus string
for _, item := range items {
if decodedString(t, item, "kind") == "invite" {
t.Errorf("no invite may be listed: %v", item)
}
if decodedString(t, item, "kind") == "user" && decodedString(t, item, "username") == "lea" {
leaStatus = decodedString(t, item, "status")
}
}
if leaStatus != "accepted" {
t.Errorf("expected lea listed as accepted, got %q", leaStatus)
}
})
t.Run("An accepted code cannot be used again", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.accept(acceptBody(acceptCode, "lea", acceptPassword))
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea2", acceptPassword))
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
t.Errorf("expected 404, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.users.users) != usersBefore || jwtCookieOf(rec) != nil {
t.Errorf("nothing may be created")
}
if _, exists := f.userNamed("lea2"); exists {
t.Errorf("lea2 must not exist")
}
if got := f.get("/v1/auth/invite?c=" + acceptCode); got.Code != http.StatusNotFound {
t.Errorf("expected 404 on GET, got %d", got.Code)
}
})
t.Run("A code that cannot be used creates nothing", func(t *testing.T) {
cases := []struct {
name string
seed func(f *acceptInviteFixture)
code string
status int
body string
}{
{"no invite has that code", func(f *acceptInviteFixture) { f.seedPending() }, "unknown-code", http.StatusNotFound, inviteInvalidBody},
{"an invite that expired an hour ago", func(f *acceptInviteFixture) { f.seedWithCode(expiredInvite(acceptEmail), acceptCode) }, acceptCode, http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
tc.seed(f)
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(tc.code, "lea", acceptPassword))
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
}
})
t.Run("A taken username is refused and the invite stays usable", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "marta", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if retry := f.accept(acceptBody(acceptCode, "lea", acceptPassword)); retry.Code != http.StatusOK {
t.Errorf("expected 200 on retry, got %d: %s", retry.Code, retry.Body.String())
}
})
t.Run("An address that got an account in the meantime is refused", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.users.users[8] = user.User{ID: 8, Username: "leam", Email: "Lea.Martin@Renew.example"}
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != emailHasAccountBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
t.Run("An invalid body is rejected before the code is looked up", func(t *testing.T) {
bodies := []string{
acceptBody(acceptCode, "le", acceptPassword),
acceptBody(acceptCode, "abcdefghijklmnopqrstu", acceptPassword),
acceptBody(acceptCode, "lea", "lime"),
`{"code": "` + acceptCode + `", "password": "limewash2026"}`,
`{"username": "lea", "password": "limewash2026"}`,
`not json`,
}
for _, body := range bodies {
t.Run(body, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(body)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.hasher.compareCalls != 0 {
t.Errorf("the code must not be looked up")
}
})
}
})
t.Run("An existing jwt cookie is ignored and replaced", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
martaCookie := displayNameCookie(t, f.token, 7, 0)
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), martaCookie, "203.0.113.1")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
created, _ := f.userNamed("lea")
cookie := jwtCookieOf(rec)
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims, _ := decoded.AsMap(context.Background())
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
}
})
t.Run("A failure while accepting leaves the invite pending and no user behind", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
f.invites.fail.failNext("MarkAccepted")
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("The accept endpoints are rate-limited", func(t *testing.T) {
f := newAcceptInviteFixture(t, 2)
ip := "203.0.113.30"
for range 2 {
f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
}
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("expected 429 with Retry-After, got %d: %q", rec.Code, rec.Body.String())
}
if other := f.do(http.MethodGet, "/v1/auth/registration", "", nil, ip); other.Code == http.StatusTooManyRequests {
t.Errorf("registration status must not be limited")
}
})
t.Run("Registration stays closed for everyone else", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
acceptedAt := time.Now()
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
rec := f.do(http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam@renew.example"}`, nil, "203.0.113.1")
status := f.do(http.MethodGet, "/v1/auth/registration", "", nil, "203.0.113.1")
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
}
if strings.ReplaceAll(status.Body.String(), " ", "") != `{"open":false}` {
t.Errorf("expected registration closed, got %s", status.Body.String())
}
})
t.Run("The invite response never leaks the code or other invite data", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
decoded := decodeKeys(t, rec.Body.Bytes())
for _, forbidden := range []string{"code", "hashed_code", "invited_by", "expires_at", "id", "accepted_at"} {
if _, present := decoded[forbidden]; present {
t.Errorf("forbidden key %q in body", forbidden)
}
if strings.Contains(rec.Body.String(), `"`+forbidden+`"`) {
t.Errorf("forbidden name %q in raw body %s", forbidden, rec.Body.String())
}
}
if strings.Contains(rec.Body.String(), acceptCode) {
t.Errorf("raw code leaked in body %s", rec.Body.String())
}
})
}
func TestAcceptInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
}
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
t.Run("unknown code is invalid", func(t *testing.T) {
uc, _, _, _ := newUseCase()
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("empty code is invalid without hashing", func(t *testing.T) {
uc, _, invites, hasher := newUseCase()
invites.seed(pendingInvite(acceptEmail))
if _, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("expired code is expired", func(t *testing.T) {
uc, _, invites, _ := newUseCase()
stored := expiredInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteExpired) {
t.Errorf("expected ErrInviteExpired, got %v", err)
}
})
t.Run("taken username", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
_, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
if !errors.Is(err, user.ErrUsernameTaken) || len(users.users) != 1 {
t.Errorf("expected ErrUsernameTaken, got %v", err)
}
})
t.Run("username lookup failure is returned", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.fail.failNext("GetByUsername")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
})
t.Run("email that has an account", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.users[8] = user.User{ID: 8, Username: "leam", Email: "LEA.martin@renew.example"}
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
t.Run("an address that got an account because the invite was just accepted is invalid", func(t *testing.T) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
racing := &atomicInviteRepoDecorator{Repository: invites, afterGetAll: func() {
acceptedAt := time.Now()
seeded.AcceptedAt = &acceptedAt
invites.invites[seeded.ID] = seeded
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
}}
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("hash failure creates nothing", func(t *testing.T) {
uc, users, invites, hasher := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
hasher.hashErr = errors.New("hash boom")
if _, err := uc.AcceptInvite(command); err == nil || len(users.users) != 1 {
t.Errorf("expected failure without a user, got %v", err)
}
})
t.Run("user creation failure leaves the invite pending", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
users.fail.failNext("Create")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected repository failure and a pending invite, got %v", err)
}
})
t.Run("accept failure leaves no user and no delete call", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
users.fail.failNextWith("Delete", errors.New("delete down"))
_, err := uc.AcceptInvite(command)
if err == nil || !strings.Contains(err.Error(), "mark down") || strings.Contains(err.Error(), "delete down") {
t.Errorf("unexpected error %v", err)
}
if len(users.users) != 1 || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected no user and a pending invite")
}
})
t.Run("an invite that was taken meanwhile is invalid", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
}
func TestGetInviteUseCase(t *testing.T) {
t.Run("returns the usable invite", func(t *testing.T) {
invites, hasher := newInviteFakeRepo(), &stubHasher{}
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
got, err := useCases.NewGetInviteUseCase(invites, hasher).GetInvite(acceptCode)
if err != nil || got.Email != acceptEmail {
t.Errorf("unexpected result %+v, %v", got, err)
}
})
}
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -81,7 +82,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
if got := rec.Header().Get("Cache-Control"); got != "no-store" { if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("expected Cache-Control no-store, got %q", got) t.Errorf("expected Cache-Control no-store, got %q", got)
} }
var body auth.PublicUser var body publicuser.PublicUser
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal body: %v", err) t.Fatalf("failed to unmarshal body: %v", err)
} }
@@ -111,7 +112,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
if got := rec.Header().Get("Cache-Control"); got != "no-store" { if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("expected Cache-Control no-store, got %q", got) t.Errorf("expected Cache-Control no-store, got %q", got)
} }
var body auth.PublicUser var body publicuser.PublicUser
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal body: %v", err) t.Fatalf("failed to unmarshal body: %v", err)
} }
+114
View File
@@ -0,0 +1,114 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestInvitationsQaMailer(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
t.Run("The invitation renders with the inviter, the address and the accept link", func(t *testing.T) {
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "lea@example.com", InvitedBy: "Ines Duarte"}
subject, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if subject != "Ines Duarte invited you to RenewCMS" {
t.Errorf("unexpected subject %q", subject)
}
for _, want := range []string{invitePublicURL + "/invite/accept?c=abc-123", "lea@example.com", "Ines Duarte"} {
if !strings.Contains(body, want) {
t.Errorf("body misses %q", want)
}
}
})
t.Run("The inviter name is HTML-escaped in the body", func(t *testing.T) {
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "lea@example.com", InvitedBy: "<b>x</b>"}
_, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if strings.Contains(body, "<b>x</b>") || !strings.Contains(body, "&lt;b&gt;x&lt;/b&gt;") {
t.Errorf("inviter name was not escaped")
}
})
t.Run("Other subjects are unchanged", func(t *testing.T) {
reset, _, err := repo.Render("password-reset", useCases.MailPasswordResetData{Host: invitePublicURL, VerificationCode: "x", Email: "a@b.example"})
if err != nil || reset != "Reset your RenewCMS password" {
t.Errorf("unexpected reset subject %q, %v", reset, err)
}
verification, _, err := repo.Render("verification", useCases.MailValidationData{Host: invitePublicURL, VerificationCode: "x"})
if err != nil || verification != "Verify your RenewCMS account" {
t.Errorf("unexpected verification subject %q, %v", verification, err)
}
})
t.Run("The shipped template is byte-identical to the design export", func(t *testing.T) {
shipped, err := os.ReadFile("../internal/infrastructure/mailer/templates/invitation.html")
if err != nil {
t.Fatal(err)
}
design, err := os.ReadFile("../docs/design/source/emails/invitation.html")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(shipped, design) {
t.Errorf("template differs from docs/design/source/emails/invitation.html")
}
})
}
func TestInvitationsQaList(t *testing.T) {
t.Run("Invite status follows its age", func(t *testing.T) {
f := newInviteFixture(t, 100)
now := time.Now()
f.invites.seed(inviteAt("old@renew.example", now.Add(-72*time.Hour-time.Minute), now.Add(-time.Minute)))
f.invites.seed(inviteAt("fresh@renew.example", now.Add(-48*time.Hour), now.Add(24*time.Hour)))
acceptedAt := now
accepted := inviteAt("done@renew.example", now.Add(-time.Hour), now.Add(71*time.Hour))
accepted.AcceptedAt = &acceptedAt
f.invites.seed(accepted)
rec := f.list(t)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
statuses := map[string]string{}
for _, item := range decodeList(t, rec.Body.Bytes()) {
if decodedString(t, item, "kind") == "invite" {
statuses[decodedString(t, item, "email")] = decodedString(t, item, "status")
}
}
if len(statuses) != 2 || statuses["old@renew.example"] != "expired" || statuses["fresh@renew.example"] != "pending" {
t.Errorf("unexpected statuses %v", statuses)
}
})
t.Run("The list never carries secrets or user emails", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.seed(pendingInvite("sam@renew.example"))
body := f.list(t).Body.String()
for _, forbidden := range []string{"\"code\"", "hashed_code", "verification_code", "password", "marta@renew.example"} {
if strings.Contains(body, forbidden) {
t.Errorf("list leaks %q", forbidden)
}
}
})
}
+733
View File
@@ -0,0 +1,733 @@
package test
import (
"RenewCMS/api/handlers/auth"
userhandler "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const invitePublicURL = "https://cms.example.com"
type inviteFakeRepo struct {
invites map[uint32]invite.Invite
nextID uint32
fail failSwitch
users *mockUserRepo
}
func newInviteFakeRepo() *inviteFakeRepo {
return &inviteFakeRepo{invites: make(map[uint32]invite.Invite)}
}
func (m *inviteFakeRepo) seed(i invite.Invite) invite.Invite {
m.nextID++
i.ID = m.nextID
m.invites[i.ID] = i
return i
}
func (m *inviteFakeRepo) Create(i invite.Invite) (invite.Invite, error) {
if err := m.fail.trigger("Create"); err != nil {
return invite.Invite{}, err
}
return m.seed(i), nil
}
func (m *inviteFakeRepo) GetAll() []invite.Invite {
ids := make([]uint32, 0, len(m.invites))
for id := range m.invites {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
result := make([]invite.Invite, 0, len(ids))
for _, id := range ids {
result = append(result, m.invites[id])
}
return result
}
func (m *inviteFakeRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.invites[id]; !ok {
return invite.ErrInviteNotFound
}
delete(m.invites, id)
return nil
}
func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) {
if err := m.fail.trigger("MarkAccepted"); err != nil {
return user.User{}, err
}
stored, ok := m.invites[id]
if !ok || stored.AcceptedAt != nil {
return user.User{}, invite.ErrInviteNotFound
}
created, err := m.users.Create(newUser)
if err != nil {
return user.User{}, err
}
stored.AcceptedAt = &at
m.invites[id] = stored
return created, nil
}
var _ invite.Repository = &inviteFakeRepo{}
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt}
}
func pendingInvite(email string) invite.Invite {
now := time.Now()
return inviteAt(email, now.Add(-24*time.Hour), now.Add(48*time.Hour))
}
func expiredInvite(email string) invite.Invite {
now := time.Now()
return inviteAt(email, now.Add(-73*time.Hour), now.Add(-time.Hour))
}
func inviteInviterRepo(displayName string) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true}
repo.nextID = 7
return repo
}
type inviteFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
t.Helper()
validator.InitValidator()
f := &inviteFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", handler.CreateInvite)
})
})
return f
}
func (f *inviteFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.RemoteAddr = remoteAddr + ":4321"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *inviteFixture) post(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return f.do(t, http.MethodPost, "/v1/user/invite", body, displayNameCookie(t, f.token, 7, 0), "203.0.113.1")
}
func (f *inviteFixture) list(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.do(t, http.MethodGet, "/v1/user/", "", displayNameCookie(t, f.token, 7, 0), "203.0.113.1")
}
func assertExactKeys(t *testing.T, raw map[string]json.RawMessage, keys ...string) {
t.Helper()
if len(raw) != len(keys) {
t.Errorf("expected exactly keys %v, got %d keys", keys, len(raw))
}
for _, key := range keys {
if _, ok := raw[key]; !ok {
t.Errorf("missing key %q", key)
}
}
}
func decodeList(t *testing.T, body []byte) []map[string]json.RawMessage {
t.Helper()
var items []map[string]json.RawMessage
if err := json.Unmarshal(body, &items); err != nil {
t.Fatalf("failed to decode list %q: %v", string(body), err)
}
return items
}
func inviteMailData(t *testing.T, m *mockMailRepo) useCases.MailInviteData {
t.Helper()
if len(m.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(m.SentEmails))
}
data, ok := m.SentEmails[0].Data.(useCases.MailInviteData)
if !ok {
t.Fatalf("unexpected mail data %T", m.SentEmails[0].Data)
}
return data
}
func TestInvitationsHttp(t *testing.T) {
t.Run("A verified user invites a new address", func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, `{"email": "Sam.Oliveira@Renew.example"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at")
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
if len(f.invites.invites) != 1 {
t.Fatalf("expected one stored invite, got %d", len(f.invites.invites))
}
stored := f.invites.invites[1]
data := inviteMailData(t, f.mail)
if stored.Email != "sam.oliveira@renew.example" || stored.InvitedBy != 7 || stored.AcceptedAt != nil {
t.Errorf("unexpected stored invite: %+v", stored)
}
if stored.ExpiresAt.Sub(stored.CreatedAt) != 3*24*time.Hour {
t.Errorf("expected 3 day lifetime, got %v", stored.ExpiresAt.Sub(stored.CreatedAt))
}
if stored.HashedCode == data.VerificationCode || stored.HashedCode != "hashed:"+data.VerificationCode {
t.Errorf("stored code must be the hash of the raw code: %q vs %q", stored.HashedCode, data.VerificationCode)
}
if f.mail.SentEmails[0].Address != "sam.oliveira@renew.example" || f.mail.SentEmails[0].Template != "invitation" {
t.Errorf("unexpected mail: %+v", f.mail.SentEmails[0])
}
if data.Host != invitePublicURL || data.Email != "sam.oliveira@renew.example" || data.InvitedBy != "marta" || data.VerificationCode == "" {
t.Errorf("unexpected mail data: %+v", data)
}
for _, forbidden := range []string{"code", "hashed_code", "verification_code", "password"} {
if _, present := decoded[forbidden]; present {
t.Errorf("forbidden key %q in body", forbidden)
}
}
})
t.Run("The email names the inviter by display name when there is one", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true}
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
if got := inviteMailData(t, f.mail).InvitedBy; got != "Ines Duarte" {
t.Errorf("expected display name, got %q", got)
}
})
invalidBodies := []string{`{"email": "sam.oliveira@renew"}`, `{"email": ""}`, `{}`, `{"email": "not-an-email"}`, `not json`}
for _, body := range invalidBodies {
t.Run("An invalid email is rejected before anything is stored or sent: "+body, func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be stored or sent")
}
})
}
t.Run("An address that already has an account is refused", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[8] = user.User{ID: 8, Username: "jonas", Email: "Jonas@Renew.example"}
rec := f.post(t, `{"email": "jonas@renew.example"}`)
if rec.Code != http.StatusConflict || rec.Body.String() != apperr.EmailHasAccountErrorMessage+"\n" {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be stored or sent")
}
})
t.Run("An address with a live pending invite is refused", func(t *testing.T) {
f := newInviteFixture(t, 100)
original := f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := f.post(t, `{"email": "sam.oliveira@renew.example"}`)
if rec.Code != http.StatusConflict || rec.Body.String() != apperr.PendingInviteErrorMessage+"\n" {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 1 || f.invites.invites[original.ID] != original {
t.Errorf("original invite must be unchanged")
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
})
t.Run("An expired invite is replaced by a new one", func(t *testing.T) {
f := newInviteFixture(t, 100)
old := f.invites.seed(expiredInvite("p.huber@gmail.com"))
rec := f.post(t, `{"email": "p.huber@gmail.com"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 1 {
t.Fatalf("expected exactly one invite, got %d", len(f.invites.invites))
}
for _, stored := range f.invites.invites {
if stored.ID == old.ID || stored.HashedCode == old.HashedCode || stored.StatusAt(time.Now()) != invite.StatusPending {
t.Errorf("expected a new pending invite, got %+v", stored)
}
}
if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Address != "p.huber@gmail.com" {
t.Errorf("expected exactly one email to p.huber@gmail.com")
}
})
t.Run("A failed email removes the invite", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.mail.fail.failNext("Send")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" {
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 {
t.Errorf("invite must be removed")
}
if again := f.post(t, `{"email": "lea.martin@renew.example"}`); again.Code != http.StatusCreated {
t.Errorf("expected 201 on retry, got %d", again.Code)
}
})
t.Run("A failed email does not restore a replaced expired invite", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.seed(expiredInvite("p.huber@gmail.com"))
f.mail.fail.failNext("Send")
rec := f.post(t, `{"email": "p.huber@gmail.com"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
if len(f.invites.invites) != 0 {
t.Errorf("expected no invite for p.huber@gmail.com")
}
})
t.Run("A failed cleanup after a failed email is still reported as 500", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.mail.fail.failNext("Send")
f.invites.fail.failNext("Delete")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" {
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
}
})
t.Run("A storage failure answers a generic 500 and sends nothing", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.fail.failNext("Create")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("expected generic 500, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
})
t.Run("Only an authenticated, verified, current-token user can use the endpoints", func(t *testing.T) {
cases := []struct {
name, method, path string
cookie func(f *inviteFixture) *http.Cookie
status int
}{
{"POST with no jwt cookie", http.MethodPost, "/v1/user/invite", func(*inviteFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"POST unverified user", http.MethodPost, "/v1/user/invite", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 8, 0) }, http.StatusForbidden},
{"POST stale token_version", http.MethodPost, "/v1/user/invite", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 7, 5) }, http.StatusUnauthorized},
{"GET with no jwt cookie", http.MethodGet, "/v1/user/", func(*inviteFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"GET unverified user", http.MethodGet, "/v1/user/", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 8, 0) }, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
rec := f.do(t, tc.method, tc.path, `{"email": "lea.martin@renew.example"}`, tc.cookie(f), "203.0.113.1")
if rec.Code != tc.status {
t.Errorf("expected %d, got %d", tc.status, rec.Code)
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be created or sent")
}
})
}
})
t.Run("The invite endpoint is rate-limited", func(t *testing.T) {
f := newInviteFixture(t, 2)
cookie := displayNameCookie(t, f.token, 7, 0)
ip := "203.0.113.20"
for _, email := range []string{"a@renew.example", "b@renew.example"} {
if rec := f.do(t, http.MethodPost, "/v1/user/invite", `{"email": "`+email+`"}`, cookie, ip); rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
}
rec := f.do(t, http.MethodPost, "/v1/user/invite", `{"email": "c@renew.example"}`, cookie, ip)
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("expected 429 with Retry-After, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 2 || len(f.mail.SentEmails) != 2 {
t.Errorf("the 3rd request must not create or send")
}
if got := f.do(t, http.MethodGet, "/v1/user/", "", cookie, ip); got.Code != http.StatusOK {
t.Errorf("GET must not be limited, got %d", got.Code)
}
})
t.Run("The list shows users and open invites, newest first", func(t *testing.T) {
f := newInviteFixture(t, 100)
day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 9, 0, 0, 0, time.UTC) }
f.users.users[7] = user.User{ID: 7, Username: "marta", Email: "marta@renew.example", IsVerified: true, Password: "x", CreatedAt: day(2026, 3, 12)}
f.users.users[8] = user.User{ID: 8, Username: "jonas", Email: "jonas@renew.example", CreatedAt: day(2026, 4, 2)}
f.users.users[9] = user.User{ID: 9, Username: "ines", DisplayName: "Ines Duarte", CreatedAt: day(2026, 6, 18)}
f.invites.seed(inviteAt("p.huber@gmail.com", day(2026, 8, 3), day(2026, 8, 6)))
f.invites.seed(inviteAt("sam.oliveira@renew.example", time.Now().Add(-time.Hour), time.Now().Add(71*time.Hour)))
acceptedAt := day(2026, 4, 1)
accepted := inviteAt("jonas@renew.example", day(2026, 4, 1), day(2026, 4, 4))
accepted.AcceptedAt = &acceptedAt
f.invites.seed(accepted)
rec := f.list(t)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
items := decodeList(t, rec.Body.Bytes())
if len(items) != 5 {
t.Fatalf("expected 5 items, got %d: %s", len(items), rec.Body.String())
}
wantOrder := []struct{ kind, key, value, status string }{
{"invite", "email", "sam.oliveira@renew.example", "pending"},
{"invite", "email", "p.huber@gmail.com", "expired"},
{"user", "username", "ines", "accepted"},
{"user", "username", "jonas", "accepted"},
{"user", "username", "marta", "accepted"},
}
for index, want := range wantOrder {
item := items[index]
if decodedString(t, item, "kind") != want.kind || decodedString(t, item, want.key) != want.value || decodedString(t, item, "status") != want.status {
t.Errorf("item %d unexpected: %v", index, item)
}
if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
} else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "status", "created_at")
}
}
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
t.Errorf("expected display name for ines")
}
if strings.Contains(rec.Body.String(), "jonas@renew.example") || strings.Contains(rec.Body.String(), "marta@renew.example") {
t.Errorf("user emails and accepted invites must not be listed")
}
})
t.Run("With nobody invited the list contains only the registered user", func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.list(t)
items := decodeList(t, rec.Body.Bytes())
if rec.Code != http.StatusOK || len(items) != 1 || decodedString(t, items[0], "kind") != "user" || decodedString(t, items[0], "username") != "marta" || decodedString(t, items[0], "status") != "accepted" {
t.Errorf("unexpected list: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("An invite turns from pending to expired with time, without any stored status", func(t *testing.T) {
f := newInviteFixture(t, 100)
now := time.Now()
f.invites.seed(inviteAt("late@renew.example", now.Add(-invite.Lifetime-time.Minute), now.Add(-time.Minute)))
items := decodeList(t, f.list(t).Body.Bytes())
if decodedString(t, items[0], "status") != "expired" {
t.Errorf("expected expired, got %v", items[0])
}
})
}
func TestInvitationsDoNotChangeRegistration(t *testing.T) {
validator.InitValidator()
newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux {
hasher := &stubHasher{}
handler := auth.NewLoginHandler(
jwtauth.New("HS256", []byte("test-secret"), nil),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL),
useCases.NewAuthenticateUserUseCase(users, hasher),
true,
)
r := chi.NewRouter()
r.Post("/v1/auth/register", handler.Register)
r.Get("/v1/auth/registration", handler.RegistrationStatus)
return r
}
send := func(r *chi.Mux, method, target, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(method, target, bytes.NewBufferString(body)))
return rec
}
t.Run("Registration stays closed for uninvited people", func(t *testing.T) {
users, mail := inviteInviterRepo(""), newMockMailRepo()
invites := newInviteFakeRepo()
invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := send(newRegistration(users, mail), http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam.oliveira@renew.example"}`)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
}
if len(users.users) != 1 || len(mail.SentEmails) != 0 {
t.Errorf("no user may be created and no email sent")
}
})
t.Run("Registration on an empty instance is unchanged", func(t *testing.T) {
users, mail := newMockUserRepo(), newMockMailRepo()
invites := newInviteFakeRepo()
rec := send(newRegistration(users, mail), http.MethodPost, "/v1/auth/register", `{"username":"bob","password":"hunter2example12","email":"bob@example.com"}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if len(mail.SentEmails) != 1 || mail.SentEmails[0].Template != "verification" || len(invites.invites) != 0 {
t.Errorf("expected one verification email and no invite")
}
})
t.Run("Inviting someone never changes GET /v1/auth/registration", func(t *testing.T) {
users := inviteInviterRepo("")
invites := newInviteFakeRepo()
invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := send(newRegistration(users, newMockMailRepo()), http.MethodGet, "/v1/auth/registration", "")
var status map[string]bool
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
}
open, present := status["open"]
if rec.Code != http.StatusOK || len(status) != 1 || !present || open {
t.Errorf("expected registration reported closed, got %d: %s", rec.Code, rec.Body.String())
}
})
}
func TestCreateInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL), users, invites, mail, hasher
}
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
uc, _, _, _, _ := newUseCase()
got, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
if err != nil || got.Email != "a@b.example" || got.ID == 0 {
t.Fatalf("unexpected result %+v, %v", got, err)
}
})
t.Run("unknown inviter", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
})
t.Run("existing account is refused case-insensitively", func(t *testing.T) {
uc, users, _, _, _ := newUseCase()
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
t.Run("pending invite is refused", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
invites.seed(pendingInvite("a@b.example"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrPendingInvite) {
t.Errorf("expected ErrPendingInvite, got %v", err)
}
})
t.Run("accepted invites and other addresses do not block", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
acceptedAt := time.Now()
accepted := pendingInvite("a@b.example")
accepted.AcceptedAt = &acceptedAt
invites.seed(accepted)
invites.seed(pendingInvite("other@b.example"))
if _, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
t.Errorf("unexpected error %v", err)
}
if len(invites.invites) != 3 {
t.Errorf("expected accepted and other invites to remain, got %d", len(invites.invites))
}
})
t.Run("hash failure stores and sends nothing", func(t *testing.T) {
uc, _, invites, mail, hasher := newUseCase()
hasher.hashErr = errors.New("hash boom")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
t.Errorf("expected failure without side effects, got %v", err)
}
})
t.Run("expired invite deletion failure aborts", func(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
invites.seed(expiredInvite("a@b.example"))
invites.fail.failNext("Delete")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
t.Errorf("expected repository failure, got %v", err)
}
})
t.Run("mail failure joins the cleanup failure", func(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
mail.fail.failNextWith("Send", errors.New("smtp down"))
invites.fail.failNextWith("Delete", errors.New("cleanup down"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
t.Errorf("unexpected error %v", err)
}
})
}
func TestInviteStatusAt(t *testing.T) {
now := time.Now()
accepted := now
cases := []struct {
name string
in invite.Invite
want invite.Status
}{
{"pending", invite.Invite{ExpiresAt: now.Add(time.Minute)}, invite.StatusPending},
{"expired", invite.Invite{ExpiresAt: now.Add(-time.Minute)}, invite.StatusExpired},
{"expiring exactly now is expired", invite.Invite{ExpiresAt: now}, invite.StatusExpired},
{"accepted wins over expiry", invite.Invite{ExpiresAt: now.Add(-time.Minute), AcceptedAt: &accepted}, invite.StatusAccepted},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.in.StatusAt(now); got != tc.want {
t.Errorf("expected %s, got %s", tc.want, got)
}
})
}
}
func TestInvitationEmailRendering(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "sam@renew.example", InvitedBy: "Ines\nDuarte"}
t.Run("subject is built from the inviter and the link carries the raw code", func(t *testing.T) {
subject, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if subject != "Ines Duarte invited you to RenewCMS" {
t.Errorf("unexpected subject %q", subject)
}
if !strings.Contains(body, invitePublicURL+"/invite/accept?c=abc-123") || !strings.Contains(body, "sam@renew.example") {
t.Errorf("unexpected body")
}
})
t.Run("other templates keep their subjects", func(t *testing.T) {
subject, _, _ := repo.Render("verification", useCases.MailValidationData{Host: invitePublicURL, VerificationCode: "x"})
if subject != "Verify your RenewCMS account" {
t.Errorf("unexpected subject %q", subject)
}
})
}
func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, `{"email": "x@renew.example"}`)
for _, forbidden := range []string{"hashed", "invited_by", "accepted_at", "token_version"} {
if strings.Contains(rec.Body.String(), forbidden) {
t.Errorf("body leaks %q", forbidden)
}
}
}
func TestListUsersAndInvitesUseCase(t *testing.T) {
t.Run("empty repositories give an empty non-nil list", func(t *testing.T) {
got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites()
if got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil slice, got %#v", got)
}
})
}
+59
View File
@@ -0,0 +1,59 @@
package test
import (
"encoding/json"
"net/http"
"os"
"testing"
)
type inviteEmailCase struct {
Email string `json:"email"`
Valid bool `json:"valid"`
}
func loadInviteEmailCases(t *testing.T) []inviteEmailCase {
t.Helper()
raw, err := os.ReadFile("testdata/invite_emails.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var cases []inviteEmailCase
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatalf("decode fixture: %v", err)
}
return cases
}
func TestInviteEmailValidationHttp(t *testing.T) {
t.Run("The same address gets the same verdict on both sides", func(t *testing.T) {
for _, tc := range loadInviteEmailCases(t) {
t.Run("["+tc.Email+"]", func(t *testing.T) {
f := newInviteFixture(t, 100)
body, _ := json.Marshal(map[string]string{"email": tc.Email})
rec := f.post(t, string(body))
if tc.Valid && rec.Code != http.StatusCreated {
t.Errorf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
if !tc.Valid && rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("A rejected address stores nothing and sends nothing", func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, `{"email": "user@localhost"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be stored or sent, got %d invites and %d emails", len(f.invites.invites), len(f.mail.SentEmails))
}
})
}
@@ -0,0 +1,114 @@
package test
import (
"RenewCMS/api/handlers/auth"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"log"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
type resetMailFailureHasher struct{}
func (resetMailFailureHasher) Hash(value string) (string, error) {
return "hash-of-" + value, nil
}
func (resetMailFailureHasher) Compare(hashed string, value string) error {
return nil
}
func captureStandardLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buffer bytes.Buffer
previousOutput, previousFlags := log.Writer(), log.Flags()
log.SetOutput(&buffer)
t.Cleanup(func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
})
return &buffer
}
func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseRecorder, string) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
hasher := resetMailFailureHasher{}
handler := auth.NewPasswordResetHandler(
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
)
router := chi.NewRouter()
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(`{"email": "`+email+`"}`))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder, userRepo.lastUpdatePasswordResetCodeCode
}
func TestRequestPasswordResetMailFailure(t *testing.T) {
validator.InitValidator()
sendFailure := errors.New("smtp: connection refused")
failingMail := func() *mockMailRepo {
mailRepo := newMockMailRepo()
mailRepo.fail.failNextWith("Send", sendFailure)
return mailRepo
}
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
logged := captureStandardLog(t)
recorder, _ := resetRequestWith(failingMail(), "alice@example.com")
if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
t.Errorf("expected the generic 200, got %d %s", recorder.Code, recorder.Body.String())
}
if jwtCookieOf(recorder) != nil {
t.Errorf("no jwt cookie may be set")
}
line := logged.String()
if !strings.Contains(line, "Send") || !strings.Contains(line, "smtp: connection refused") {
t.Errorf("log must name the failed send and its error, got %q", line)
}
})
t.Run("The response is identical for a failed send, a successful send and an unknown email", func(t *testing.T) {
captureStandardLog(t)
failed, _ := resetRequestWith(failingMail(), "alice@example.com")
sent, _ := resetRequestWith(newMockMailRepo(), "alice@example.com")
unknown, _ := resetRequestWith(newMockMailRepo(), "nobody@example.com")
for name, other := range map[string]*httptest.ResponseRecorder{"successful send": sent, "unknown email": unknown} {
if failed.Code != other.Code || failed.Body.String() != other.Body.String() {
t.Errorf("%s differs: %d %q vs %d %q", name, failed.Code, failed.Body.String(), other.Code, other.Body.String())
}
if !reflect.DeepEqual(failed.Header(), other.Header()) {
t.Errorf("%s headers differ: %v vs %v", name, failed.Header(), other.Header())
}
}
})
t.Run("The log does not contain the raw reset code", func(t *testing.T) {
logged := captureStandardLog(t)
_, storedHash := resetRequestWith(failingMail(), "alice@example.com")
rawCode := strings.TrimPrefix(storedHash, "hash-of-")
if rawCode == "" || strings.Contains(logged.String(), rawCode) || strings.Contains(logged.String(), storedHash) {
t.Errorf("log must contain neither the raw code nor its hash, got %q", logged.String())
}
})
}
+49
View File
@@ -0,0 +1,49 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/infrastructure/persistence/models"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) {
t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "busy.db")
open := func() *gorm.DB {
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
first, second := open(), open()
if err := first.AutoMigrate(&models.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
tx := first.Begin()
if err := tx.Create(&models.User{Username: "a", Password: "x", Email: "a@x.example"}).Error; err != nil {
t.Fatalf("first write: %v", err)
}
go func() {
time.Sleep(300 * time.Millisecond)
tx.Commit()
}()
err := second.Create(&models.User{Username: "b", Password: "x", Email: "b@x.example"}).Error
if err != nil {
t.Errorf("second writer must wait for the lock, got %v", err)
}
})
}
+17
View File
@@ -0,0 +1,17 @@
[
{ "email": "sam.oliveira@renew.example", "valid": true },
{ "email": "Sam.Oliveira@Renew.example", "valid": true },
{ "email": "sam+news@renew.example", "valid": true },
{ "email": "sam@mail.renew.example", "valid": true },
{ "email": "sam@-renew.example", "valid": true },
{ "email": "user@localhost", "valid": false },
{ "email": "\"sam smith\"@renew.example", "valid": false },
{ "email": "sam@renew", "valid": false },
{ "email": "sam@.renew.example", "valid": false },
{ "email": "sam@renew..example", "valid": false },
{ "email": "sam@renew.example.", "valid": false },
{ "email": "sam @renew.example", "valid": false },
{ "email": "@renew.example", "valid": false },
{ "email": "not-an-email", "valid": false },
{ "email": "", "valid": false }
]
+471
View File
@@ -0,0 +1,471 @@
package test
import (
"RenewCMS/api/handlers/auth"
userhandler "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo)
handler := userhandler.NewHandler(update)
meHandler := auth.NewMeHandler(getUser)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
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", handler.UpdateMe)
})
r.Route("/v1/auth", func(r chi.Router) {
r.Post("/login", loginHandler.Login)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
return r
}
func displayNameCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: tokenString}
}
func sendDisplayNameRequest(router *chi.Mux, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func displayNameBody(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(map[string]string{"display_name": value})
if err != nil {
t.Fatalf("failed to encode body: %v", err)
}
return string(encoded)
}
func seededDisplayNameRepo(displayName string) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{
ID: 7,
Username: "marta",
Email: "marta@renew.example",
Password: "hashed:correct-horse",
IsVerified: true,
DisplayName: displayName,
}
return repo
}
func decodeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
var decoded map[string]json.RawMessage
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("failed to decode body %q: %v", string(body), err)
}
return decoded
}
func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
decoded := decodeKeys(t, body)
if len(decoded) != len(displayNameResponseKeys) {
t.Errorf("expected exactly %d keys, got %d: %s", len(displayNameResponseKeys), len(decoded), string(body))
}
for _, key := range displayNameResponseKeys {
if _, present := decoded[key]; !present {
t.Errorf("expected key %q in body %s", key, string(body))
}
}
assertNoForbiddenKeys(t, body)
return decoded
}
func decodedString(t *testing.T, decoded map[string]json.RawMessage, key string) string {
t.Helper()
var value string
if err := json.Unmarshal(decoded[key], &value); err != nil {
t.Fatalf("failed to decode %s: %v", key, err)
}
return value
}
func TestUpdateUserProfileUseCase(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{ID: 7, DisplayName: "Old"}
return repo
}
successCases := []struct {
name string
input string
want string
}{
{"trims surrounding whitespace", " Marta ", "Marta"},
{"empty clears", "", ""},
{"whitespace only clears", " ", ""},
{"60 emoji runes", strings.Repeat("😀", 60), strings.Repeat("😀", 60)},
}
for _, tc := range successCases {
t.Run(tc.name, func(t *testing.T) {
repo := newRepo()
got, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, tc.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.DisplayName != tc.want || repo.users[7].DisplayName != tc.want {
t.Errorf("expected %q, got %q stored %q", tc.want, got.DisplayName, repo.users[7].DisplayName)
}
})
}
t.Run("61 emoji runes are refused without calling the repository", func(t *testing.T) {
repo := newRepo()
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, strings.Repeat("😀", 61))
if !errors.Is(err, user.ErrInvalidDisplayName) {
t.Fatalf("expected ErrInvalidDisplayName, got %v", err)
}
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Old" {
t.Errorf("repository must be untouched")
}
})
t.Run("unknown user", func(t *testing.T) {
_, err := useCases.NewUpdateUserProfileUseCase(newRepo()).UpdateDisplayName(99, "Marta")
if !errors.Is(err, user.ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got %v", err)
}
})
t.Run("repository failure is returned unchanged", func(t *testing.T) {
repo := newRepo()
repo.fail.failNext("UpdateDisplayName")
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, "Marta")
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected repository failure, got %v", err)
}
})
}
func TestUserDisplayNameHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder {
t.Helper()
router := newDisplayNameRouter(repo, token)
return sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", body, displayNameCookie(t, token, 7, 0))
}
t.Run("A verified user sets a display name", func(t *testing.T) {
repo := seededDisplayNameRepo("")
router := newDisplayNameRouter(repo, token)
cookie := displayNameCookie(t, token, 7, 0)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta Alves"}`, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
decoded := assertSevenKeys(t, rec.Body.Bytes())
var id uint32
_ = json.Unmarshal(decoded["id"], &id)
if id != 7 || decodedString(t, decoded, "username") != "marta" || decodedString(t, decoded, "display_name") != "Marta Alves" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
if repo.users[7].DisplayName != "Marta Alves" || repo.users[7].Username != "marta" {
t.Errorf("unexpected stored user: %+v", repo.users[7])
}
})
trimCases := []struct{ name, sent, returned string }{
{"spaces", " Marta Alves ", "Marta Alves"},
{"tab and newline", "\tMarta Alves\n", "Marta Alves"},
{"interior spaces kept", "Marta Alves", "Marta Alves"},
}
for _, tc := range trimCases {
t.Run("The value is trimmed before it is stored and returned: "+tc.name, func(t *testing.T) {
repo := seededDisplayNameRepo("")
rec := patch(t, repo, displayNameBody(t, tc.sent))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != tc.returned {
t.Errorf("expected %q, got %q", tc.returned, got)
}
if repo.users[7].DisplayName != tc.returned {
t.Errorf("expected stored %q, got %q", tc.returned, repo.users[7].DisplayName)
}
})
}
for _, sent := range []string{"", " ", "\t \n"} {
t.Run("An empty or whitespace-only value clears the display name: "+displayNameBody(t, sent), func(t *testing.T) {
repo := seededDisplayNameRepo("Marta Alves")
rec := patch(t, repo, displayNameBody(t, sent))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
if decodedString(t, decoded, "display_name") != "" || decodedString(t, decoded, "username") != "marta" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
if repo.users[7].DisplayName != "" {
t.Errorf("expected cleared, got %q", repo.users[7].DisplayName)
}
})
}
limitCases := []struct {
name string
count int
unit string
status int
}{
{"60 a", 60, "a", http.StatusOK},
{"61 a", 61, "a", http.StatusBadRequest},
{"60 emoji", 60, "😀", http.StatusOK},
{"61 emoji", 61, "😀", http.StatusBadRequest},
{"60 e-acute", 60, "é", http.StatusOK},
}
for _, tc := range limitCases {
t.Run("The limit is 60 Unicode characters, counted as runes: "+tc.name, func(t *testing.T) {
repo := seededDisplayNameRepo("")
value := strings.Repeat(tc.unit, tc.count)
rec := patch(t, repo, displayNameBody(t, value))
if rec.Code != tc.status {
t.Fatalf("expected %d, got %d: %s", tc.status, rec.Code, rec.Body.String())
}
if tc.status == http.StatusOK {
if repo.users[7].DisplayName != value {
t.Errorf("value not stored")
}
return
}
if rec.Body.String() != apperr.DisplayNameErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if repo.users[7].DisplayName != "" || repo.updateDisplayNameCalls != 0 {
t.Errorf("repository must be untouched")
}
})
}
t.Run("The limit applies after trimming, not before", func(t *testing.T) {
repo := seededDisplayNameRepo("")
core := strings.Repeat("a", 60)
rec := patch(t, repo, displayNameBody(t, strings.Repeat(" ", 5)+core+strings.Repeat(" ", 5)))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != core {
t.Errorf("unexpected display_name %q", got)
}
})
t.Run("Control characters inside the name are kept", func(t *testing.T) {
repo := seededDisplayNameRepo("")
rec := patch(t, repo, `{"display_name": "Ma\nrta"}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != "Ma\nrta" {
t.Errorf("unexpected display_name %q", got)
}
if repo.users[7].DisplayName != "Ma\nrta" {
t.Errorf("unexpected stored %q", repo.users[7].DisplayName)
}
})
t.Run("A rejected value leaves the existing display name untouched", func(t *testing.T) {
repo := seededDisplayNameRepo("Marta Alves")
rec := patch(t, repo, displayNameBody(t, strings.Repeat("a", 61)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Marta Alves" {
t.Errorf("repository must be untouched")
}
})
malformedBodies := []string{"not json", `{}`, `{"display_name": null}`, `{"display_name": 42}`, `{"displayName": "Marta"}`}
for _, body := range malformedBodies {
t.Run("A malformed body is refused with 400: "+body, func(t *testing.T) {
repo := seededDisplayNameRepo("Marta Alves")
rec := patch(t, repo, body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Marta Alves" {
t.Errorf("repository must be untouched")
}
})
}
t.Run("The password, token version and login username are unaffected by a display name", func(t *testing.T) {
repo := seededDisplayNameRepo("")
router := newDisplayNameRouter(repo, token)
cookie := displayNameCookie(t, token, 7, 0)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta Alves"}`, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if repo.users[7].Password != "hashed:correct-horse" || repo.users[7].TokenVersion != 0 {
t.Errorf("password or token version changed: %+v", repo.users[7])
}
me := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", cookie)
if me.Code != http.StatusOK {
t.Errorf("expected 200 from /me, got %d", me.Code)
}
})
t.Run("GET /v1/auth/me returns the display name once set", func(t *testing.T) {
repo := seededDisplayNameRepo("Marta Alves")
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 7, 0))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
decoded := assertSevenKeys(t, rec.Body.Bytes())
if decodedString(t, decoded, "display_name") != "Marta Alves" || decodedString(t, decoded, "username") != "marta" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
})
t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
decoded := assertSevenKeys(t, rec.Body.Bytes())
if decodedString(t, decoded, "display_name") != "" {
t.Errorf("expected empty display_name")
}
})
t.Run("Logging in still uses the username only", func(t *testing.T) {
repo := seededDisplayNameRepo("Ines Duarte")
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPost, "/v1/auth/login", `{"username":"Ines Duarte","password":"correct-horse"}`, nil)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.LogsErrorMessage+"\n" {
t.Errorf("expected 403 logs error, got %d: %q", rec.Code, rec.Body.String())
}
rec = sendDisplayNameRequest(router, http.MethodPost, "/v1/auth/login", `{"username":"marta","password":"correct-horse"}`, nil)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("An unverified user is refused", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0))
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("expected 403 verification required, got %d: %q", rec.Code, rec.Body.String())
}
if repo.updateDisplayNameCalls != 0 || repo.users[8].DisplayName != "" {
t.Errorf("repository must be untouched")
}
})
t.Run("No jwt cookie is refused", func(t *testing.T) {
repo := seededDisplayNameRepo("")
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if repo.updateDisplayNameCalls != 0 {
t.Errorf("repository must be untouched")
}
})
t.Run("A stale jwt is refused", func(t *testing.T) {
repo := seededDisplayNameRepo("")
u := repo.users[7]
u.TokenVersion = 1
repo.users[7] = u
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, displayNameCookie(t, token, 7, 0))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
}
if repo.updateDisplayNameCalls != 0 {
t.Errorf("repository must be untouched")
}
})
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
repo := newMockUserRepo()
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, displayNameCookie(t, token, 999, 0))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
}
})
t.Run("A repository failure while saving answers 500", func(t *testing.T) {
repo := seededDisplayNameRepo("")
repo.fail.failNext("UpdateDisplayName")
rec := patch(t, repo, `{"display_name": "Marta"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
}
})
t.Run("The user is deleted between authentication and the save", func(t *testing.T) {
repo := seededDisplayNameRepo("")
repo.fail.failNextWith("UpdateDisplayName", user.ErrUserNotFound)
rec := patch(t, repo, `{"display_name": "Marta"}`)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
}
})
}
+40
View File
@@ -0,0 +1,40 @@
package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
)
func TestUserRepositoryDelete(t *testing.T) {
t.Run("Deleting a user removes only that row", func(t *testing.T) {
db := openRealSqlite(t)
for _, name := range []string{"one", "two", "three"} {
seedRealUser(t, db, models.User{Username: name, Password: "x", Email: name + "@renew.example"})
}
repo := persistence.NewUserRepository(db)
if err := repo.Delete(2); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := repo.Get(2); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("user 2 must be gone, got %v", err)
}
for _, id := range []uint32{1, 3} {
if _, err := repo.Get(id); err != nil {
t.Errorf("user %d must remain, got %v", id, err)
}
}
})
t.Run("Deleting a missing user reports not found", func(t *testing.T) {
repo := persistence.NewUserRepository(openRealSqlite(t))
if err := repo.Delete(99); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
})
}
+7 -1
View File
@@ -76,6 +76,7 @@ var forbiddenUserFieldKeys = []string{
"verification_code", "verification_code",
"verification_expiration", "verification_expiration",
"password_reset_expiration", "password_reset_expiration",
"token_version",
} }
func assertNoForbiddenKeys(t *testing.T, body []byte) { func assertNoForbiddenKeys(t *testing.T, body []byte) {
@@ -124,7 +125,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at"} expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
if len(decoded) != len(expectedKeys) { if len(decoded) != len(expectedKeys) {
t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded) t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded)
} }
@@ -135,6 +136,11 @@ func TestUserResponseFieldHygiene(t *testing.T) {
} }
assertNoForbiddenKeys(t, rec.Body.Bytes()) assertNoForbiddenKeys(t, rec.Body.Bytes())
var displayName string
if err := json.Unmarshal(decoded["display_name"], &displayName); err != nil || displayName != "" {
t.Errorf("expected display_name to decode to an empty string, got %q (%v)", displayName, err)
}
var isVerified bool var isVerified bool
if err := json.Unmarshal(decoded["is_verified"], &isVerified); err != nil { if err := json.Unmarshal(decoded["is_verified"], &isVerified); err != nil {
t.Fatalf("failed to decode is_verified: %v", err) t.Fatalf("failed to decode is_verified: %v", err)
+17
View File
@@ -44,6 +44,8 @@ type mockUserRepo struct {
resetPasswordCalls int resetPasswordCalls int
lastResetPasswordUserId uint32 lastResetPasswordUserId uint32
lastResetPasswordHash string lastResetPasswordHash string
updateDisplayNameCalls int
lastUpdateDisplayName string
} }
func newMockUserRepo() *mockUserRepo { func newMockUserRepo() *mockUserRepo {
@@ -210,6 +212,21 @@ func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user
return u, nil return u, nil
} }
func (m *mockUserRepo) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
m.updateDisplayNameCalls++
m.lastUpdateDisplayName = displayName
if err := m.fail.trigger("UpdateDisplayName"); err != nil {
return user.User{}, err
}
u, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
u.DisplayName = displayName
m.users[userId] = u
return u, nil
}
var _ user.Repository = &mockUserRepo{} var _ user.Repository = &mockUserRepo{}
func TestCreateUser(t *testing.T) { func TestCreateUser(t *testing.T) {
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
username: string
displayName?: string
email?: string
}>()
const emit = defineEmits<{
logout: []
}>()
const open = defineModel<boolean>('open', { default: false })
const {
colorMode,
name,
initials,
onProfile,
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
function goToProfile() {
open.value = false
navigateToProfile()
}
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
open.value = false
emit('logout')
}
function moveFocus(event: KeyboardEvent) {
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
}
const current = items.indexOf(document.activeElement as HTMLElement)
const lastIndex = items.length - 1
const targets: Record<string, number> = {
ArrowDown: current >= lastIndex ? 0 : current + 1,
ArrowUp: current <= 0 ? lastIndex : current - 1,
Home: 0,
End: lastIndex,
}
const target = targets[event.key]
if (target === undefined) {
return
}
event.preventDefault()
items[target]?.focus()
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:close="false"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
aria-hidden="true"
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
name
}}</span>
<span class="truncate text-muted">{{ email }}</span>
</div>
</div>
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-circle-user"
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<USeparator class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-log-out"
:class="ITEM_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
</UButton>
</div>
</template>
</USlideover>
</template>
+7 -1
View File
@@ -7,7 +7,7 @@ const props = withDefaults(
max: number max: number
min?: number min?: number
warnAt?: number warnAt?: number
variant?: 'inline' | 'footer' variant?: 'inline' | 'footer' | 'field'
plainText?: boolean plainText?: boolean
}>(), }>(),
{ {
@@ -113,6 +113,12 @@ const isErrorStatus = computed(() => status.value === 'over' || status.value ===
</span> </span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span> <span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div> </div>
<span
v-else-if="variant === 'field'"
class="font-mono text-xs leading-tight"
:class="overBy > 0 ? 'text-error' : 'text-muted'"
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
<div v-else class="flex flex-col gap-1"> <div v-else class="flex flex-col gap-1">
<div <div
class="flex items-center gap-1.5 font-mono text-xs" class="flex items-center gap-1.5 font-mono text-xs"
+52 -12
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed' import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue' import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue' import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue' import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue' import OfflineBanner from '@/components/OfflineBanner.vue'
@@ -14,10 +15,12 @@ const props = withDefaults(
defineProps<{ defineProps<{
mobileTitle?: string mobileTitle?: string
fullBleed?: boolean fullBleed?: boolean
renderWhileChecking?: boolean
}>(), }>(),
{ {
mobileTitle: undefined, mobileTitle: undefined,
fullBleed: false, fullBleed: false,
renderWhileChecking: false,
}, },
) )
@@ -36,6 +39,7 @@ const navItems = computed(() =>
[ [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' }, { label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' }, { label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' }, { label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
].map((item) => ({ ].map((item) => ({
...item, ...item,
@@ -62,6 +66,36 @@ const navUi = computed(() => ({
linkLeadingIcon: 'size-4', linkLeadingIcon: 'size-4',
})) }))
const loggingOut = ref(false) const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
const NAVBAR_TOGGLE_SELECTOR = '[data-slot="toggle"]'
let sheetOpener: HTMLElement | undefined
function openAccountSheet(opener: HTMLElement | undefined) {
sheetOpener = opener
sidebarOpen.value = false
accountSheetOpen.value = true
}
async function restoreFocusAfterSheet() {
await nextTick()
if (loggingOut.value) {
return
}
const target = sheetOpener?.isConnected
? sheetOpener
: document.querySelector<HTMLElement>(NAVBAR_TOGGLE_SELECTOR)
sheetOpener = undefined
target?.focus()
}
watch(accountSheetOpen, (isOpen) => {
if (!isOpen) {
restoreFocusAfterSheet()
}
})
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
function onRetry() { function onRetry() {
session.retry() session.retry()
@@ -81,11 +115,10 @@ async function onLogout() {
<template> <template>
<UDashboardGroup unit="rem"> <UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only" <a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
>Skip to content</a
>
<UDashboardSidebar <UDashboardSidebar
v-model:open="sidebarOpen"
v-model:collapsed="sidebarCollapsed" v-model:collapsed="sidebarCollapsed"
collapsible collapsible
:resizable="false" :resizable="false"
@@ -114,8 +147,12 @@ async function onLogout() {
<template #footer> <template #footer>
<UserMenu <UserMenu
:username="session.session?.username ?? ''" :username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed" :collapsed="sidebarCollapsed"
:sheet-open="accountSheetOpen"
@logout="onLogout" @logout="onLogout"
@open-sheet="openAccountSheet"
/> />
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>
@@ -163,26 +200,29 @@ async function onLogout() {
<template #body> <template #body>
<div <div
id="dashboard-content" id="dashboard-content"
:class=" :class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
> >
<OfflineBanner /> <OfflineBanner />
<AppBoot v-if="session.status === 'checking'" /> <AppBoot v-if="showBoot" />
<ErrorView <ErrorView
v-else-if="session.status === 'error'" v-else-if="session.status === 'error'"
kind="error" kind="error"
@retry="onRetry" @retry="onRetry"
/> />
<div <div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<slot /> <slot />
</div> </div>
</div> </div>
</template> </template>
</UDashboardPanel> </UDashboardPanel>
<AccountMenuSheet
v-model:open="accountSheetOpen"
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
/>
</UDashboardGroup> </UDashboardGroup>
</template> </template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
status: 'accepted' | 'pending' | 'expired'
size?: 'sm' | 'md'
}>(),
{
size: 'md',
},
)
const badges = {
accepted: {
label: 'Accepted',
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
},
} as const
const badge = computed(() => badges[props.status])
const sizeClass = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : 'h-5.5 px-1.75'))
</script>
<template>
<UBadge
:color="badge.color"
variant="soft"
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:ui="{ base: 'gap-1 py-0 text-xs', leadingIcon: 'size-3' }"
>
{{ badge.label }}
</UBadge>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
const EMAIL_PLACEHOLDER = 'name@example.com'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
const { inviteUser } = useUsers()
const email = ref('')
const touched = ref(false)
const focused = ref(false)
const submitting = ref(false)
const serverError = ref('')
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
}
return ''
})
function resetForm() {
email.value = ''
touched.value = false
focused.value = false
submitting.value = false
serverError.value = ''
formAlert.value = ''
}
function emailInput() {
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
}
async function focusEmailField() {
await nextTick()
await nextTick()
emailInput()?.focus()
focused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusEmailField()
},
{ immediate: true },
)
watch(email, () => {
serverError.value = ''
})
function onBlur() {
touched.value = true
focused.value = false
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
}
function showFailure(error: unknown) {
if (error instanceof ApiError && error.status === 409) {
serverError.value = messageForConflict(error)
return
}
if (error instanceof ApiError && error.status === 400) {
serverError.value = INVALID_EMAIL_MESSAGE
formAlert.value = 'invalid'
return
}
formAlert.value = error instanceof ApiError && error.status === 429 ? 'rateLimited' : 'failed'
}
async function onSubmit() {
if (submitting.value) {
return
}
touched.value = true
focused.value = false
if (!emailValid.value) {
emailInput()?.focus()
return
}
serverError.value = ''
formAlert.value = ''
submitting.value = true
try {
const invite = await inviteUser(email.value)
open.value = false
emit('invited', invite)
} catch (error) {
showFailure(error)
} finally {
submitting.value = false
}
}
function onOpenChange(isOpen: boolean) {
if (!isOpen && submitting.value) {
return
}
open.value = isOpen
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
close: 'top-5 end-6 size-7 justify-center p-0',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
v-model="email"
type="email"
:placeholder="EMAIL_PLACEHOLDER"
autocomplete="off"
:disabled="submitting"
color="primary"
:highlight="focused || !!emailError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="focused = true"
@blur="onBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
</UButton>
</div>
</template>
</UModal>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
</div>
<div
v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
>
<slot name="footer" />
</div>
</section>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<li
v-for="n in USER_SKELETON_ROWS"
:key="n"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
>
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li
v-for="item in items"
:key="rowKey(item)"
data-testid="user-card"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
<div class="flex items-center gap-2.5 text-xs text-muted">
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
</div>
</li>
</template>
</ul>
</template>
+70 -31
View File
@@ -1,43 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { import { useMediaQuery } from '@vueuse/core'
useColorMode, import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
type ColorModePreference,
} from '@/composables/useColorMode' const DESKTOP_QUERY = '(min-width: 64rem)'
const props = defineProps<{ const props = defineProps<{
username: string username: string
displayName?: string
email?: string
collapsed: boolean collapsed: boolean
sheetOpen?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
logout: [] logout: []
'open-sheet': [chip: HTMLElement | undefined]
}>() }>()
const colorMode = useColorMode() const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const chip = ref<{ $el: HTMLElement } | null>(null)
const initials = computed(() => { const chipActive = computed(() => open.value || onProfile.value)
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
const triggerAriaLabel = computed(() => const triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined, props.collapsed ? `${name.value} — account menu` : undefined,
) )
const colorModeOptions: { label: string; value: ColorModePreference }[] = [ const chipClass = computed(() => [
{ label: 'System', value: 'system' }, 'h-10 gap-2.5 px-1.5 font-normal',
{ label: 'Light', value: 'light' }, props.collapsed ? 'justify-center' : 'justify-start',
{ label: 'Dark', value: 'dark' }, chipActive.value
] ? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 hover:text-primary-900 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'text-highlighted',
])
function onUpdateOpen(value: boolean) {
if (isDesktop.value) {
open.value = value
return
}
if (value) {
emit('open-sheet', chip.value?.$el)
}
}
const chipExpanded = computed(() => (isDesktop.value ? open.value : props.sheetOpen === true))
const items = computed(() => [ const items = computed(() => [
[{ type: 'label' as const, label: props.username }], [
{
type: 'label' as const,
label: name.value,
slot: 'account-header' as const,
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
icon: 'i-lucide-circle-user',
class: onProfile.value ? 'bg-elevated' : undefined,
onSelect: goToProfile,
},
],
colorModeOptions.map((option) => ({ colorModeOptions.map((option) => ({
type: 'checkbox' as const, type: 'checkbox' as const,
label: option.label, label: option.label,
@@ -51,6 +77,7 @@ const items = computed(() => [
[ [
{ {
label: 'Log out', label: 'Log out',
icon: 'i-lucide-log-out',
onSelect: () => emit('logout'), onSelect: () => emit('logout'),
}, },
], ],
@@ -58,17 +85,23 @@ const items = computed(() => [
</script> </script>
<template> <template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }"> <UDropdownMenu
:open="open"
:items="items"
:content="{ side: 'top', align: 'start' }"
:ui="{ content: 'w-56 p-1' }"
@update:open="onUpdateOpen"
>
<UButton <UButton
ref="chip"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
block block
:class="[ title="Account"
'h-10 gap-2.5 px-1.5 font-normal text-highlighted', :aria-expanded="chipExpanded"
collapsed ? 'justify-center' : 'justify-start', :class="chipClass"
]"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'" :trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }" :ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
:aria-label="triggerAriaLabel" :aria-label="triggerAriaLabel"
> >
<template #leading> <template #leading>
@@ -78,12 +111,18 @@ const items = computed(() => [
size="sm" size="sm"
:ui="{ :ui="{
root: 'size-6.5 bg-secondary-300', root: 'size-6.5 bg-secondary-300',
fallback: fallback: 'text-[0.6875rem] font-semibold text-primary-900',
'text-[0.6875rem] font-semibold text-primary-900',
}" }"
/> />
</template> </template>
<span v-if="!collapsed" class="truncate">{{ username }}</span> <span v-if="!collapsed" class="truncate font-medium">{{ name }}</span>
</UButton> </UButton>
<template #account-header>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
</UDropdownMenu> </UDropdownMenu>
</template> </template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { UserListItem } from '@/composables/useUsers'
const props = defineProps<{
item: UserListItem
isYou: boolean
large?: boolean
}>()
const primary = computed(() => {
if (props.item.kind === 'invite') {
return props.item.email
}
return props.item.display_name || props.item.username
})
const secondary = computed(() =>
props.item.kind === 'user' && props.item.display_name ? props.item.username : '',
)
const primaryClass = computed(() => [
props.item.kind === 'invite' ? 'text-toned' : 'font-medium text-highlighted',
props.large ? 'text-[0.9375rem]/[21px]' : '',
])
</script>
<template>
<span class="flex min-w-0 items-center gap-2">
<span class="truncate" :class="primaryClass">{{ primary }}</span>
<span v-if="secondary" class="shrink-0 text-muted">{{ secondary }}</span>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
</span>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span>Username</span>
<span>Invite status</span>
<span class="flex items-center gap-1 text-highlighted">
Created
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
<div
v-for="n in USER_SKELETON_ROWS"
:key="n"
:class="ROW_GRID"
class="h-11 border-t border-default"
>
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<div
v-for="item in items"
:key="rowKey(item)"
data-testid="user-row"
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
class="h-11 border-t border-default"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
class="pr-3"
/>
<span><InviteStatusBadge :status="item.status" /></span>
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
import { MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let mountedShell: VueWrapper | null = null
async function mountMobileShell() {
localStorage.clear()
setViewportWidth(MOBILE_WIDTH)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push('/home')
mountedShell = mount(DashboardShell, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function drawerChip() {
return document.body.querySelector<HTMLButtonElement>('[role="dialog"] button[aria-haspopup]')
}
function sheet() {
return document.body.querySelector('[role="menu"]')
}
function menuItem(label: string) {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === label)!
}
async function openDrawer() {
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
await flushPromises()
}
async function openSheetFromChip() {
await openDrawer()
drawerChip()!.click()
await flushPromises()
}
const closingActions = [
{
name: 'pressing Escape',
close: async () => {
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
},
},
{
name: 'tapping the backdrop',
close: async () => {
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
overlay.click()
},
},
{
name: 'activating "Profile"',
close: async () => {
menuItem('Profile').click()
},
},
]
afterEach(async () => {
mountedShell?.unmount()
mountedShell = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('Mobile account sheet focus', () => {
it.each(closingActions)(
'returns focus to the navbar toggle when the sheet closes by $name',
async ({ close }) => {
await mountMobileShell()
await openSheetFromChip()
expect(sheet()).not.toBeNull()
await close()
await flushPromises()
expect(sheet()).toBeNull()
expect(document.activeElement).toBe(document.body.querySelector('[data-slot="toggle"]'))
},
)
it('logs out from the sheet without throwing', async () => {
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
await mountMobileShell()
await openSheetFromChip()
menuItem('Log out').click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(unhandled).not.toHaveBeenCalled()
process.off('unhandledRejection', unhandled)
})
it('does not put focus on the chip or the toggle when the sheet was never opened', async () => {
await mountMobileShell()
expect(document.activeElement).toBe(document.body)
})
})
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
const wrapper = mountCounter(4318) const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000') expect(wrapper.text()).toContain('4 318 / 10 000')
expect( expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
wrapper expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
}) })
}) })
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
const wrapper = mountCounter(9240) const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000') expect(wrapper.text()).toContain('9 240 / 10 000')
expect( expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).not.toContain('characters over') expect(wrapper.text()).not.toContain('characters over')
}) })
}) })
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
const wrapper = mountCounter(10214) const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000') expect(wrapper.text()).toContain('10 214 / 10 000')
expect( expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.', '214 characters over. Shorten the text or remove an image. Formatting counts too.',
) )
}) })
}) })
describe('The field variant is a bare mono count', () => {
function mountField(value: number) {
return mount(CharacterCounter, {
props: { value, max: 60, variant: 'field' },
global: { plugins: [uiPlugin] },
})
}
it.each([
[0, 'text-muted'],
[60, 'text-muted'],
[61, 'text-error'],
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
const wrapper = mountField(value)
expect(wrapper.text()).toBe(`${value} / 60`)
expect(wrapper.classes()).toContain(colorClass)
expect(wrapper.classes()).toContain('font-mono')
expect(wrapper.find('svg').exists()).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin' import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router' import router from '@/router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue' import DashboardShell from '../DashboardShell.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => { vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>() const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
} }
}) })
async function mountShell(path: string, username = 'marta') { interface ShellOptions {
username?: string
displayName?: string
email?: string
width?: number
}
let mountedShell: VueWrapper | null = null
async function mountShell(path: string, options: ShellOptions = {}) {
const {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({ vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username }), getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
request: vi.fn().mockResolvedValue(undefined), request: vi.fn().mockResolvedValue(undefined),
}) })
await router.push(path) await router.push(path)
@@ -27,38 +45,51 @@ async function mountShell(path: string, username = 'marta') {
plugins: [router, uiPlugin], plugins: [router, uiPlugin],
}, },
}) })
mountedShell = wrapper
await flushPromises() await flushPromises()
return wrapper return wrapper
} }
afterEach(async () => { afterEach(async () => {
mountedShell?.unmount()
mountedShell = null
await router.push('/login') await router.push('/login')
document.body.innerHTML = '' document.body.innerHTML = ''
}) })
describe('DashboardShell renders the three navigation destinations', () => { describe('DashboardShell renders the four navigation destinations', () => {
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => { it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article') const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
const home = wrapper.find('a[href="/home"]') const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]') const articles = wrapper.find('a[href="/article"]')
const users = wrapper.find('a[href="/users"]')
const integration = wrapper.find('a[href="/integration"]') const integration = wrapper.find('a[href="/integration"]')
expect(home.exists()).toBe(true)
expect(articles.exists()).toBe(true)
expect(integration.exists()).toBe(true)
expect(articles.attributes('aria-current')).toBe('page') expect(articles.attributes('aria-current')).toBe('page')
expect(home.attributes('aria-current')).toBeUndefined() expect(home.attributes('aria-current')).toBeUndefined()
expect(users.attributes('aria-current')).toBeUndefined()
expect(integration.attributes('aria-current')).toBeUndefined() expect(integration.attributes('aria-current')).toBeUndefined()
}) })
it('marks Users with aria-current on /users', async () => {
const wrapper = await mountShell('/users')
const users = wrapper.find('a[href="/users"]')
expect(users.text()).toContain('Users')
expect(users.attributes('aria-current')).toBe('page')
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
})
}) })
describe('DashboardShell shows the offline banner when the browser reports offline', () => { describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => { it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const wrapper = await mountShell('/article') const wrapper = await mountShell('/article')
@@ -79,16 +110,12 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
const wrapper = await mountShell('/home') const wrapper = await mountShell('/home')
const header = wrapper.find('[data-slot="header"]') const header = wrapper.find('[data-slot="header"]')
expect( expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
).toBe(true)
expect(header.text()).toContain('RenewCMS') expect(header.text()).toContain('RenewCMS')
expect(header.find('a').exists()).toBe(false) expect(header.find('a').exists()).toBe(false)
const headerHtml = header.html() const headerHtml = header.html()
expect(headerHtml.indexOf('<svg')).toBeLessThan( expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
headerHtml.indexOf('RenewCMS</span>'),
)
}) })
}) })
@@ -97,9 +124,7 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
await mountShell('/home') await mountShell('/home')
const collapseButton = () => const collapseButton = () =>
document.body.querySelector( document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
expect(collapseButton().getAttribute('aria-expanded')).toBe('true') expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
@@ -120,13 +145,11 @@ describe('A successful logout clears the session and navigates to /login', () =>
await mountShell('/home') await mountShell('/home')
const logOutItem = () => const logOutItem = () =>
Array.from( Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
document.body.querySelectorAll('[role="menuitem"]'), (el) => el.textContent?.trim() === 'Log out',
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement ) as HTMLElement
const trigger = document.body.querySelector( const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
'button[aria-haspopup]',
) as HTMLButtonElement
trigger.click() trigger.click()
await flushPromises() await flushPromises()
@@ -151,28 +174,125 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
collapseButton.click() collapseButton.click()
await flushPromises() await flushPromises()
const header = document.body.querySelector( const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
'[data-slot="header"]', expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
) as HTMLElement
expect(
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
).toBeTruthy()
expect(header.textContent).not.toContain('RenewCMS') expect(header.textContent).not.toContain('RenewCMS')
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull() expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
const footer = document.body.querySelector( const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
'[data-slot="footer"]',
) as HTMLElement
expect(footer.textContent).not.toContain('marta') expect(footer.textContent).not.toContain('marta')
const userMenuTrigger = footer.querySelector( const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
'button[aria-haspopup]', expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
describe('The account chip shows the display name and no item is current on /profile', () => {
it('shows the display name and its initials in the sidebar chip', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
it('keeps the collapsed accessible name on the display name', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const collapseButton = document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement ) as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe( collapseButton.click()
'marta — account menu', await flushPromises()
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the four navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
describe('Account menu: opened from the drawer on mobile', () => {
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const accountRow = document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
expect(accountRow).not.toBeNull()
accountRow.click()
await flushPromises()
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
expect(sheets).toHaveLength(1)
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
expect(
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
).not.toBeNull()
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(item.className).toContain('h-12'),
) )
expect( })
footer.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA') it('navigates to /profile and closes the sheet from Profile', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const profile = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profile.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('logs out from the sheet', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
logOut.click()
await flushPromises()
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
'/v1/auth/logout',
expect.objectContaining({ method: 'POST' }),
)
expect(router.currentRoute.value.path).toBe('/login')
}) })
}) })
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import inviteEmailCases from '../../../../test/testdata/invite_emails.json'
import InviteUserModal from '../InviteUserModal.vue'
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
const inviteUser = vi.fn()
vi.mock('@/composables/useUsers', () => ({
useUsers: () => ({ inviteUser }),
}))
let wrapper: VueWrapper | null = null
function emailInput() {
return document.body.querySelector<HTMLInputElement>('input[placeholder="name@example.com"]')!
}
function dialogText() {
return document.body.querySelector('[role="dialog"]')?.textContent ?? ''
}
function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function mountOpenModal() {
wrapper = mount(InviteUserModal, {
attachTo: document.body,
props: { open: true },
global: { plugins: [uiPlugin] },
})
await flushPromises()
}
function clickSend() {
const send = [...document.body.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Send invite',
)!
send.click()
return flushPromises()
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
inviteUser.mockReset()
})
describe('Invite modal email rule', () => {
it.each(inviteEmailCases)('gives "$email" the shared verdict (valid: $valid)', async (row) => {
inviteUser.mockResolvedValue({ id: 1 })
await mountOpenModal()
const input = emailInput()
input.value = row.email
dispatch(input, 'input')
dispatch(input, 'blur')
await flushPromises()
await clickSend()
if (row.valid) {
expect(dialogText()).not.toContain(INVALID_EMAIL)
expect(inviteUser).toHaveBeenCalledTimes(1)
} else {
expect(dialogText()).toContain(INVALID_EMAIL)
expect(inviteUser).not.toHaveBeenCalled()
}
})
})
+288 -130
View File
@@ -1,175 +1,333 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin' import uiPlugin from '@nuxt/ui/vue-plugin'
import { useSessionStore } from '@/stores/session'
import router from '@/router'
import UserMenu from '../UserMenu.vue' import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode' import { useColorMode } from '@/composables/useColorMode'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
function mountMenu(props: { username: string; collapsed: boolean }) { interface MenuProps {
username: string
displayName?: string
email?: string
collapsed: boolean
sheetOpen?: boolean
}
const baseProps: MenuProps = {
username: 'marta',
displayName: '',
email: 'marta@renew.example',
collapsed: false,
}
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
return mount(UserMenu, { return mount(UserMenu, {
attachTo: document.body, attachTo: document.body,
props, props: { ...baseProps, ...props },
global: { global: { plugins: [router, uiPlugin] },
plugins: [uiPlugin],
},
}) })
} }
function findTriggerButton() { function chip() {
return document.body.querySelector('button') return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
} }
async function openMenu() { async function openMenu() {
findTriggerButton()?.click() chip().click()
await flushPromises() await flushPromises()
} }
function findMenuItemsByRole(role: string) { function menu() {
return Array.from(document.body.querySelectorAll(`[role="${role}"]`)) return document.body.querySelector('[role="menu"]') as HTMLElement | null
} }
function findCheckboxItem(label: string) { function itemsIn(container: ParentNode) {
return findMenuItemsByRole('menuitemcheckbox').find( return Array.from(
(el) => el.textContent?.trim() === label, container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
) ) as HTMLElement[]
}
function itemLabels() {
return itemsIn(menu()!).map((el) => el.textContent?.trim())
}
function findItem(label: string) {
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
} }
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
document.documentElement.classList.remove('dark', 'light') document.documentElement.classList.remove('dark', 'light')
setViewportWidth(DESKTOP_WIDTH)
}) })
afterEach(() => { afterEach(async () => {
document.body.innerHTML = '' document.body.innerHTML = ''
await router.push('/login')
}) })
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => { describe('The chip opens the account menu instead of logging out', () => {
it('shows avatar initials MA, the username, and the three ordered groups', async () => { it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false }) const wrapper = await mountMenu()
const avatar = document.body.querySelector('[data-slot="fallback"]') expect(chip().getAttribute('aria-haspopup')).toBe('menu')
expect(avatar?.textContent).toBe('MA') expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(avatar?.getAttribute('aria-hidden')).toBe('true') expect(chip().getAttribute('title')).toBe('Account')
expect(wrapper.text()).toContain('marta') expect(chip().textContent).toContain('marta')
expect(wrapper.emitted('logout')).toBeUndefined()
await openMenu() await openMenu()
const groups = findMenuItemsByRole('group') expect(chip().getAttribute('aria-expanded')).toBe('true')
expect(groups.length).toBe(3) expect(menu()).not.toBeNull()
expect(wrapper.emitted('logout')).toBeUndefined()
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
expect(groups[2].textContent?.trim()).toBe('Log out')
wrapper.unmount()
})
it('derives initials from the first letter of the first two words for a multi-word username', () => {
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('JD')
wrapper.unmount()
})
it('checks exactly the item matching the current color-mode preference', async () => {
const colorMode = useColorMode()
colorMode.preference = 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
}) })
}) })
describe('Selecting a color-mode item updates the preference and its checked state', () => { describe('Account menu: opened on desktop', () => {
it.each([ it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
['system', 'light'], await mountMenu()
['light', 'dark'],
['dark', 'system'],
])('moves from %s to %s', async (from, to) => {
const colorMode = useColorMode()
colorMode.preference = from as 'system' | 'light' | 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu() await openMenu()
const label = to.charAt(0).toUpperCase() + to.slice(1) const header = menu()!.querySelector('[role="group"]')!
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1) expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
findCheckboxItem(label)?.dispatchEvent( expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('emits logout when the user activates Log out', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
expect(wrapper.emitted('logout')).toBeTruthy()
wrapper.unmount()
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect( expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent, menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe('MA') ).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
})
it('shows the display name in the header and the chip when there is one', async () => {
await mountMenu({ displayName: 'Marta Alves' })
await openMenu()
expect(chip().textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('marta@renew.example')
})
it('does not highlight Profile away from /profile', async () => {
await mountMenu()
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
})
it('gives the chip the sand fill only while the menu is open', async () => {
await mountMenu()
expect(chip().className).not.toContain('bg-secondary-200')
await openMenu() await openMenu()
const groups = findMenuItemsByRole('group') expect(chip().className).toContain('bg-secondary-200')
expect(groups.length).toBe(3) expect(chip().className).toContain('text-primary-900')
expect(groups[0].textContent).toContain('marta') })
})
wrapper.unmount() describe('The chip is sand while on /profile even with the menu closed', () => {
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
await mountMenu({}, '/profile')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().getAttribute('aria-expanded')).toBe('false')
await router.push('/home')
await flushPromises()
expect(chip().className).not.toContain('bg-secondary-200')
})
})
describe('Closing the menu', () => {
it('closes on Escape and returns focus to the chip', async () => {
await mountMenu()
await openMenu()
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(chip())
})
it('closes on a click outside', async () => {
await mountMenu()
await openMenu()
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
})
it('moves between items with the arrow keys', async () => {
await mountMenu()
await openMenu()
const first = itemsIn(menu()!)[0]!
first.focus()
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
})
})
describe('Menu items', () => {
it('navigates to /profile and closes the menu from Profile', async () => {
await mountMenu()
await openMenu()
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('highlights Profile on /profile and only closes when activated there', async () => {
await mountMenu({}, '/profile')
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('emits logout from Log out', async () => {
const wrapper = await mountMenu()
await openMenu()
activate(findItem('Log out'))
await flushPromises()
expect(wrapper.emitted('logout')).toHaveLength(1)
})
})
describe('Theme choice stays in the menu', () => {
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountMenu()
await openMenu()
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
activate(findItem('Dark'))
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(menu()).toBeNull()
await openMenu()
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
})
})
describe('Avatar initials', () => {
it.each([
['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'],
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
await mountMenu({ username, displayName })
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
expect(chip().textContent).toContain(shown)
await openMenu()
expect(menu()!.textContent).toContain(shown)
})
})
describe('The collapsed rail keeps the menu and an accessible name', () => {
it('shows only the avatar, names the chip and opens the same menu', async () => {
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
expect(chip().textContent).not.toContain('Marta Alves')
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
})
})
describe('Below lg the chip hands the menu to the bottom sheet', () => {
it('emits open-sheet instead of opening the popover', async () => {
setViewportWidth(MOBILE_WIDTH)
const wrapper = await mountMenu()
await openMenu()
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
expect(menu()).toBeNull()
})
})
describe('Account menu: the chip on mobile follows the sheet', () => {
it('reports the sheet through aria-expanded and hands its own element to open-sheet', async () => {
setViewportWidth(MOBILE_WIDTH)
const wrapper = await mountMenu({ sheetOpen: false })
expect(chip().getAttribute('aria-expanded')).toBe('false')
await openMenu()
expect(wrapper.emitted('open-sheet')).toEqual([[chip()]])
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(menu()).toBeNull()
await wrapper.setProps({ sheetOpen: true })
expect(chip().getAttribute('aria-expanded')).toBe('true')
await wrapper.setProps({ sheetOpen: false })
expect(chip().getAttribute('aria-expanded')).toBe('false')
})
it('does not add the sand fill while the sheet is open', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountMenu({ sheetOpen: true })
expect(chip().classList.contains('bg-secondary-200')).toBe(false)
}) })
}) })
+30
View File
@@ -0,0 +1,30 @@
const REM_PX = 16
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
if (!match) {
return null
}
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
}
export function setViewportWidth(width: number) {
window.matchMedia = ((query: string) => {
const min = widthLimit(query, 'min-width')
const max = widthLimit(query, 'max-width')
const matches = (min === null || width >= min) && (max === null || width <= max)
return {
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}
}) as unknown as typeof window.matchMedia
}
export const DESKTOP_WIDTH = 1440
export const MOBILE_WIDTH = 390
+45 -15
View File
@@ -5,11 +5,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi' import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse( function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value }) Object.defineProperty(navigator, 'onLine', { configurable: true, value })
} }
async function expectApiError( async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
await expect(call).rejects.toBeInstanceOf(ApiError) await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher) await expect(call).rejects.toMatchObject(matcher)
} }
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 }) await expectApiError(call, { status: 403 })
await vi.waitFor(() => await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(session.session).toEqual({ verified: false }) expect(session.session).toEqual({ verified: false })
}) })
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
}) })
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => { it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue( fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
mockResponse(429, null, { 'Retry-After': '30' }),
)
const call = useApi().request('/v1/auth/login', { method: 'POST' }) const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
expect(isOffline.value).toBe(false) expect(isOffline.value).toBe(false)
}) })
}) })
describe('getMe maps the account fields', () => {
it('maps email and display_name onto email and displayName', async () => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: 'Marta Alves',
}),
)
await expect(useApi().getMe()).resolves.toEqual({
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: 'Marta Alves',
})
})
it.each([
['absent', undefined],
['null', null],
])('reads a %s display_name as an empty string', async (_label, value) => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: value,
}),
)
const me = await useApi().getMe()
expect(me.displayName).toBe('')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import { accountInitials, accountName } from '@/lib/accountName'
const PROFILE_PATH = '/profile'
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
const route = useRoute()
const router = useRouter()
const colorMode = useColorMode()
const name = computed(() => accountName(account().username, account().displayName))
const initials = computed(() => accountInitials(account().username, account().displayName))
const onProfile = computed(() => route.path === PROFILE_PATH)
function goToProfile() {
if (!onProfile.value) {
router.push(PROFILE_PATH)
}
}
return { colorMode, name, initials, onProfile, goToProfile }
}
+32 -6
View File
@@ -5,11 +5,13 @@ import { useSessionStore } from '@/stores/session'
export class ApiError extends Error { export class ApiError extends Error {
status: number status: number
retryAfter?: number retryAfter?: number
body?: string
constructor(status: number, retryAfter?: number) { constructor(status: number, retryAfter?: number, body?: string) {
super(`Request failed with status ${status}`) super(`Request failed with status ${status}`)
this.status = status this.status = status
this.retryAfter = retryAfter this.retryAfter = retryAfter
this.body = body
} }
} }
@@ -20,8 +22,11 @@ export class NetworkError extends Error {
} }
export interface MeResult { export interface MeResult {
id?: number
verified: boolean verified: boolean
username?: string username?: string
email?: string
displayName: string
} }
export type OnUnauthorized = 'redirect' | 'passthrough' export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -51,6 +56,17 @@ function parseRetryAfterSeconds(response: Response): number | undefined {
return Number.parseInt(header, 10) return Number.parseInt(header, 10)
} }
async function readErrorBody(response: Response): Promise<string | undefined> {
if (typeof response.text !== 'function') {
return undefined
}
try {
return (await response.text()).trim()
} catch {
return undefined
}
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
isOffline.value = true isOffline.value = true
@@ -102,7 +118,7 @@ export function useApi() {
throw new ApiError(429, parseRetryAfterSeconds(response)) throw new ApiError(429, parseRetryAfterSeconds(response))
} }
throw new ApiError(response.status) throw new ApiError(response.status, undefined, await readErrorBody(response))
} }
if (response.status === 204) { if (response.status === 204) {
@@ -113,10 +129,20 @@ export function useApi() {
} }
async function getMe(): Promise<MeResult> { async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', { const body = await request<{
onUnauthorized: 'passthrough', id?: number
}) is_verified: boolean
return { verified: Boolean(body.is_verified), username: body.username } username?: string
email?: string
display_name?: string | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
verified: Boolean(body.is_verified),
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
}
} }
return { getMe, request } return { getMe, request }
+51
View File
@@ -0,0 +1,51 @@
import { useApi } from '@/composables/useApi'
export type UserListItem =
| {
kind: 'user'
id: number
username: string
display_name: string
status: 'accepted'
created_at: string
}
| {
kind: 'invite'
id: number
email: string
status: 'pending' | 'expired'
created_at: string
}
export const USER_SKELETON_ROWS = 5
export function userRowKey(kind: UserListItem['kind'], id: number) {
return `${kind}-${id}`
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
status: 'pending'
created_at: string
expires_at: string
}
export function useUsers() {
const { request } = useApi()
function listUsers(): Promise<UserListItem[]> {
return request<UserListItem[]>('/v1/user/')
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
}
return { listUsers, inviteUser }
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more