feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s

- /admin/users: list with search, role filter and cursor pages; create
  with a one-time temporary password; rename, change role, block;
  reset password; delete
- every change is checked against the role policy, a forbidden field
  rejects the whole patch; the last active admin is locked in the
  transaction and cannot be demoted, blocked or deleted
- role change and blocking revoke access tokens at once; blocking and
  password reset also end all sessions
- /admin/audit: newest-first journal with actor names
- path and query parameter errors are problem+json now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:18:02 +03:00
1 parent ed4a492dba
commit f075863f15
13 files changed
+2578 -70

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
package auth
import (
"context"
"pipu/api/internal/api/gen"
)
// ListUsers реализует gen.StrictServerInterface.
func (h *Handler) ListUsers(ctx context.Context, req gen.ListUsersRequestObject) (gen.ListUsersResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
var (
q, cursor string
role *Role
)
if req.Params.Q != nil {
q = *req.Params.Q
}
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
if req.Params.Role != nil {
r := Role(*req.Params.Role)
role = &r
}
users, next, err := h.svc.ListUsers(ctx, p, q, role, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AdminUser, 0, len(users))
for _, u := range users {
items = append(items, AdminUserDTO(u))
}
return gen.ListUsers200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// CreateUser реализует gen.StrictServerInterface.
func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObject) (gen.CreateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
if req.Body.DisplayName != nil {
in.DisplayName = *req.Body.DisplayName
}
u, password, err := h.svc.CreateUser(ctx, p, in)
if err != nil {
return nil, err
}
return gen.CreateUser201JSONResponse{User: AdminUserDTO(u), TemporaryPassword: password}, nil
}
// UpdateUser реализует gen.StrictServerInterface.
func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObject) (gen.UpdateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked}
if req.Body.Role != nil {
r := Role(*req.Body.Role)
patch.Role = &r
}
u, err := h.svc.UpdateUser(ctx, p, req.Id, patch)
if err != nil {
return nil, err
}
return gen.UpdateUser200JSONResponse(AdminUserDTO(u)), nil
}
// DeleteUser реализует gen.StrictServerInterface.
func (h *Handler) DeleteUser(ctx context.Context, req gen.DeleteUserRequestObject) (gen.DeleteUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.DeleteUser(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.DeleteUser204Response{}, nil
}
// ResetUserPassword реализует gen.StrictServerInterface.
func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswordRequestObject) (gen.ResetUserPasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
password, err := h.svc.ResetPassword(ctx, p, req.Id)
if err != nil {
return nil, err
}
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
}
// ListAudit реализует gen.StrictServerInterface.
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
cursor := ""
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
records, next, err := h.svc.ListAudit(ctx, p, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AuditEntry, 0, len(records))
for _, r := range records {
e := gen.AuditEntry{
Id: r.ID, Action: r.Action, TargetType: r.TargetType, TargetId: r.TargetID, CreatedAt: r.CreatedAt,
}
if r.ActorID != nil && r.ActorUsername != nil {
e.Actor = &gen.AuditActor{Id: *r.ActorID, Username: *r.ActorUsername}
}
if r.Payload != nil {
payload := r.Payload
e.Payload = &payload
}
items = append(items, e)
}
return gen.ListAudit200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// AdminUserDTO переводит пользователя в схему AdminUser.
func AdminUserDTO(u User) gen.AdminUser {
return gen.AdminUser{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
LastLoginAt: u.LastLoginAt,
}
}
func optional(s string) *string {
if s == "" {
return nil
}
return &s
}