Merge pull request #23 from rede5/Front-back-integracao-task5
implementa correções importantes no fluxo de autenticação para garantir que usuários pendentes de aprovação não tenham acesso ao sistema, além de melhorias na interface administrativa. Principais Alterações: Autenticação e Segurança: Login de Inativos: Adicionada verificação do campo ativo no login e na restauração de sessão ( AuthContext ). Usuários pendentes são impedidos de logar ou deslogados automaticamente. Auto-login no Registro: O sistema agora impede o login automático após o cadastro se o usuário necessitar de aprovação. Logout: Atualizado para chamar a rota /auth/logout na API, garantindo a invalidação dos cookies de sessão (HttpOnly). Interface de Aprovação (Admin): Dados Adicionais: Adicionada a coluna Telefone nas listagens de Empresas e Profissionais para facilitar a verificação. Correção de Interface: Refatoração do componente UserApproval.tsx para corrigir um glitch visual que ocorria ao alternar entre as abas, onde as colunas ficavam desalinhadas ou duplicadas. Como testar: Tente fazer login com um usuário que ainda não foi aprovado (deve receber erro ou ser impedido). Acesse a tela de "Aprovação de Cadastros" e verifique se o telefone aparece na lista. Alterne entre as abas "Empresas" e "Profissionais" para garantir que a tabela renderiza corretamente.
This commit is contained in:
commit
85e07c2f26
15 changed files with 637 additions and 405 deletions
|
|
@ -136,15 +136,7 @@ func main() {
|
|||
api := r.Group("/api")
|
||||
api.Use(auth.AuthMiddleware(cfg))
|
||||
{
|
||||
api.GET("/me", func(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
c.JSON(200, gin.H{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"message": "You are authenticated",
|
||||
})
|
||||
})
|
||||
api.GET("/me", authHandler.Me)
|
||||
|
||||
profGroup := api.Group("/profissionais")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1294,6 +1294,29 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"description": "Get current authenticated user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Get current user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.loginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/profissionais": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -2209,6 +2232,10 @@ const docTemplate = `{
|
|||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"empresa_id": {
|
||||
"description": "Optional, for EVENT_OWNER",
|
||||
"type": "string"
|
||||
},
|
||||
"nome": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -2242,12 +2269,21 @@ const docTemplate = `{
|
|||
"ativo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"company_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1288,6 +1288,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"description": "Get current authenticated user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Get current user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.loginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/profissionais": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -2203,6 +2226,10 @@
|
|||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"empresa_id": {
|
||||
"description": "Optional, for EVENT_OWNER",
|
||||
"type": "string"
|
||||
},
|
||||
"nome": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -2236,12 +2263,21 @@
|
|||
"ativo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"company_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ definitions:
|
|||
properties:
|
||||
email:
|
||||
type: string
|
||||
empresa_id:
|
||||
description: Optional, for EVENT_OWNER
|
||||
type: string
|
||||
nome:
|
||||
type: string
|
||||
role:
|
||||
|
|
@ -69,10 +72,16 @@ definitions:
|
|||
properties:
|
||||
ativo:
|
||||
type: boolean
|
||||
company_name:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
|
|
@ -1193,6 +1202,21 @@ paths:
|
|||
summary: Update function
|
||||
tags:
|
||||
- funcoes
|
||||
/api/me:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get current authenticated user
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/auth.loginResponse'
|
||||
summary: Get current user
|
||||
tags:
|
||||
- auth
|
||||
/api/profissionais:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ func NewHandler(service *Service) *Handler {
|
|||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Senha string `json:"senha" binding:"required,min=6"`
|
||||
Nome string `json:"nome" binding:"required"`
|
||||
Telefone string `json:"telefone"`
|
||||
Role string `json:"role" binding:"required"` // Role is now required
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Senha string `json:"senha" binding:"required,min=6"`
|
||||
Nome string `json:"nome" binding:"required"`
|
||||
Telefone string `json:"telefone"`
|
||||
Role string `json:"role" binding:"required"` // Role is now required
|
||||
EmpresaID string `json:"empresa_id"` // Optional, for EVENT_OWNER
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
|
|
@ -46,15 +47,27 @@ func (h *Handler) Register(c *gin.Context) {
|
|||
|
||||
// Create professional data only if role is appropriate
|
||||
var profData *profissionais.CreateProfissionalInput
|
||||
// COMMENTED OUT to enable 2-step registration (User -> Full Profile)
|
||||
// if req.Role == RolePhotographer || req.Role == RoleBusinessOwner {
|
||||
// profData = &profissionais.CreateProfissionalInput{
|
||||
// Nome: req.Nome,
|
||||
// Whatsapp: &req.Telefone,
|
||||
// }
|
||||
// }
|
||||
// For PHOTOGRAPHER or BUSINESS_OWNER, we might populate this if we were doing 1-step,
|
||||
// but actually 'nome' and 'telefone' are passed as args now.
|
||||
// We keep passing nil for profData because Service logic for Professionals relies on 'CreateProfissionalInput'
|
||||
// However, I updated Service to take nome/telefone directly.
|
||||
// Wait, the Service code I JUST wrote takes (email, senha, role, nome, telefone, empresaID, profissionalData).
|
||||
// If role is Photographer, the Service code checks `profissionalData`.
|
||||
// I should probably populate `profissionalData` if it's a professional.
|
||||
|
||||
user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, profData)
|
||||
if req.Role == "PHOTOGRAPHER" || req.Role == "BUSINESS_OWNER" {
|
||||
profData = &profissionais.CreateProfissionalInput{
|
||||
Nome: req.Nome,
|
||||
Whatsapp: &req.Telefone,
|
||||
}
|
||||
}
|
||||
|
||||
var empresaIDPtr *string
|
||||
if req.EmpresaID != "" {
|
||||
empresaIDPtr = &req.EmpresaID
|
||||
}
|
||||
|
||||
user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, empresaIDPtr, profData)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
|
||||
|
|
@ -117,10 +130,13 @@ type loginResponse struct {
|
|||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Ativo bool `json:"ativo"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Ativo bool `json:"ativo"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
CompanyName string `json:"company_name,omitempty"`
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
|
|
@ -258,6 +274,52 @@ func (h *Handler) Logout(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current user
|
||||
// @Description Get current authenticated user
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} loginResponse
|
||||
// @Router /api/me [get]
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
// User ID comes from context (AuthMiddleware)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// We can fetch fresh user data
|
||||
user, err := h.service.GetUser(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var empresaNome string
|
||||
if user.EmpresaNome.Valid {
|
||||
empresaNome = user.EmpresaNome.String
|
||||
}
|
||||
|
||||
resp := loginResponse{
|
||||
User: userResponse{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
Ativo: user.Ativo,
|
||||
Name: user.Nome,
|
||||
Phone: user.Whatsapp,
|
||||
CompanyName: empresaNome,
|
||||
},
|
||||
}
|
||||
// Note: We are not returning AccessToken/ExpiresAt here as they are already set/active.
|
||||
// But to match loginResponse structure we can leave them empty or fill appropriately if we were refreshing.
|
||||
// For session restore, we mainly need the User object.
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ListPending godoc
|
||||
// @Summary List pending users
|
||||
// @Description List users with ativo=false
|
||||
|
|
@ -287,23 +349,23 @@ func (h *Handler) ListPending(c *gin.Context) {
|
|||
|
||||
resp := make([]map[string]interface{}, len(users))
|
||||
for i, u := range users {
|
||||
var nome string
|
||||
if u.Nome.Valid {
|
||||
nome = u.Nome.String
|
||||
}
|
||||
var whatsapp string
|
||||
if u.Whatsapp.Valid {
|
||||
whatsapp = u.Whatsapp.String
|
||||
nome := u.Nome
|
||||
whatsapp := u.Whatsapp
|
||||
|
||||
var empresaNome string
|
||||
if u.EmpresaNome.Valid {
|
||||
empresaNome = u.EmpresaNome.String
|
||||
}
|
||||
|
||||
resp[i] = map[string]interface{}{
|
||||
"id": uuid.UUID(u.ID.Bytes).String(),
|
||||
"email": u.Email,
|
||||
"role": u.Role,
|
||||
"ativo": u.Ativo,
|
||||
"created_at": u.CriadoEm.Time,
|
||||
"name": nome, // Mapped to name for frontend compatibility
|
||||
"phone": whatsapp,
|
||||
"id": uuid.UUID(u.ID.Bytes).String(),
|
||||
"email": u.Email,
|
||||
"role": u.Role,
|
||||
"ativo": u.Ativo,
|
||||
"created_at": u.CriadoEm.Time,
|
||||
"name": nome, // Mapped to name for frontend compatibility
|
||||
"phone": whatsapp,
|
||||
"company_name": empresaNome,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,12 +565,20 @@ func (h *Handler) GetUser(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
var empresaNome string
|
||||
if user.EmpresaNome.Valid {
|
||||
empresaNome = user.EmpresaNome.String
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"ativo": user.Ativo,
|
||||
"created_at": user.CriadoEm.Time,
|
||||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"ativo": user.Ativo,
|
||||
"created_at": user.CriadoEm.Time,
|
||||
"name": user.Nome,
|
||||
"phone": user.Whatsapp,
|
||||
"company_name": empresaNome,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func NewService(queries *generated.Queries, profissionaisService *profissionais.
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, email, senha, role string, profissionalData *profissionais.CreateProfissionalInput) (*generated.Usuario, error) {
|
||||
func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefone string, empresaID *string, profissionalData *profissionais.CreateProfissionalInput) (*generated.Usuario, error) {
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
|
@ -71,6 +71,30 @@ func (s *Service) Register(ctx context.Context, email, senha, role string, profi
|
|||
}
|
||||
}
|
||||
|
||||
// If role is 'EVENT_OWNER', create client profile
|
||||
if role == RoleEventOwner {
|
||||
userID := user.ID
|
||||
var empID pgtype.UUID
|
||||
if empresaID != nil && *empresaID != "" {
|
||||
parsedEmpID, err := uuid.Parse(*empresaID)
|
||||
if err == nil {
|
||||
empID.Bytes = parsedEmpID
|
||||
empID.Valid = true
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.queries.CreateCadastroCliente(ctx, generated.CreateCadastroClienteParams{
|
||||
UsuarioID: userID,
|
||||
EmpresaID: empID,
|
||||
Nome: pgtype.Text{String: nome, Valid: nome != ""},
|
||||
Telefone: pgtype.Text{String: telefone, Valid: telefone != ""},
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.queries.DeleteUsuario(ctx, user.ID)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +261,7 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error {
|
|||
{"admin@photum.com", RoleSuperAdmin, "Dev Admin"},
|
||||
{"empresa@photum.com", RoleBusinessOwner, "PHOTUM CEO"},
|
||||
{"foto@photum.com", RolePhotographer, "COLABORADOR PHOTUM"},
|
||||
{"cliente@photum.com", RoleEventOwner, "CLIENTE TESTE"},
|
||||
}
|
||||
|
||||
for _, u := range demoUsers {
|
||||
|
|
@ -270,7 +295,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]generated.ListAllUsuariosRow
|
|||
return s.queries.ListAllUsuarios(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, id string) (*generated.Usuario, error) {
|
||||
func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuarioByIDRow, error) {
|
||||
parsedUUID, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ type AnosFormatura struct {
|
|||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
}
|
||||
|
||||
type CadastroCliente struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||
EmpresaID pgtype.UUID `json:"empresa_id"`
|
||||
Nome pgtype.Text `json:"nome"`
|
||||
Telefone pgtype.Text `json:"telefone"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
}
|
||||
|
||||
type CadastroFot struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Fot int32 `json:"fot"`
|
||||
|
|
|
|||
|
|
@ -11,6 +11,39 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createCadastroCliente = `-- name: CreateCadastroCliente :one
|
||||
INSERT INTO cadastro_clientes (usuario_id, empresa_id, nome, telefone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, usuario_id, empresa_id, nome, telefone, criado_em, atualizado_em
|
||||
`
|
||||
|
||||
type CreateCadastroClienteParams struct {
|
||||
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||
EmpresaID pgtype.UUID `json:"empresa_id"`
|
||||
Nome pgtype.Text `json:"nome"`
|
||||
Telefone pgtype.Text `json:"telefone"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroClienteParams) (CadastroCliente, error) {
|
||||
row := q.db.QueryRow(ctx, createCadastroCliente,
|
||||
arg.UsuarioID,
|
||||
arg.EmpresaID,
|
||||
arg.Nome,
|
||||
arg.Telefone,
|
||||
)
|
||||
var i CadastroCliente
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UsuarioID,
|
||||
&i.EmpresaID,
|
||||
&i.Nome,
|
||||
&i.Telefone,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createUsuario = `-- name: CreateUsuario :one
|
||||
INSERT INTO usuarios (email, senha_hash, role, ativo)
|
||||
VALUES ($1, $2, $3, false)
|
||||
|
|
@ -69,13 +102,33 @@ func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (Usuario,
|
|||
}
|
||||
|
||||
const getUsuarioByID = `-- name: GetUsuarioByID :one
|
||||
SELECT id, email, senha_hash, role, ativo, criado_em, atualizado_em FROM usuarios
|
||||
WHERE id = $1 LIMIT 1
|
||||
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
|
||||
COALESCE(cp.nome, cc.nome, '') as nome,
|
||||
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
|
||||
e.nome as empresa_nome
|
||||
FROM usuarios u
|
||||
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
|
||||
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
|
||||
LEFT JOIN empresas e ON cc.empresa_id = e.id
|
||||
WHERE u.id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (Usuario, error) {
|
||||
type GetUsuarioByIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
SenhaHash string `json:"senha_hash"`
|
||||
Role string `json:"role"`
|
||||
Ativo bool `json:"ativo"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
Nome string `json:"nome"`
|
||||
Whatsapp string `json:"whatsapp"`
|
||||
EmpresaNome pgtype.Text `json:"empresa_nome"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (GetUsuarioByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUsuarioByID, id)
|
||||
var i Usuario
|
||||
var i GetUsuarioByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
|
|
@ -84,6 +137,9 @@ func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (Usuario,
|
|||
&i.Ativo,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
&i.Nome,
|
||||
&i.Whatsapp,
|
||||
&i.EmpresaNome,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -132,21 +188,26 @@ func (q *Queries) ListAllUsuarios(ctx context.Context) ([]ListAllUsuariosRow, er
|
|||
|
||||
const listUsuariosPending = `-- name: ListUsuariosPending :many
|
||||
SELECT u.id, u.email, u.role, u.ativo, u.criado_em,
|
||||
cp.nome, cp.whatsapp
|
||||
COALESCE(cp.nome, cc.nome, '') as nome,
|
||||
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
|
||||
e.nome as empresa_nome
|
||||
FROM usuarios u
|
||||
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
|
||||
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
|
||||
LEFT JOIN empresas e ON cc.empresa_id = e.id
|
||||
WHERE u.ativo = false
|
||||
ORDER BY u.criado_em DESC
|
||||
`
|
||||
|
||||
type ListUsuariosPendingRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Ativo bool `json:"ativo"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
Nome pgtype.Text `json:"nome"`
|
||||
Whatsapp pgtype.Text `json:"whatsapp"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Ativo bool `json:"ativo"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
Nome string `json:"nome"`
|
||||
Whatsapp string `json:"whatsapp"`
|
||||
EmpresaNome pgtype.Text `json:"empresa_nome"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUsuariosPending(ctx context.Context) ([]ListUsuariosPendingRow, error) {
|
||||
|
|
@ -166,6 +227,7 @@ func (q *Queries) ListUsuariosPending(ctx context.Context) ([]ListUsuariosPendin
|
|||
&i.CriadoEm,
|
||||
&i.Nome,
|
||||
&i.Whatsapp,
|
||||
&i.EmpresaNome,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,15 @@ SELECT * FROM usuarios
|
|||
WHERE email = $1 LIMIT 1;
|
||||
|
||||
-- name: GetUsuarioByID :one
|
||||
SELECT * FROM usuarios
|
||||
WHERE id = $1 LIMIT 1;
|
||||
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
|
||||
COALESCE(cp.nome, cc.nome, '') as nome,
|
||||
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
|
||||
e.nome as empresa_nome
|
||||
FROM usuarios u
|
||||
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
|
||||
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
|
||||
LEFT JOIN empresas e ON cc.empresa_id = e.id
|
||||
WHERE u.id = $1 LIMIT 1;
|
||||
|
||||
-- name: DeleteUsuario :exec
|
||||
DELETE FROM usuarios
|
||||
|
|
@ -17,9 +24,13 @@ WHERE id = $1;
|
|||
|
||||
-- name: ListUsuariosPending :many
|
||||
SELECT u.id, u.email, u.role, u.ativo, u.criado_em,
|
||||
cp.nome, cp.whatsapp
|
||||
COALESCE(cp.nome, cc.nome, '') as nome,
|
||||
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
|
||||
e.nome as empresa_nome
|
||||
FROM usuarios u
|
||||
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
|
||||
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
|
||||
LEFT JOIN empresas e ON cc.empresa_id = e.id
|
||||
WHERE u.ativo = false
|
||||
ORDER BY u.criado_em DESC;
|
||||
|
||||
|
|
@ -38,3 +49,8 @@ RETURNING *;
|
|||
SELECT id, email, role, ativo, criado_em, atualizado_em
|
||||
FROM usuarios
|
||||
ORDER BY criado_em DESC;
|
||||
|
||||
-- name: CreateCadastroCliente :one
|
||||
INSERT INTO cadastro_clientes (usuario_id, empresa_id, nome, telefone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
|
|
|||
|
|
@ -301,3 +301,15 @@ BEGIN
|
|||
ON CONFLICT (tipo_evento_id, funcao_profissional_id) DO UPDATE SET valor = EXCLUDED.valor;
|
||||
|
||||
END $$;
|
||||
|
||||
-- Cadastro Clientes (Representatives of Companies)
|
||||
CREATE TABLE IF NOT EXISTS cadastro_clientes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
empresa_id UUID REFERENCES empresas(id) ON DELETE SET NULL,
|
||||
nome VARCHAR(255), -- Name of the representative
|
||||
telefone VARCHAR(20),
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(usuario_id)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||
import { User, UserRole } from '../types';
|
||||
|
||||
// Mock Users Database
|
||||
|
|
@ -38,14 +38,58 @@ interface AuthContextType {
|
|||
user: User | null;
|
||||
login: (email: string, password?: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
register: (data: { nome: string; email: string; senha: string; telefone: string; role: string }) => Promise<{ success: boolean; userId?: string; token?: string }>;
|
||||
register: (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string }) => Promise<{ success: boolean; userId?: string; token?: string }>;
|
||||
availableUsers: User[]; // Helper for the login screen demo
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem("token"));
|
||||
|
||||
useEffect(() => {
|
||||
const restoreSession = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const backendUser = data.user;
|
||||
const mappedUser: User = {
|
||||
id: backendUser.id,
|
||||
email: backendUser.email,
|
||||
name: backendUser.email.split('@')[0],
|
||||
role: backendUser.role as UserRole,
|
||||
ativo: backendUser.ativo,
|
||||
};
|
||||
if (!backendUser.ativo) {
|
||||
console.warn("User is not active, logging out.");
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
setUser(mappedUser);
|
||||
} else {
|
||||
// Token invalid or expired
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Session restore error:", err);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user && token) {
|
||||
restoreSession();
|
||||
}
|
||||
}, [token]); // removed 'user' from dependency to avoid loop if user is set, though !user check handles it. safer to just depend on token mount.
|
||||
|
||||
const getErrorMessage = (errorKey: string): string => {
|
||||
// Map backend error messages to Portuguese
|
||||
|
|
@ -93,9 +137,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
|
||||
const data = await response.json();
|
||||
|
||||
// Store token (optional, if you need it for other requests outside cookies)
|
||||
localStorage.setItem('token', data.access_token);
|
||||
|
||||
const backendUser = data.user;
|
||||
|
||||
// Enforce active check
|
||||
|
|
@ -103,6 +144,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
throw new Error("Cadastro pendente de aprovação.");
|
||||
}
|
||||
|
||||
// Store token (optional, if you need it for other requests outside cookies)
|
||||
localStorage.setItem('token', data.access_token);
|
||||
setToken(data.access_token);
|
||||
|
||||
// Map backend user to frontend User type
|
||||
const mappedUser: User = {
|
||||
id: backendUser.id,
|
||||
|
|
@ -121,17 +166,41 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token) {
|
||||
await fetch(`${import.meta.env.VITE_API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}` // Though refresh token is in cookie
|
||||
},
|
||||
// body: JSON.stringify({ refresh_token: ... }) // If we had it in client state, but it is in cookie.
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
// Force redirect or let app router handle it safely
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: { nome: string; email: string; senha: string; telefone: string; role: string }) => {
|
||||
const register = async (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string }) => {
|
||||
try {
|
||||
// Destructure to separate empresaId from the rest
|
||||
const { empresaId, ...rest } = data;
|
||||
const payload = {
|
||||
...rest,
|
||||
empresa_id: empresaId
|
||||
};
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -142,14 +211,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.access_token) {
|
||||
localStorage.setItem('token', responseData.access_token);
|
||||
}
|
||||
|
||||
// IF user is returned (auto-login), logic:
|
||||
// Only set user if they are ACTIVE (which they won't be for standard clients)
|
||||
// Only set user/token if they are ACTIVE (which they won't be for standard clients/professionals)
|
||||
// This allows the "Pending Approval" modal to show instead of auto-redirecting.
|
||||
if (responseData.user && responseData.user.ativo) {
|
||||
if (responseData.access_token) {
|
||||
localStorage.setItem('token', responseData.access_token);
|
||||
setToken(responseData.access_token);
|
||||
}
|
||||
|
||||
const backendUser = responseData.user;
|
||||
const mappedUser: User = {
|
||||
id: backendUser.id,
|
||||
|
|
@ -160,6 +230,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
};
|
||||
setUser(mappedUser);
|
||||
}
|
||||
// If user is NOT active, we do NOT set the token/user state, preventing auto-login.
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -173,7 +244,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, register, availableUsers: MOCK_USERS }}>
|
||||
<AuthContext.Provider value={{ user, token, login, logout, register, availableUsers: MOCK_USERS }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
|
|||
{ id: "1", name: "Dev Admin", email: "admin@photum.com", role: UserRole.SUPERADMIN },
|
||||
{ id: "2", name: "PHOTUM CEO", email: "empresa@photum.com", role: UserRole.BUSINESS_OWNER },
|
||||
{ id: "3", name: "COLABORADOR PHOTUM", email: "foto@photum.com", role: UserRole.PHOTOGRAPHER },
|
||||
{ id: "4", name: "CLIENTE TESTE", email: "cliente@photum.com", role: UserRole.EVENT_OWNER },
|
||||
].map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
|
|||
senha: formData.password,
|
||||
telefone: formData.phone,
|
||||
role: "EVENT_OWNER", // Client Role
|
||||
empresaId: formData.empresaId,
|
||||
});
|
||||
setIsLoading(false);
|
||||
setIsPending(true);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import React, { useState } from "react";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getPendingUsers,
|
||||
approveUser as apiApproveUser,
|
||||
rejectUser as apiRejectUser,
|
||||
} from "../services/apiService";
|
||||
import { UserApprovalStatus } from "../types";
|
||||
import {
|
||||
CheckCircle,
|
||||
|
|
@ -17,62 +22,111 @@ interface UserApprovalProps {
|
|||
}
|
||||
|
||||
export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||
const { pendingUsers, approveUser, rejectUser } = useData();
|
||||
const { token } = useAuth();
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | UserApprovalStatus>(
|
||||
"ALL"
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<"normal" | "professional">(
|
||||
"normal"
|
||||
const [activeTab, setActiveTab] = useState<"cliente" | "profissional">(
|
||||
"cliente"
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState<string | null>(null);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getPendingUsers(token);
|
||||
if (result.data) {
|
||||
// Mapear dados do backend para o formato esperado pelo componente, se necessário
|
||||
// Supondo que o backend retorna estrutura compatível ou fazemos o map aqui
|
||||
const mappedUsers = result.data.map((u: any) => ({
|
||||
...u,
|
||||
approvalStatus: u.ativo
|
||||
? UserApprovalStatus.APPROVED
|
||||
: UserApprovalStatus.PENDING, // Simplificação, backend deve retornar status real se houver rejected
|
||||
// Se o backend não retornar status explícito, assumimos pendente se !ativo
|
||||
// Mas idealmente o backend retornaria um status enum
|
||||
}));
|
||||
setUsers(mappedUsers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar usuários:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [token]);
|
||||
|
||||
const handleApprove = async (userId: string) => {
|
||||
if (!token) return;
|
||||
setIsProcessing(userId);
|
||||
// Simular processamento
|
||||
setTimeout(() => {
|
||||
approveUser(userId);
|
||||
try {
|
||||
await apiApproveUser(userId, token);
|
||||
// Atualizar lista após aprovação
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("Erro ao aprovar usuário:", error);
|
||||
alert("Erro ao aprovar usuário");
|
||||
} finally {
|
||||
setIsProcessing(null);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (userId: string) => {
|
||||
if (!token) return;
|
||||
setIsProcessing(userId);
|
||||
// Simular processamento
|
||||
setTimeout(() => {
|
||||
rejectUser(userId);
|
||||
try {
|
||||
await apiRejectUser(userId, token);
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("Erro ao rejeitar usuário:", error);
|
||||
alert("Erro ao rejeitar usuário");
|
||||
} finally {
|
||||
setIsProcessing(null);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
// Separar usuários normais e profissionais (profissionais têm role PHOTOGRAPHER)
|
||||
const normalUsers = pendingUsers.filter(
|
||||
(user) => user.role !== "PHOTOGRAPHER"
|
||||
// Separar usuários Clientes (EVENT_OWNER) e Profissionais (PHOTOGRAPHER)
|
||||
// Backend roles: PHOTOGRAPHER, EVENT_OWNER, BUSINESS_OWNER, SUPERADMIN
|
||||
const clientUsers = users.filter(
|
||||
(user) => user.role === "EVENT_OWNER"
|
||||
);
|
||||
const professionalUsers = pendingUsers.filter(
|
||||
const professionalUsers = users.filter(
|
||||
(user) => user.role === "PHOTOGRAPHER"
|
||||
);
|
||||
|
||||
// Filtrar usuários baseado na aba ativa
|
||||
const currentUsers = activeTab === "normal" ? normalUsers : professionalUsers;
|
||||
const currentUsers = activeTab === "cliente" ? clientUsers : professionalUsers;
|
||||
|
||||
const filteredUsers = currentUsers.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(user.registeredInstitution
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ??
|
||||
false);
|
||||
(user.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(user.email || "").toLowerCase().includes(searchTerm.toLowerCase());
|
||||
// Remover filtro por registeredInstitution se não vier do backend ainda
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === "ALL" || user.approvalStatus === statusFilter;
|
||||
// Por enquanto, como o backend retorna apenas pendentes na rota /pending (conforme nome da rota),
|
||||
// o statusFilter pode ser redundante se a rota só traz pendentes.
|
||||
// Mas se o backend trouxer todos, o filtro funciona.
|
||||
// Se a rota for /users/pending, assumimos que todos são pendentes.
|
||||
// VAMOS ASSUMIR QUE O BACKEND SÓ RETORNA PENDENTES POR ENQUANTO.
|
||||
// Mas para manter a UI, vamos considerar todos como Pendentes se status não vier.
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: UserApprovalStatus) => {
|
||||
switch (status) {
|
||||
// Se status undefined, assume pendente para visualização
|
||||
const s = status || UserApprovalStatus.PENDING;
|
||||
switch (s) {
|
||||
case UserApprovalStatus.PENDING:
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
|
|
@ -94,19 +148,11 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
Rejeitado
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = currentUsers.filter(
|
||||
(u) => u.approvalStatus === UserApprovalStatus.PENDING
|
||||
).length;
|
||||
const approvedCount = currentUsers.filter(
|
||||
(u) => u.approvalStatus === UserApprovalStatus.APPROVED
|
||||
).length;
|
||||
const rejectedCount = currentUsers.filter(
|
||||
(u) => u.approvalStatus === UserApprovalStatus.REJECTED
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
|
@ -124,41 +170,37 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("normal")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === "normal"
|
||||
? "border-[#B9CF33] text-[#B9CF33]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("cliente")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${activeTab === "cliente"
|
||||
? "border-[#B9CF33] text-[#B9CF33]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
Cadastros Normais
|
||||
Cadastros Empresas
|
||||
<span
|
||||
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${
|
||||
activeTab === "normal"
|
||||
? "bg-[#B9CF33] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${activeTab === "cliente"
|
||||
? "bg-[#B9CF33] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{normalUsers.length}
|
||||
{clientUsers.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("professional")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === "professional"
|
||||
? "border-[#B9CF33] text-[#B9CF33]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("profissional")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${activeTab === "profissional"
|
||||
? "border-[#B9CF33] text-[#B9CF33]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Briefcase className="w-5 h-5" />
|
||||
Cadastros Profissionais
|
||||
<span
|
||||
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${
|
||||
activeTab === "professional"
|
||||
? "bg-[#B9CF33] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${activeTab === "profissional"
|
||||
? "bg-[#B9CF33] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{professionalUsers.length}
|
||||
</span>
|
||||
|
|
@ -166,51 +208,6 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pendentes</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{pendingCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-100 rounded-full">
|
||||
<Clock className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Aprovados</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{approvedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-full">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rejeitados</p>
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
{rejectedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-100 rounded-full">
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
|
|
@ -219,183 +216,40 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome, email ou universidade..."
|
||||
placeholder="Buscar por nome ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-gold focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-gold focus:border-transparent"
|
||||
>
|
||||
<option value="ALL">Todos os Status</option>
|
||||
<option value={UserApprovalStatus.PENDING}>Pendentes</option>
|
||||
<option value={UserApprovalStatus.APPROVED}>Aprovados</option>
|
||||
<option value={UserApprovalStatus.REJECTED}>Rejeitados</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{activeTab === "normal" ? (
|
||||
// Tabela de Cadastros Normais
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Telefone
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Empresa
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Data de Cadastro
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Clock className="w-12 h-12 text-gray-300 mb-3" />
|
||||
<p className="text-lg font-medium">
|
||||
Nenhum cadastro encontrado
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Não há cadastros que correspondam aos filtros
|
||||
selecionados.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{user.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.phone || "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.registeredInstitution || (
|
||||
<span className="text-gray-400 italic">
|
||||
Não cadastrado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString(
|
||||
"pt-BR"
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(user.approvalStatus!)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.PENDING && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReject(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Rejeitar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.APPROVED && (
|
||||
<span className="text-green-600 text-xs">
|
||||
Aprovado
|
||||
</span>
|
||||
)}
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.REJECTED && (
|
||||
<span className="text-red-600 text-xs">
|
||||
Rejeitado
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Carregando solicitações...
|
||||
</div>
|
||||
) : (
|
||||
// Tabela de Cadastros Profissionais
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<table key={activeTab} className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
{activeTab === "cliente" && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Empresa
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Telefone
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Função Profissional
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Data de Cadastro
|
||||
</th>
|
||||
|
|
@ -411,32 +265,41 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
colSpan={activeTab === "cliente" ? 7 : 6}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Briefcase className="w-12 h-12 text-gray-300 mb-3" />
|
||||
{activeTab === "cliente" ? (
|
||||
<Users className="w-12 h-12 text-gray-300 mb-3" />
|
||||
) : (
|
||||
<Briefcase className="w-12 h-12 text-gray-300 mb-3" />
|
||||
)}
|
||||
<p className="text-lg font-medium">
|
||||
Nenhum cadastro profissional encontrado
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Não há cadastros profissionais que correspondam aos
|
||||
filtros selecionados.
|
||||
{activeTab === "cliente"
|
||||
? "Nenhum cadastro de empresa encontrado"
|
||||
: "Nenhum cadastro profissional encontrado"}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
filteredUsers.map((user, index) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
key={`${user.id}-${index}`}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{user.name}
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
</td>
|
||||
{activeTab === "cliente" && (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.company_name || "-"}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.email}
|
||||
|
|
@ -449,73 +312,52 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{(user as any).funcao || (
|
||||
<span className="text-gray-400 italic">
|
||||
Não cadastrado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString(
|
||||
"pt-BR"
|
||||
)
|
||||
{user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString(
|
||||
"pt-BR"
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(user.approvalStatus!)}
|
||||
{getStatusBadge(
|
||||
user.approvalStatus || UserApprovalStatus.PENDING
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.PENDING && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReject(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Rejeitar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.APPROVED && (
|
||||
<span className="text-green-600 text-xs">
|
||||
Aprovado
|
||||
</span>
|
||||
)}
|
||||
{user.approvalStatus ===
|
||||
UserApprovalStatus.REJECTED && (
|
||||
<span className="text-red-600 text-xs">
|
||||
Rejeitado
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReject(user.id)}
|
||||
isLoading={isProcessing === user.id}
|
||||
disabled={isProcessing !== null}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Rejeitar
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -262,3 +262,37 @@ export async function approveUser(userId: string, token: string): Promise<ApiRes
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejeita um usuário
|
||||
*/
|
||||
export async function rejectUser(userId: string, token: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin/users/${userId}/reject`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
// body: JSON.stringify({ reason }) // Future improvement
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
data,
|
||||
error: null,
|
||||
isBackendDown: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error rejecting user:", error);
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
isBackendDown: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue