From 958918cb8a301e71d19d26ff6d27f72d973103dc Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Thu, 25 Dec 2025 16:25:07 -0300 Subject: [PATCH] =?UTF-8?q?feat(team):=20fluxo=20completo=20de=20cadastro?= =?UTF-8?q?=20de=20equipe=20com=20senha,=20corre=C3=A7=C3=B5es=20de=20v?= =?UTF-8?q?=C3=ADnculo=20e=20avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: Adiciona campos de senha e visibilidade na tela de Equipe. - Frontend: Implementa criação de usuário prévia ao cadastro do profissional. - Backend (Auth): Remove criação duplicada de perfil e ativa usuários automaticamente. - Backend (Auth): Inclui dados do profissional (avatar) na resposta do endpoint /me. - Backend (Profissionais): Corrige chave de contexto ('role') para permitir vínculo correto de usuário. - Backend (Profissionais): Sincroniza exclusão para remover conta de usuário ao deletar profissional. - Docs: Atualização dos arquivos Swagger. --- backend/docs/docs.go | 4 + backend/docs/swagger.json | 4 + backend/docs/swagger.yaml | 3 + backend/internal/auth/handler.go | 19 +++- backend/internal/auth/service.go | 44 +++++--- backend/internal/profissionais/handler.go | 15 +++ backend/internal/profissionais/service.go | 40 ++++++- frontend/pages/Team.tsx | 123 ++++++++++++++++++++-- frontend/services/apiService.ts | 35 ++++++ 9 files changed, 259 insertions(+), 28 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index b990cb4..241c0ce 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3030,6 +3030,10 @@ const docTemplate = `{ "tabela_free": { "type": "string" }, + "target_user_id": { + "description": "Optional: For admin creation", + "type": "string" + }, "tem_estudio": { "type": "boolean" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 341971f..b83d61a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3024,6 +3024,10 @@ "tabela_free": { "type": "string" }, + "target_user_id": { + "description": "Optional: For admin creation", + "type": "string" + }, "tem_estudio": { "type": "boolean" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 3f47eea..7466503 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -296,6 +296,9 @@ definitions: type: integer tabela_free: type: string + target_user_id: + description: 'Optional: For admin creation' + type: string tem_estudio: type: boolean tipo_cartao: diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 85039b4..de30a78 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -361,9 +361,20 @@ func (h *Handler) Me(c *gin.Context) { CompanyID: empresaID, }, } - // 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. + + if user.Role == "PHOTOGRAPHER" || user.Role == "BUSINESS_OWNER" { + profData, err := h.service.GetProfessionalByUserID(c.Request.Context(), uuid.UUID(user.ID.Bytes).String()) + if err == nil && profData != nil { + resp.Profissional = map[string]interface{}{ + "id": uuid.UUID(profData.ID.Bytes).String(), + "nome": profData.Nome, + "funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(), + "funcao_profissional": profData.FuncaoNome.String, + "equipamentos": profData.Equipamentos.String, + "avatar_url": profData.AvatarUrl.String, + } + } + } c.JSON(http.StatusOK, resp) } @@ -468,7 +479,7 @@ func (h *Handler) AdminCreateUser(c *gin.Context) { } // Just reuse the request struct but call AdminCreateUser service - user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome) + user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, true) if err != nil { if strings.Contains(err.Error(), "duplicate key") { c.JSON(http.StatusConflict, gin.H{"error": "email already registered"}) diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 523b432..9b248e5 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -194,7 +194,7 @@ func (s *Service) ApproveUser(ctx context.Context, id string) error { return err } -func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome string) (*generated.Usuario, error) { +func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome string, ativo bool) (*generated.Usuario, error) { // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost) if err != nil { @@ -211,18 +211,22 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome return nil, err } - // If needed, create partial professional profile or just ignore for admin creation - // For simplicity, if name is provided and it's a professional role, we can stub it. - // But let's stick to basic user creation first as per plan. - // If the Admin creates a user, they might need to go to another endpoint to set profile. - // Or we can optionally create if name is present. - if (role == RolePhotographer || role == RoleBusinessOwner) && nome != "" { - userID := uuid.UUID(user.ID.Bytes).String() - _, _ = s.profissionaisService.Create(ctx, userID, profissionais.CreateProfissionalInput{ - Nome: nome, - }) + if ativo { + // Approve user immediately + err = s.ApproveUser(ctx, uuid.UUID(user.ID.Bytes).String()) + if err != nil { + // Log error but don't fail user creation? Or fail? + // Better to return error + return nil, err + } + // Refresh user object to reflect changes if needed, but ID and Email are same. + user.Ativo = true } + // Stub creation removed to prevent duplicate profiles. + // The frontend is responsible for creating the full professional profile + // immediately after user creation via the professionals service. + return &user, nil } @@ -270,7 +274,7 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error { existingUser, err := s.queries.GetUsuarioByEmail(ctx, u.Email) if err != nil { // User not found (or error), try to create - user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name) + user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name, true) if err != nil { return err } @@ -313,6 +317,22 @@ func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuario return &user, nil } +func (s *Service) GetProfessionalByUserID(ctx context.Context, userID string) (*generated.GetProfissionalByUsuarioIDRow, error) { + parsedUUID, err := uuid.Parse(userID) + if err != nil { + return nil, err + } + var pgID pgtype.UUID + pgID.Bytes = parsedUUID + pgID.Valid = true + + p, err := s.queries.GetProfissionalByUsuarioID(ctx, pgID) + if err != nil { + return nil, err + } + return &p, nil +} + func toPgText(s *string) pgtype.Text { if s == nil { return pgtype.Text{Valid: false} diff --git a/backend/internal/profissionais/handler.go b/backend/internal/profissionais/handler.go index 4a2eea8..fd169e8 100644 --- a/backend/internal/profissionais/handler.go +++ b/backend/internal/profissionais/handler.go @@ -217,6 +217,21 @@ func (h *Handler) Create(c *gin.Context) { return } + // Security: Only allow TargetUserID if user is ADMIN or OWNER + if input.TargetUserID != nil && *input.TargetUserID != "" { + userRole, exists := c.Get("role") + if !exists { + // Should validation fail? Or just ignore target? + // Safer to ignore target user ID if role not found + input.TargetUserID = nil + } else { + roleStr, ok := userRole.(string) + if !ok || (roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER") { + input.TargetUserID = nil + } + } + } + prof, err := h.service.Create(c.Request.Context(), userIDStr, input) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/internal/profissionais/service.go b/backend/internal/profissionais/service.go index 8d56f57..04cbd27 100644 --- a/backend/internal/profissionais/service.go +++ b/backend/internal/profissionais/service.go @@ -45,12 +45,18 @@ type CreateProfissionalInput struct { Equipamentos *string `json:"equipamentos"` Email *string `json:"email"` AvatarURL *string `json:"avatar_url"` + TargetUserID *string `json:"target_user_id"` // Optional: For admin creation } func (s *Service) Create(ctx context.Context, userID string, input CreateProfissionalInput) (*generated.CadastroProfissionai, error) { - usuarioUUID, err := uuid.Parse(userID) + finalUserID := userID + if input.TargetUserID != nil && *input.TargetUserID != "" { + finalUserID = *input.TargetUserID + } + + usuarioUUID, err := uuid.Parse(finalUserID) if err != nil { - return nil, errors.New("invalid usuario_id from context") + return nil, errors.New("invalid usuario_id") } var funcaoUUID uuid.UUID @@ -198,7 +204,35 @@ func (s *Service) Delete(ctx context.Context, id string) error { if err != nil { return errors.New("invalid id") } - return s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) + + // Get professional to find associated user + prof, err := s.queries.GetProfissionalByID(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) + if err != nil { + return err + } + + // Delete professional profile (should be done first or after?) + // If foreign key is SET NULL, it doesn't strictly matter for FK constraint, + // but logically deleting the profile first is cleaner if we want to ensure profile is gone. + // Actually, if we delete User first and it SETS NULL, we still have to delete Profile. + // So let's delete Profile first. + err = s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) + if err != nil { + return err + } + + // Delete associated user if exists + if prof.UsuarioID.Valid { + err = s.queries.DeleteUsuario(ctx, prof.UsuarioID) + if err != nil { + // Create warning log? For now just return error or ignore? + // If user deletion fails, it's orphan but harmless-ish (except login). + // Better to return error. + return err + } + } + + return nil } // Helpers diff --git a/frontend/pages/Team.tsx b/frontend/pages/Team.tsx index 313f03e..f60748d 100644 --- a/frontend/pages/Team.tsx +++ b/frontend/pages/Team.tsx @@ -22,6 +22,8 @@ import { AlertTriangle, Check, DollarSign, + Eye, + EyeOff, } from "lucide-react"; import { Button } from "../components/Button"; import { @@ -64,10 +66,12 @@ export const TeamPage: React.FC = () => { const [viewProfessional, setViewProfessional] = useState(null); // Form State - const initialFormState: CreateProfessionalDTO = { + const initialFormState: CreateProfessionalDTO & { senha?: string; confirmarSenha?: string } = { nome: "", funcao_profissional_id: "", email: "", + senha: "", + confirmarSenha: "", whatsapp: "", cpf_cnpj_titular: "", endereco: "", @@ -92,9 +96,12 @@ export const TeamPage: React.FC = () => { avatar_url: "", }; - const [formData, setFormData] = useState(initialFormState); + const [formData, setFormData] = useState(initialFormState); const [avatarFile, setAvatarFile] = useState(null); const [avatarPreview, setAvatarPreview] = useState(""); + // Password Visibility + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); // Fetch Data useEffect(() => { @@ -228,6 +235,8 @@ export const TeamPage: React.FC = () => { nome: professional.nome, funcao_profissional_id: professional.funcao_profissional_id, email: professional.email || "", + senha: "", // Não editamos senha aqui + confirmarSenha: "", whatsapp: professional.whatsapp || "", cpf_cnpj_titular: professional.cpf_cnpj_titular || "", endereco: professional.endereco || "", @@ -287,6 +296,20 @@ export const TeamPage: React.FC = () => { setIsSubmitting(true); try { + // Validation for password on creation + if (!isEdit && (formData.senha || formData.confirmarSenha)) { + if (formData.senha !== formData.confirmarSenha) { + alert("As senhas não coincidem!"); + setIsSubmitting(false); + return; + } + if (formData.senha && formData.senha.length < 6) { + alert("A senha deve ter pelo menos 6 caracteres."); + setIsSubmitting(false); + return; + } + } + let finalAvatarUrl = formData.avatar_url; // Handle Avatar Upload if new file selected @@ -298,13 +321,52 @@ export const TeamPage: React.FC = () => { } } - const payload = { ...formData, avatar_url: finalAvatarUrl }; + const payload: any = { ...formData, avatar_url: finalAvatarUrl }; + // Remove password fields from professional payload + delete payload.senha; + delete payload.confirmarSenha; if (isEdit && selectedProfessional) { await updateProfessional(selectedProfessional.id, payload, token); alert("Profissional atualizado com sucesso!"); } else { - await createProfessional(payload, token); + // Create User First (if password provided or mandatory logic?) + // If password is provided, we must create a user account. + // User requested: "ao cadastrar um novo profissional falta cadastrar a senha ... pra que esse profissional acesse a interface" + // So we should try to create user first. + let targetUserId = ""; + + if (formData.email && formData.senha) { + const { adminCreateUser } = await import("../services/apiService"); + const createRes = await adminCreateUser({ + email: formData.email, + senha: formData.senha, + nome: formData.nome, + role: "PHOTOGRAPHER", // Default role for professionals created here? Or map from selected role? + // Mapear função? Usually PHOTOGRAPHER or generic. Let's assume PHOTOGRAPHER for now as they are "Equipe". + tipo_profissional: roles.find(r => r.id === formData.funcao_profissional_id)?.nome || "", + ativo: true, // Auto-active as per request + }, token); + + if (createRes.error) { + // If user API fails (e.g. email exists), we stop? Or let create professional proceed unlinked? + // User requirement implies linked account. + // If email exists, maybe we can't create user, but we can check if we should link to existing? + // For simplicity, error out. + throw new Error("Erro ao criar usuário de login: " + createRes.error); + } + if (createRes.data && createRes.data.id) { + targetUserId = createRes.data.id; + } + } + + if (targetUserId) { + payload.target_user_id = targetUserId; + } + + const res = await createProfessional(payload, token); + if (res.error) throw new Error(res.error); + alert("Profissional criado com sucesso!"); } @@ -312,11 +374,10 @@ export const TeamPage: React.FC = () => { setShowEditModal(false); fetchData(); // Reset form - // Reset form resetForm(); - } catch (error) { + } catch (error: any) { console.error("Error submitting form:", error); - alert("Erro ao salvar profissional. Verifique o console."); + alert(error.message || "Erro ao salvar profissional. Verifique o console."); } finally { setIsSubmitting(false); } @@ -553,9 +614,53 @@ export const TeamPage: React.FC = () => {
- - setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" /> + + setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
+ {!showEditModal && ( + <> +
+ +
+ setFormData({ ...formData, senha: e.target.value })} + minLength={6} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10" + /> + +
+
+
+ +
+ setFormData({ ...formData, confirmarSenha: e.target.value })} + minLength={6} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10" + /> + +
+
+ + )}
setFormData({ ...formData, whatsapp: maskPhone(e.target.value) })} maxLength={15} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" /> diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index fb161bd..50fd741 100644 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -753,6 +753,41 @@ export async function updateEventStatus(token: string, eventId: string, status: } } +/** + * Cria um usuário pela interface administrativa + */ +export async function adminCreateUser(data: any, token: string): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/api/admin/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const responseData = await response.json(); + return { + data: responseData, + error: null, + isBackendDown: false, + }; + } catch (error) { + console.error("Error creating admin user:", error); + return { + data: null, + error: error instanceof Error ? error.message : "Erro desconhecido", + isBackendDown: true, + }; + } +} + /** * Obtém URL pré-assinada para upload de arquivo */