first commit
This commit is contained in:
209
backend/internal/users/invitations.go
Normal file
209
backend/internal/users/invitations.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvitationNotFound = errors.New("invitation not found")
|
||||
|
||||
type Invitation struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ClientID *string `json:"client_id"`
|
||||
ClientName *string `json:"client_name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AcceptedAt *time.Time `json:"accepted_at"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
InvitationURL string `json:"invitation_url,omitempty"`
|
||||
}
|
||||
|
||||
type CreateInvitationInput struct {
|
||||
Email string
|
||||
Role string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func NewInvitationToken() (string, string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return token, HashInvitationToken(token), nil
|
||||
}
|
||||
|
||||
func HashInvitationToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO invitations (email, role, client_id, token_hash, expires_at, created_by)
|
||||
VALUES (lower($1), $2, NULLIF($3, '')::uuid, $4, $5, NULLIF($6, '')::uuid)
|
||||
RETURNING
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at
|
||||
`, input.Email, input.Role, input.ClientID, input.TokenHash, input.ExpiresAt, input.CreatedBy)
|
||||
|
||||
return scanInvitation(row)
|
||||
}
|
||||
|
||||
func (s Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
clients.name,
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at
|
||||
FROM invitations
|
||||
LEFT JOIN clients ON clients.id = invitations.client_id
|
||||
ORDER BY invitations.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
invitations := []Invitation{}
|
||||
for rows.Next() {
|
||||
invitation, err := scanInvitation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, invitation)
|
||||
}
|
||||
|
||||
return invitations, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) AcceptInvitation(ctx context.Context, tokenHash string, name string, passwordHash string) (User, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var invitation Invitation
|
||||
var rawTokenHash string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at,
|
||||
invitations.token_hash
|
||||
FROM invitations
|
||||
WHERE invitations.token_hash = $1
|
||||
AND invitations.accepted_at IS NULL
|
||||
AND invitations.expires_at > now()
|
||||
FOR UPDATE
|
||||
`, tokenHash).Scan(
|
||||
&invitation.ID,
|
||||
&invitation.Email,
|
||||
&invitation.Role,
|
||||
&invitation.ClientID,
|
||||
&invitation.ClientName,
|
||||
&invitation.ExpiresAt,
|
||||
&invitation.AcceptedAt,
|
||||
&invitation.CreatedBy,
|
||||
&invitation.CreatedAt,
|
||||
&rawTokenHash,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user, err := scanUser(tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, role, status)
|
||||
VALUES ($1, $2, $3, $4, 'active')
|
||||
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
`, invitation.Email, name, passwordHash, invitation.Role))
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if invitation.ClientID != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO client_users (client_id, user_id)
|
||||
VALUES ($1::uuid, $2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, *invitation.ClientID, user.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE invitations
|
||||
SET accepted_at = now()
|
||||
WHERE token_hash = $1
|
||||
`, rawTokenHash); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
type invitationScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanInvitation(row invitationScanner) (Invitation, error) {
|
||||
var invitation Invitation
|
||||
err := row.Scan(
|
||||
&invitation.ID,
|
||||
&invitation.Email,
|
||||
&invitation.Role,
|
||||
&invitation.ClientID,
|
||||
&invitation.ClientName,
|
||||
&invitation.ExpiresAt,
|
||||
&invitation.AcceptedAt,
|
||||
&invitation.CreatedBy,
|
||||
&invitation.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Invitation{}, ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
20
backend/internal/users/model.go
Normal file
20
backend/internal/users/model.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package users
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "super_admin"
|
||||
RoleAgencyUser = "agency_user"
|
||||
RoleClientViewer = "client_viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
118
backend/internal/users/routes.go
Normal file
118
backend/internal/users/routes.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
store Store
|
||||
appPublicURL string
|
||||
}
|
||||
|
||||
func NewRoutes(store Store, appPublicURL string) Routes {
|
||||
return Routes{
|
||||
store: store,
|
||||
appPublicURL: strings.TrimRight(appPublicURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
router.Use(requireAuth)
|
||||
|
||||
router.GET("", r.list)
|
||||
router.GET("/invitations", r.listInvitations)
|
||||
router.POST("/invitations", r.createInvitation)
|
||||
}
|
||||
|
||||
func (r Routes) list(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
users, err := r.store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar usuarios."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func (r Routes) listInvitations(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
invitations, err := r.store.ListInvitations(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar convites."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"invitations": invitations})
|
||||
}
|
||||
|
||||
type createInvitationRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
func (r Routes) createInvitation(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createInvitationRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados do convite."})
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(input.Role)
|
||||
if role != RoleAgencyUser && role != RoleClientViewer {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Perfil invalido para convite."})
|
||||
return
|
||||
}
|
||||
if role == RoleClientViewer && strings.TrimSpace(input.ClientID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Convites de cliente precisam de um cliente vinculado."})
|
||||
return
|
||||
}
|
||||
|
||||
token, tokenHash, err := NewInvitationToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel gerar o convite."})
|
||||
return
|
||||
}
|
||||
|
||||
invitation, err := r.store.CreateInvitation(c.Request.Context(), CreateInvitationInput{
|
||||
Email: strings.TrimSpace(input.Email),
|
||||
Role: role,
|
||||
ClientID: strings.TrimSpace(input.ClientID),
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
CreatedBy: c.GetString("auth_user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o convite."})
|
||||
return
|
||||
}
|
||||
|
||||
invitation.InvitationURL = r.invitationURL(token)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"invitation": invitation})
|
||||
}
|
||||
|
||||
func (r Routes) invitationURL(token string) string {
|
||||
if r.appPublicURL == "" {
|
||||
return "/accept-invitation?token=" + token
|
||||
}
|
||||
return r.appPublicURL + "/accept-invitation?token=" + token
|
||||
}
|
||||
100
backend/internal/users/store.go
Normal file
100
backend/internal/users/store.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func (s Store) FindByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE lower(email) = lower($1)
|
||||
`, strings.TrimSpace(email))
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s Store) FindByID(ctx context.Context, id string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
user, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) UpsertSuperAdmin(ctx context.Context, email string, passwordHash string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, role, status)
|
||||
VALUES ($1, 'Super Admin', $2, 'super_admin', 'active')
|
||||
ON CONFLICT (email)
|
||||
DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = 'super_admin',
|
||||
status = 'active',
|
||||
updated_at = now()
|
||||
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
`, strings.TrimSpace(email), passwordHash)
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var user User
|
||||
err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Name,
|
||||
&user.PasswordHash,
|
||||
&user.Role,
|
||||
&user.Status,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
Reference in New Issue
Block a user