feat: invitation accept screen and api

This commit is contained in:
fsylvain committed 2026-09-29 09:21:53 +02:00
1 parent 310cfd528e
commit 55525c06d6
23 files changed
+2043 -11

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ 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
@@ -1,7 +1,10 @@
package invite
import "time"
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
Delete(id uint32) error
MarkAccepted(id uint32, acceptedAt time.Time) error
}
+1
View File
@@ -14,6 +14,7 @@ var ErrAlreadyVerified = errors.New("user already verified")
var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken")
type User struct {
ID uint32 `json:"id"`
@@ -4,6 +4,7 @@ import (
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
)
@@ -43,4 +44,9 @@ func (i *InviteRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
func (i *InviteRepository) MarkAccepted(id uint32, acceptedAt time.Time) error {
result := i.db.Model(&entity.Invite{}).Where("id = ?", id).Update("accepted_at", acceptedAt)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
var _ domainInvite.Repository = &InviteRepository{}
@@ -33,6 +33,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
Password: user.Password,
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email,
IsVerified: user.IsVerified,
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
})
@@ -0,0 +1,87 @@
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) {
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.userRepository.Create(user.User{
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
})
if err != nil {
return user.User{}, err
}
if err := a.inviteRepository.MarkAccepted(pending.ID, now); err != nil {
if deleteErr := a.userRepository.Delete(createdUser.ID); deleteErr != nil {
return user.User{}, errors.Join(err, deleteErr)
}
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,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
}