feat: roles SPA Users, Profile, API keys, Activity

This commit is contained in:
Florian Sylvain committed 2026-10-28 00:10:15 +01:00
1 parent 3db490a615
commit 34dffe222f
98 files changed
+4957 -434

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ type User struct {
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
RoleID uint32 `json:"role_id"`
RoleAssignedByID uint32 `json:"role_assigned_by_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
@@ -16,6 +16,6 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error)
UpdateRole(id uint32, from uint32, to uint32) (User, error)
UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
}
@@ -25,7 +25,7 @@ func InviteToModel(i invite.Invite) models.Invite {
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
RoleID: DomainToNullableID(i.RoleID),
RoleID: ZeroAsNullID(i.RoleID),
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
@@ -26,7 +26,7 @@ func nullableIDToDomain(value *uint32) uint32 {
return *value
}
func DomainToNullableID(value uint32) *uint32 {
func ZeroAsNullID(value uint32) *uint32 {
if value == 0 {
return nil
}
@@ -43,6 +43,7 @@ func UserToDomain(m models.User) user.User {
DisplayName: m.DisplayName,
IsVerified: m.IsVerified,
RoleID: nullableIDToDomain(m.RoleID),
RoleAssignedByID: nullableIDToDomain(m.RoleAssignedByID),
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
@@ -60,7 +61,8 @@ func UserToModel(a user.User) models.User {
Email: a.Email,
DisplayName: a.DisplayName,
IsVerified: a.IsVerified,
RoleID: DomainToNullableID(a.RoleID),
RoleID: ZeroAsNullID(a.RoleID),
RoleAssignedByID: ZeroAsNullID(a.RoleAssignedByID),
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
@@ -13,6 +13,7 @@ type User struct {
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
RoleID *uint32 `gorm:"index"`
RoleAssignedByID *uint32
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
@@ -36,7 +36,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email,
IsVerified: user.IsVerified,
RoleID: mappers.DomainToNullableID(user.RoleID),
RoleID: mappers.ZeroAsNullID(user.RoleID),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
}
@@ -220,7 +220,7 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUser.User, error) {
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
if from == 0 {
@@ -228,7 +228,7 @@ func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUs
} else {
query = query.Where("role_id = ?", from)
}
result := query.Update("role_id", to)
result := query.Updates(map[string]any{"role_id": to, "role_assigned_by_id": mappers.ZeroAsNullID(assignedBy)})
if result.Error != nil {
return result.Error
}
@@ -266,14 +266,14 @@ func (u *UserRepository) TransferOwnership(from uint32, to uint32, ownerRoleID u
return domainUser.ErrInvalidTransfer
}
return u.db.Transaction(func(tx *gorm.DB) error {
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Update("role_id", formerRoleID)
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Updates(map[string]any{"role_id": formerRoleID, "role_assigned_by_id": nil})
if demoted.Error != nil {
return demoted.Error
}
if demoted.RowsAffected == 0 {
return domainUser.ErrRoleChanged
}
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Update("role_id", ownerRoleID)
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Updates(map[string]any{"role_id": ownerRoleID, "role_assigned_by_id": from})
if promoted.Error != nil {
return promoted.Error
}
@@ -92,12 +92,17 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
return user.User{}, err
}
inviterID, err := a.inviterOf(pending)
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,
RoleID: assigned.ID,
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
RoleID: assigned.ID,
RoleAssignedByID: inviterID,
})
if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid
@@ -109,6 +114,14 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
return createdUser, nil
}
func (a *AcceptInviteUseCase) inviterOf(pending invite.Invite) (uint32, error) {
assigner, err := resolveRoleAssigner(a.userRepository, pending.InvitedBy)
if err != nil || assigner == nil {
return 0, err
}
return assigner.ID, nil
}
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range a.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
@@ -48,10 +48,9 @@ func (u *ChangeUserRoleUseCase) ChangeRole(ctx context.Context, command ChangeUs
return DirectoryEntry{}, err
}
if held && current.ID == next.ID {
ref := NewRoleRef(next)
return userEntry(target, &ref), nil
return u.entryOf(target, next)
}
return u.write(ctx, target, current, next)
return u.write(ctx, command, target, current, held, next)
}
func (u *ChangeUserRoleUseCase) heldRole(target user.User) (role.Role, bool, error) {
@@ -81,10 +80,10 @@ func (u *ChangeUserRoleUseCase) checkCoverage(command ChangeUserRoleCommand, tar
return ensureCoveredBy(command.Caller, typeKeys, next)
}
func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, current role.Role, next role.Role) (DirectoryEntry, error) {
func (u *ChangeUserRoleUseCase) write(ctx context.Context, command ChangeUserRoleCommand, target user.User, current role.Role, held bool, next role.Role) (DirectoryEntry, error) {
event := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID, command.Caller.UserID)
if errors.Is(err, role.ErrRoleNotFound) {
return DirectoryEntry{}, role.ErrUnknownRole
}
@@ -92,12 +91,22 @@ func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, cur
u.recordWriteFailure(ctx, event, err)
return DirectoryEntry{}, err
}
event.Params[audit.ParamFrom] = current.ID
if held {
event.Params[audit.ParamFrom] = current.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
}
event.Params[audit.ParamTo] = next.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
u.recorder.Record(ctx, event)
ref := NewRoleRef(next)
return userEntry(updated, &ref), nil
return u.entryOf(updated, next)
}
func (u *ChangeUserRoleUseCase) entryOf(account user.User, assigned role.Role) (DirectoryEntry, error) {
assigner, err := resolveRoleAssigner(u.users, account.RoleAssignedByID)
if err != nil {
return DirectoryEntry{}, err
}
ref := NewRoleRef(assigned)
return userEntry(account, &ref, assigner), nil
}
func (u *ChangeUserRoleUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
@@ -6,16 +6,18 @@ import (
)
type Access struct {
Role role.Role
Permissions []role.Grant
Role role.Role
Permissions []role.Grant
RoleAssignedBy *RoleAssigner
}
type GetMyAccessUseCase struct {
roles role.Repository
users user.Repository
}
func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles}
func NewGetMyAccessUseCase(roles role.Repository, users user.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles, users: users}
}
func resolveRole(roles role.Repository, account user.User) (role.Role, error) {
@@ -30,5 +32,10 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
access := Access{Role: resolved, Permissions: resolved.EffectiveGrants()}
access.RoleAssignedBy, err = resolveRoleAssigner(u.users, account.RoleAssignedByID)
if err != nil {
return Access{}, err
}
return access, nil
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"errors"
"sort"
"time"
)
@@ -19,34 +20,67 @@ type ListUsersAndInvitesUseCase struct {
roles role.Repository
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
func newRoleAssigner(account user.User) *RoleAssigner {
return &RoleAssigner{ID: account.ID, Name: account.NameOrUsername()}
}
func resolveRoleAssigner(users user.Repository, id uint32) (*RoleAssigner, error) {
if id == 0 {
return nil, nil
}
found, err := users.Get(id)
if errors.Is(err, user.ErrUserNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return newRoleAssigner(found), nil
}
type DirectoryEntry struct {
ID uint32
Kind string
Username string
DisplayName string
Email string
Role *RoleRef
Status string
CreatedAt time.Time
ID uint32
Kind string
Username string
DisplayName string
Email string
Role *RoleRef
RoleAssignedBy *RoleAssigner
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository, roles}
}
func userEntry(account user.User, found *RoleRef) DirectoryEntry {
func userEntry(account user.User, found *RoleRef, assigner *RoleAssigner) DirectoryEntry {
return DirectoryEntry{
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
RoleAssignedBy: assigner,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
}
}
func assignerAmong(loaded map[uint32]user.User, id uint32) *RoleAssigner {
found, present := loaded[id]
if id == 0 || !present {
return nil
}
return newRoleAssigner(found)
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, error) {
refs, err := roleRefsByID(g.roles)
if err != nil {
@@ -55,8 +89,13 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, er
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID)))
users := g.userRepository.GetAll()
loaded := make(map[uint32]user.User, len(users))
for _, existingUser := range users {
loaded[existingUser.ID] = existingUser
}
for _, existingUser := range users {
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID), assignerAmong(loaded, existingUser.RoleAssignedByID)))
}
for _, existingInvite := range g.inviteRepository.GetAll() {