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

@@ -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() {