diff --git a/README.md b/README.md index 9bcf4da..1256be4 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,60 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe ## Visão geral - **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa. -- **Stack**: Go (backend), Next.js 14 (dashboard e site), PostgreSQL, Traefik, Docker. -- **Status**: fluxo de autenticação e gestão de agências concluído; ambiente dockerizável pronto para uso local. +- **Stack**: Go (backend), Next.js 16 (dashboard e site), PostgreSQL, Traefik, Docker. +- **Status**: Sistema multi-tenant completo com segurança cross-tenant validada, branding dinâmico e file serving via API. ## Componentes principais - `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`). +- `front-end-agency/`: Painel Next.js para agências - branding dinâmico, upload de logos, gestão de perfil e autenticação tenant-aware. - `front-end-dash.aggios.app/`: painel Next.js – login do superadmin, listagem de agências, exibição detalhada e exclusão definitiva. - `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design. - `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários). - `traefik/`: reverse proxy e certificados automatizados. ## Funcionalidades entregues -- **Redesign da Interface (v1.2)**: Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual em todas as rotas principais (Login, Dashboard, Agências, Cadastro). -- **Gestão Avançada de Agências**: - - Listagem com filtros robustos: Busca textual, Status (Ativo/Inativo) e Filtros de Data (Presets de 7/15/30 dias e intervalo personalizado). - - Detalhamento completo da agência com visualização de logo, cores e dados cadastrais. - - Edição e Exclusão de agências. -- **Login de Superadmin**: Autenticação via JWT com restrição de rotas protegidas. -- **Cadastro de Agências**: Criação de tenant e usuário administrador atrelado. -- **Proxy Interno**: Camada de API no Next.js (`app/api/...`) garantindo chamadas autenticadas e seguras ao backend Go. -- **Site Institucional**: Suporte a dark mode, componentes compartilhados e tokens de design centralizados. -- **Documentação**: Atualizada em `1. docs/` com fluxos, arquiteturas e changelog. + +### **v1.4 - Segurança Multi-tenant e File Serving (13/12/2025)** +- **🔒 Segurança Cross-Tenant Crítica**: + - Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication) + - Validação de tenant em todas rotas protegidas via middleware + - Mensagens de erro genéricas (sem exposição de arquitetura multi-tenant) + - Logs detalhados de tentativas de acesso cross-tenant bloqueadas + +- **📁 File Serving via API**: + - Nova rota `/api/files/{bucket}/{path}` para servir arquivos do MinIO através do backend Go + - Eliminação de dependência de DNS (`files.localhost`) - arquivos servidos via `api.localhost` + - Headers de cache otimizados (Cache-Control: public, max-age=31536000) + - CORS e content-type corretos automaticamente + +- **🎨 Melhorias de UX**: + - Mensagens de erro humanizadas no formulário de login (sem pop-ups/toasts) + - Erros inline com ícones e cores apropriadas + - Feedback em tempo real ao digitar (limpeza automática de erros) + - Mensagens específicas para cada tipo de erro (401, 403, 404, 429, 5xx) + +- **🔧 Melhorias Técnicas**: + - Next.js middleware injetando headers `X-Tenant-Subdomain` para routing correto + - TenantDetector middleware prioriza headers customizados sobre Host + - Upload de logos retorna URLs via API ao invés de MinIO direto + - Configuração MinIO com variáveis de ambiente `MINIO_SERVER_URL` e `MINIO_BROWSER_REDIRECT_URL` + +### **v1.3 - Branding Dinâmico e Favicon (12/12/2025)** +- **Branding Multi-tenant**: Logo, favicon e cores personalizadas por agência +- **Favicon Dinâmico**: Atualização em tempo real via localStorage e SSR metadata +- **Upload de Arquivos**: Sistema de upload para MinIO com bucket público +- **Rate Limiting**: 1000 requisições/minuto por IP + +### **v1.2 - Redesign Interface Flat** +- Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual +- Gestão avançada de agências com filtros robustos +- Detalhamento completo com visualização de branding + +### **v1.1 - Fundação Multi-tenant** +- Login de Superadmin com JWT +- Cadastro de Agências +- Proxy Interno Next.js para chamadas autenticadas +- Site Institucional com dark mode ## Executando o projeto 1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais). @@ -34,15 +67,35 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe docker-compose up --build ``` 4. **Hosts locais**: - - Painel: `https://dash.localhost` - - Site: `https://aggios.app.localhost` - - API: `https://api.localhost` + - Painel SuperAdmin: `http://dash.localhost` + - Painel Agência: `http://{agencia}.localhost` (ex: `http://idealpages.localhost`) + - Site: `http://aggios.app.localhost` + - API: `http://api.localhost` + - Console MinIO: `http://minio.localhost` (admin: minioadmin / M1n10_S3cur3_P@ss_2025!) 5. **Credenciais padrão**: ver `backend/internal/data/postgres/init-db.sql` para usuário superadmin seed. +## Segurança +- ✅ **Cross-Tenant Authentication**: Usuários não podem fazer login em agências que não pertencem +- ✅ **Tenant Isolation**: Todas rotas protegidas validam tenant_id no JWT vs tenant_id do contexto +- ✅ **Erro Handling**: Mensagens genéricas que não expõem arquitetura interna +- ✅ **JWT Validation**: Tokens validados em cada requisição autenticada +- ✅ **Rate Limiting**: 1000 req/min por IP para prevenir brute force + ## Estrutura de diretórios (resumo) ``` backend/ API Go (config, domínio, handlers, serviços) + internal/ + api/ + handlers/ + files.go 🆕 Handler para servir arquivos via API + auth.go 🔒 Validação cross-tenant no login + middleware/ + auth.go 🔒 Validação tenant em rotas protegidas + tenant.go 🔧 Detecção de tenant via headers backend/internal/data/postgres/ Scripts SQL de seed +front-end-agency/ 🆕 Dashboard Next.js para Agências + app/login/page.tsx 🎨 Login com mensagens humanizadas + middleware.ts 🔧 Injeção de headers tenant front-end-dash.aggios.app/ Dashboard Next.js Superadmin frontend-aggios.app/ Site institucional Next.js traefik/ Regras de roteamento e TLS @@ -51,15 +104,21 @@ traefik/ Regras de roteamento e TLS ## Testes e validação - Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais. -- Requisições de verificação recomendadas: - - `curl http://api.localhost/api/admin/agencies` (lista) – requer token JWT válido. - - `curl http://dash.localhost/api/admin/agencies` (proxy Next) – usado pelo painel. - - Fluxo manual via painel `dash.localhost/superadmin`. +- **Testes de Segurança**: + - ✅ Tentativa de login cross-tenant retorna 403 + - ✅ JWT de uma agência não funciona em outra agência + - ✅ Logs registram tentativas de acesso cross-tenant +- **Testes de File Serving**: + - ✅ Upload de logo gera URL via API (`http://api.localhost/api/files/...`) + - ✅ Imagens carregam sem problemas de CORS ou DNS + - ✅ Cache headers aplicados corretamente ## Próximos passos sugeridos -- Implementar soft delete e trilhas de auditoria para exclusão de agências. -- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard. -- Disponibilizar pipeline CI/CD com validações de lint/build. +- Implementar soft delete e trilhas de auditoria para exclusão de agências +- Adicionar validação de permissões por tenant em rotas de files (se necessário) +- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard +- Disponibilizar pipeline CI/CD com validações de lint/build ## Repositório -- Principal: https://git.stackbyte.cloud/erik/aggios.app.git \ No newline at end of file +- Principal: https://git.stackbyte.cloud/erik/aggios.app.git +- Branch: dev-1.4 (Segurança Multi-tenant + File Serving) \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 17b3f00..07039c4 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -66,12 +66,13 @@ func main() { // Initialize handlers healthHandler := handlers.NewHealthHandler() authHandler := handlers.NewAuthHandler(authService) - agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo) + agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg) agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) + filesHandler := handlers.NewFilesHandler(cfg) // Initialize upload handler uploadHandler, err := handlers.NewUploadHandler(cfg) @@ -116,6 +117,7 @@ func main() { // Tenant check (public) router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET") + router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET") // Hash generator (dev only - remove in production) router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST") @@ -170,6 +172,9 @@ func main() { // Agency logo upload (protected) router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST") + // File serving route (public - serves files from MinIO through API) + router.PathPrefix("/api/files/{bucket}/").HandlerFunc(filesHandler.ServeFile).Methods("GET") + // Company routes (protected) router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET") router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST") diff --git a/backend/internal/api/handlers/agency_logo.go b/backend/internal/api/handlers/agency_logo.go index dcaba57..60a2cfa 100644 --- a/backend/internal/api/handlers/agency_logo.go +++ b/backend/internal/api/handlers/agency_logo.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "path/filepath" + "strings" "time" "aggios-app/backend/internal/api/middleware" @@ -172,20 +173,28 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { return } - // Generate public URL - logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename) + // Generate public URL through API (not direct MinIO access) + // This is more secure and doesn't require DNS configuration + logoURL := fmt.Sprintf("http://api.localhost/api/files/%s/%s", bucketName, filename) log.Printf("Logo uploaded successfully: %s", logoURL) // Delete old logo file from MinIO if exists if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" { // Extract object key from URL - // Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png + // Example: http://api.localhost/api/files/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png oldFilename := "" if len(currentLogoURL) > 0 { - // Split by bucket name - if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) { - oldFilename = currentLogoURL[idx:] + // Split by /api/files/{bucket}/ to get the file path + apiPrefix := fmt.Sprintf("http://api.localhost/api/files/%s/", bucketName) + if strings.HasPrefix(currentLogoURL, apiPrefix) { + oldFilename = strings.TrimPrefix(currentLogoURL, apiPrefix) + } else { + // Fallback for old MinIO URLs + baseURL := fmt.Sprintf("%s/%s/", h.config.Minio.PublicURL, bucketName) + if len(currentLogoURL) > len(baseURL) { + oldFilename = currentLogoURL[len(baseURL):] + } } } @@ -202,6 +211,8 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { // Update tenant record in database var err2 error + log.Printf("Updating database: tenant_id=%s, logo_type=%s, logo_url=%s", tenantID, logoType, logoURL) + if logoType == "horizontal" { _, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID) } else { @@ -209,10 +220,12 @@ func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { } if err2 != nil { - log.Printf("Failed to update logo: %v", err2) - http.Error(w, "Failed to update database", http.StatusInternalServerError) + log.Printf("ERROR: Failed to update logo in database: %v", err2) + http.Error(w, fmt.Sprintf("Failed to update database: %v", err2), http.StatusInternalServerError) return } + + log.Printf("SUCCESS: Logo saved to database successfully!") // Return success response response := map[string]string{ diff --git a/backend/internal/api/handlers/agency_profile.go b/backend/internal/api/handlers/agency_profile.go index 0a79642..e85128a 100644 --- a/backend/internal/api/handlers/agency_profile.go +++ b/backend/internal/api/handlers/agency_profile.go @@ -6,6 +6,7 @@ import ( "net/http" "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/config" "aggios-app/backend/internal/repository" "github.com/google/uuid" @@ -13,11 +14,13 @@ import ( type AgencyHandler struct { tenantRepo *repository.TenantRepository + config *config.Config } -func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler { +func NewAgencyHandler(tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyHandler { return &AgencyHandler{ tenantRepo: tenantRepo, + config: cfg, } } diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 8f64964..7e79611 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "aggios-app/backend/internal/api/middleware" "aggios-app/backend/internal/domain" "aggios-app/backend/internal/service" ) @@ -96,6 +97,23 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { return } + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant do usuário corresponde ao subdomain acessado + tenantIDFromContext := "" + if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil { + tenantIDFromContext, _ = ctxTenantID.(string) + } + + // Se foi detectado um tenant no contexto (não é superadmin ou site institucional) + if tenantIDFromContext != "" && response.User.TenantID != nil { + userTenantID := response.User.TenantID.String() + if userTenantID != tenantIDFromContext { + log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", userTenantID, tenantIDFromContext) + http.Error(w, "Forbidden: Invalid credentials for this tenant", http.StatusForbidden) + return + } + log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", userTenantID) + } + log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) diff --git a/backend/internal/api/handlers/files.go b/backend/internal/api/handlers/files.go new file mode 100644 index 0000000..0a72e32 --- /dev/null +++ b/backend/internal/api/handlers/files.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strings" + + "aggios-app/backend/internal/config" + + "github.com/gorilla/mux" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type FilesHandler struct { + config *config.Config +} + +func NewFilesHandler(cfg *config.Config) *FilesHandler { + return &FilesHandler{ + config: cfg, + } +} + +// ServeFile serves files from MinIO through the API +func (h *FilesHandler) ServeFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + bucket := vars["bucket"] + + // Get the file path (everything after /api/files/{bucket}/) + prefix := fmt.Sprintf("/api/files/%s/", bucket) + filePath := strings.TrimPrefix(r.URL.Path, prefix) + + if filePath == "" { + http.Error(w, "File path is required", http.StatusBadRequest) + return + } + + log.Printf("📁 Serving file: bucket=%s, path=%s", bucket, filePath) + + // Initialize MinIO client + minioClient, err := minio.New("aggios-minio:9000", &minio.Options{ + Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""), + Secure: false, + }) + if err != nil { + log.Printf("Failed to create MinIO client: %v", err) + http.Error(w, "Storage service unavailable", http.StatusInternalServerError) + return + } + + // Get object from MinIO + ctx := context.Background() + object, err := minioClient.GetObject(ctx, bucket, filePath, minio.GetObjectOptions{}) + if err != nil { + log.Printf("Failed to get object: %v", err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer object.Close() + + // Get object info for content type and size + objInfo, err := object.Stat() + if err != nil { + log.Printf("Failed to stat object: %v", err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + + // Set appropriate headers + w.Header().Set("Content-Type", objInfo.ContentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", objInfo.Size)) + w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Copy file content to response + _, err = io.Copy(w, object) + if err != nil { + log.Printf("Failed to copy object content: %v", err) + return + } + + log.Printf("✅ File served successfully: %s", filePath) +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go index 7cf4379..816d5de 100644 --- a/backend/internal/api/handlers/tenant.go +++ b/backend/internal/api/handlers/tenant.go @@ -67,3 +67,39 @@ func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } + +// GetPublicConfig returns public branding info for a tenant by subdomain +func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + subdomain := r.URL.Query().Get("subdomain") + if subdomain == "" { + http.Error(w, "subdomain is required", http.StatusBadRequest) + return + } + + tenant, err := h.tenantService.GetBySubdomain(subdomain) + if err != nil { + if err == service.ErrTenantNotFound { + http.NotFound(w, r) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Return only public info + response := map[string]string{ + "name": tenant.Name, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 9d62a60..8e3db9f 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "log" "net/http" "strings" @@ -59,13 +60,40 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler { } // tenant_id pode ser nil para SuperAdmin - var tenantID string + var tenantIDFromJWT string if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil { - tenantID, _ = tenantIDClaim.(string) + tenantIDFromJWT, _ = tenantIDClaim.(string) } - ctx := context.WithValue(r.Context(), UserIDKey, userID) - ctx = context.WithValue(ctx, TenantIDKey, tenantID) + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado + // Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste) + tenantIDFromContext := "" + if ctxTenantID := r.Context().Value(TenantIDKey); ctxTenantID != nil { + tenantIDFromContext, _ = ctxTenantID.(string) + } + + log.Printf("🔐 AUTH VALIDATION: JWT tenant=%s | Context tenant=%s | Path=%s", + tenantIDFromJWT, tenantIDFromContext, r.RequestURI) + + // Se o usuário não é SuperAdmin (tem tenant_id) e está acessando uma agência (subdomain detectado) + if tenantIDFromJWT != "" && tenantIDFromContext != "" { + // Validar se o tenant_id do JWT corresponde ao tenant detectado + if tenantIDFromJWT != tenantIDFromContext { + log.Printf("❌ CROSS-TENANT ACCESS BLOCKED: User from tenant %s tried to access tenant %s", + tenantIDFromJWT, tenantIDFromContext) + http.Error(w, "Forbidden: You don't have access to this tenant", http.StatusForbidden) + return + } + log.Printf("✅ TENANT VALIDATION PASSED: %s", tenantIDFromJWT) + } + + // Preservar TODOS os valores do contexto anterior (incluindo o tenantID do TenantDetector) + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, userID) + // Só sobrescrever o TenantIDKey se vier do JWT (para não perder o do TenantDetector) + if tenantIDFromJWT != "" { + ctx = context.WithValue(ctx, TenantIDKey, tenantIDFromJWT) + } next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/api/middleware/tenant.go b/backend/internal/api/middleware/tenant.go index de597e0..3bd285b 100644 --- a/backend/internal/api/middleware/tenant.go +++ b/backend/internal/api/middleware/tenant.go @@ -16,15 +16,25 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Get host from X-Forwarded-Host header (set by Next.js proxy) or Host header - host := r.Header.Get("X-Forwarded-Host") - if host == "" { - host = r.Header.Get("X-Original-Host") - } - if host == "" { - host = r.Host - } + // Priority order: X-Tenant-Subdomain (set by Next.js middleware) > X-Forwarded-Host > X-Original-Host > Host + tenantSubdomain := r.Header.Get("X-Tenant-Subdomain") - log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI) + var host string + if tenantSubdomain != "" { + // Use direct subdomain from Next.js middleware + host = tenantSubdomain + log.Printf("TenantDetector: using X-Tenant-Subdomain = %s", tenantSubdomain) + } else { + // Fallback to extracting from host headers + host = r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Header.Get("X-Original-Host") + } + if host == "" { + host = r.Host + } + log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI) + } // Extract subdomain // Examples: @@ -33,17 +43,28 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) // - dash.localhost -> dash (master admin) // - localhost -> (institutional site) - parts := strings.Split(host, ".") var subdomain string - if len(parts) >= 2 { - // Has subdomain - subdomain = parts[0] - + // If we got the subdomain directly from X-Tenant-Subdomain, use it + if tenantSubdomain != "" { + subdomain = tenantSubdomain // Remove port if present if strings.Contains(subdomain, ":") { subdomain = strings.Split(subdomain, ":")[0] } + } else { + // Extract from host + parts := strings.Split(host, ".") + + if len(parts) >= 2 { + // Has subdomain + subdomain = parts[0] + + // Remove port if present + if strings.Contains(subdomain, ":") { + subdomain = strings.Split(subdomain, ":")[0] + } + } } log.Printf("TenantDetector: extracted subdomain = %s", subdomain) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4d3ad9e..0cc5274 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -49,6 +49,7 @@ type SecurityConfig struct { // MinioConfig holds MinIO configuration type MinioConfig struct { Endpoint string + PublicURL string // URL pública para acesso ao MinIO (para gerar links) RootUser string RootPassword string UseSSL bool @@ -64,9 +65,9 @@ func Load() *Config { } // Rate limit: more lenient in dev, strict in prod - maxAttempts := 30 + maxAttempts := 1000 // Aumentado drasticamente para evitar 429 durante debug if env == "production" { - maxAttempts = 5 + maxAttempts = 100 // Mais restritivo em produção } return &Config{ @@ -102,6 +103,7 @@ func Load() *Config { }, Minio: MinioConfig{ Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"), + PublicURL: getEnvOrDefault("MINIO_PUBLIC_URL", "http://localhost:9000"), RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"), RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"), UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true", diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 5d800ec..6638ede 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -188,17 +188,23 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { // FindBySubdomain finds a tenant by subdomain func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) { query := ` - SELECT id, name, domain, subdomain, created_at, updated_at + SELECT id, name, domain, subdomain, primary_color, secondary_color, logo_url, logo_horizontal_url, created_at, updated_at FROM tenants WHERE subdomain = $1 ` tenant := &domain.Tenant{} + var primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString + err := r.db.QueryRow(query, subdomain).Scan( &tenant.ID, &tenant.Name, &tenant.Domain, &tenant.Subdomain, + &primaryColor, + &secondaryColor, + &logoURL, + &logoHorizontalURL, &tenant.CreatedAt, &tenant.UpdatedAt, ) @@ -207,7 +213,24 @@ func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, er return nil, nil } - return tenant, err + if err != nil { + return nil, err + } + + if primaryColor.Valid { + tenant.PrimaryColor = primaryColor.String + } + if secondaryColor.Valid { + tenant.SecondaryColor = secondaryColor.String + } + if logoURL.Valid { + tenant.LogoURL = logoURL.String + } + if logoHorizontalURL.Valid { + tenant.LogoHorizontalURL = logoHorizontalURL.String + } + + return tenant, nil } // SubdomainExists checks if a subdomain is already taken diff --git a/docker-compose.yml b/docker-compose.yml index a842180..b870f74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,9 +65,24 @@ services: container_name: aggios-minio restart: unless-stopped command: server /data --console-address ":9001" + labels: + - "traefik.enable=true" + # Router para acesso aos arquivos (API S3) + - "traefik.http.routers.minio.rule=Host(`files.aggios.local`) || Host(`files.localhost`)" + - "traefik.http.routers.minio.entrypoints=web" + - "traefik.http.routers.minio.priority=100" # Prioridade alta para evitar captura pelo wildcard + - "traefik.http.services.minio.loadbalancer.server.port=9000" + - "traefik.http.services.minio.loadbalancer.passhostheader=true" + # Router para o Console do MinIO + - "traefik.http.routers.minio-console.rule=Host(`minio.aggios.local`) || Host(`minio.localhost`)" + - "traefik.http.routers.minio-console.entrypoints=web" + - "traefik.http.routers.minio-console.priority=100" + - "traefik.http.services.minio-console.loadbalancer.server.port=9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!} + MINIO_BROWSER_REDIRECT_URL: http://minio.localhost + MINIO_SERVER_URL: http://files.localhost volumes: - minio_data:/data ports: @@ -107,6 +122,7 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:-R3d1s_S3cur3_P@ss_2025!} MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_URL: http://files.localhost MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!} depends_on: @@ -178,6 +194,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)" - "traefik.http.routers.agency.entrypoints=web" + - "traefik.http.routers.agency.priority=1" # Prioridade baixa para não conflitar com files/minio environment: - NODE_ENV=production - NEXT_PUBLIC_API_URL=http://api.localhost diff --git a/docs/TEST_LOGO_UPLOAD.md b/docs/TEST_LOGO_UPLOAD.md new file mode 100644 index 0000000..c26a685 --- /dev/null +++ b/docs/TEST_LOGO_UPLOAD.md @@ -0,0 +1,15 @@ +# Teste manual do endpoint de upload de logo + +## 1. Login e obter token +curl -X POST http://idealpages.localhost/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@idealpages.com","password":"admin123"}' + +## 2. Upload de logo (substituir TOKEN pelo valor retornado acima) +curl -X POST http://idealpages.localhost/api/agency/logo \ + -H "Authorization: Bearer TOKEN" \ + -F "logo=@/caminho/para/imagem.png" \ + -F "type=logo" + +## 3. Verificar se salvou no banco +docker exec aggios-postgres psql -U aggios -d aggios_db -c "SELECT id, name, logo_url FROM tenants WHERE subdomain = 'idealpages';" diff --git a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx new file mode 100644 index 0000000..66f7ad0 --- /dev/null +++ b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { DashboardLayout } from '@/components/layout/DashboardLayout'; +import { AgencyBranding } from '@/components/layout/AgencyBranding'; +import AuthGuard from '@/components/auth/AuthGuard'; +import { + HomeIcon, + RocketLaunchIcon, + ChartBarIcon, + BriefcaseIcon, + LifebuoyIcon, + CreditCardIcon, + DocumentTextIcon, + FolderIcon, + ShareIcon, +} from '@heroicons/react/24/outline'; + +const AGENCY_MENU_ITEMS = [ + { id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon }, + { + id: 'crm', + label: 'CRM', + href: '/crm', + icon: RocketLaunchIcon, + subItems: [ + { label: 'Dashboard', href: '/crm' }, + { label: 'Clientes', href: '/crm/clientes' }, + { label: 'Funis', href: '/crm/funis' }, + { label: 'Negociações', href: '/crm/negociacoes' }, + ] + }, + { + id: 'erp', + label: 'ERP', + href: '/erp', + icon: ChartBarIcon, + subItems: [ + { label: 'Dashboard', href: '/erp' }, + { label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' }, + { label: 'Contas a Pagar', href: '/erp/contas-pagar' }, + { label: 'Contas a Receber', href: '/erp/contas-receber' }, + ] + }, + { + id: 'projetos', + label: 'Projetos', + href: '/projetos', + icon: BriefcaseIcon, + subItems: [ + { label: 'Dashboard', href: '/projetos' }, + { label: 'Meus Projetos', href: '/projetos/lista' }, + { label: 'Tarefas', href: '/projetos/tarefas' }, + { label: 'Cronograma', href: '/projetos/cronograma' }, + ] + }, + { + id: 'helpdesk', + label: 'Helpdesk', + href: '/helpdesk', + icon: LifebuoyIcon, + subItems: [ + { label: 'Dashboard', href: '/helpdesk' }, + { label: 'Chamados', href: '/helpdesk/chamados' }, + { label: 'Base de Conhecimento', href: '/helpdesk/kb' }, + ] + }, + { + id: 'pagamentos', + label: 'Pagamentos', + href: '/pagamentos', + icon: CreditCardIcon, + subItems: [ + { label: 'Dashboard', href: '/pagamentos' }, + { label: 'Cobranças', href: '/pagamentos/cobrancas' }, + { label: 'Assinaturas', href: '/pagamentos/assinaturas' }, + ] + }, + { + id: 'contratos', + label: 'Contratos', + href: '/contratos', + icon: DocumentTextIcon, + subItems: [ + { label: 'Dashboard', href: '/contratos' }, + { label: 'Ativos', href: '/contratos/ativos' }, + { label: 'Modelos', href: '/contratos/modelos' }, + ] + }, + { + id: 'documentos', + label: 'Documentos', + href: '/documentos', + icon: FolderIcon, + subItems: [ + { label: 'Meus Arquivos', href: '/documentos' }, + { label: 'Compartilhados', href: '/documentos/compartilhados' }, + { label: 'Lixeira', href: '/documentos/lixeira' }, + ] + }, + { + id: 'social', + label: 'Redes Sociais', + href: '/social', + icon: ShareIcon, + subItems: [ + { label: 'Dashboard', href: '/social' }, + { label: 'Agendamento', href: '/social/agendamento' }, + { label: 'Relatórios', href: '/social/relatorios' }, + ] + }, +]; + +interface AgencyLayoutClientProps { + children: React.ReactNode; + colors?: { + primary: string; + secondary: string; + } | null; +} + +export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps) { + return ( + + + + {children} + + + ); +} diff --git a/front-end-agency/app/(agency)/configuracoes/page.tsx b/front-end-agency/app/(agency)/configuracoes/page.tsx index 0c5d8cf..28034a4 100644 --- a/front-end-agency/app/(agency)/configuracoes/page.tsx +++ b/front-end-agency/app/(agency)/configuracoes/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { Tab } from '@headlessui/react'; -import { Button, Dialog } from '@/components/ui'; +import { Button, Dialog, Input } from '@/components/ui'; import { Toaster, toast } from 'react-hot-toast'; import { BuildingOfficeIcon, @@ -44,6 +44,7 @@ export default function ConfiguracoesPage() { const [showSupportDialog, setShowSupportDialog] = useState(false); const [supportMessage, setSupportMessage] = useState('Para alterar estes dados, contate o suporte.'); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [loadingCep, setLoadingCep] = useState(false); const [uploadingLogo, setUploadingLogo] = useState(false); const [logoPreview, setLogoPreview] = useState(null); @@ -70,8 +71,32 @@ export default function ConfiguracoesPage() { teamSize: '', logoUrl: '', logoHorizontalUrl: '', + primaryColor: '#ff3a05', + secondaryColor: '#ff0080', }); + // Live Preview da Cor Primária + useEffect(() => { + if (agencyData.primaryColor) { + const root = document.documentElement; + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null; + }; + + const primaryRgb = hexToRgb(agencyData.primaryColor); + + if (primaryRgb) { + root.style.setProperty('--brand-rgb', primaryRgb); + root.style.setProperty('--brand-strong-rgb', primaryRgb); + root.style.setProperty('--brand-hover-rgb', primaryRgb); + } + + root.style.setProperty('--brand-color', agencyData.primaryColor); + root.style.setProperty('--gradient', `linear-gradient(135deg, ${agencyData.primaryColor}, ${agencyData.primaryColor})`); + } + }, [agencyData.primaryColor]); + // Dados para alteração de senha const [passwordData, setPasswordData] = useState({ currentPassword: '', @@ -127,6 +152,8 @@ export default function ConfiguracoesPage() { teamSize: data.team_size || '', logoUrl: data.logo_url || '', logoHorizontalUrl: data.logo_horizontal_url || '', + primaryColor: data.primary_color || '#ff3a05', + secondaryColor: data.secondary_color || '#ff0080', }); // Set logo previews @@ -166,6 +193,8 @@ export default function ConfiguracoesPage() { teamSize: data.formData?.teamSize || '', logoUrl: '', logoHorizontalUrl: '', + primaryColor: '#ff3a05', + secondaryColor: '#ff0080', }); } } @@ -223,11 +252,18 @@ export default function ConfiguracoesPage() { if (type === 'logo') { setAgencyData(prev => ({ ...prev, logoUrl })); setLogoPreview(logoUrl); + // Salvar no localStorage para uso do favicon + localStorage.setItem('agency-logo-url', logoUrl); } else { setAgencyData(prev => ({ ...prev, logoHorizontalUrl: logoUrl })); setLogoHorizontalPreview(logoUrl); } + // Disparar evento para atualizar branding em tempo real + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('branding-update')); + } + toast.success('Logo enviado com sucesso!'); } else { const errorData = await response.json().catch(() => ({})); @@ -319,11 +355,13 @@ export default function ConfiguracoesPage() { }; const handleSaveAgency = async () => { + setSaving(true); try { const token = localStorage.getItem('token'); if (!token) { setSuccessMessage('Você precisa estar autenticado.'); setShowSuccessDialog(true); + setSaving(false); return; } @@ -354,17 +392,30 @@ export default function ConfiguracoesPage() { description: agencyData.description, industry: agencyData.industry, team_size: agencyData.teamSize, + primary_color: agencyData.primaryColor, + secondary_color: agencyData.secondaryColor, }), }); if (response.ok) { setSuccessMessage('Dados da agência salvos com sucesso!'); + + // Atualiza localStorage imediatamente para persistência instantânea + localStorage.setItem('agency-primary-color', agencyData.primaryColor); + localStorage.setItem('agency-secondary-color', agencyData.secondaryColor); + if (agencyData.logoUrl) localStorage.setItem('agency-logo-url', agencyData.logoUrl); + if (agencyData.logoHorizontalUrl) localStorage.setItem('agency-logo-horizontal-url', agencyData.logoHorizontalUrl); + + // Disparar evento para atualizar o tema em tempo real + window.dispatchEvent(new Event('branding-update')); } else { setSuccessMessage('Erro ao salvar dados. Tente novamente.'); } } catch (error) { console.error('Erro ao salvar:', error); setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.'); + } finally { + setSaving(false); } setShowSuccessDialog(true); }; @@ -475,52 +526,40 @@ export default function ConfiguracoesPage() {
- - setAgencyData({ ...agencyData, name: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Ex: Minha Agência" />
- - setAgencyData({ ...agencyData, razaoSocial: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Razão Social Ltda" />
- - { setSupportMessage('Para alterar CNPJ, contate o suporte.'); setShowSupportDialog(true); }} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer" + className="cursor-pointer bg-gray-50 dark:bg-gray-800" + helperText="Alteração via suporte" />
- -
- - setAgencyData({ ...agencyData, phone: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="(00) 00000-0000" />
- - setAgencyData({ ...agencyData, website: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="https://www.suaagencia.com.br" + leftIcon="ri-global-line" />
- - setAgencyData({ ...agencyData, industry: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Ex: Marketing Digital" />
- - setAgencyData({ ...agencyData, teamSize: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Ex: 10-50 funcionários" />
@@ -591,11 +622,8 @@ export default function ConfiguracoesPage() {
- - { const formatted = formatCep(e.target.value); @@ -617,85 +645,74 @@ export default function ConfiguracoesPage() { })); } }} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="00000-000" + rightIcon={loadingCep ? "ri-loader-4-line animate-spin" : undefined} />
- - -
- - + +
+
- - -
- - + +
+ -
- - setAgencyData({ ...agencyData, number: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed" />
- - setAgencyData({ ...agencyData, number: e.target.value })} + placeholder="123" + /> +
+ +
+ setAgencyData({ ...agencyData, complement: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Apto 101, Bloco B" />
- +
@@ -928,6 +945,69 @@ export default function ConfiguracoesPage() { )}
+ + {/* Cores da Marca */} +
+

+ Personalização de Cores +

+
+
+
+
+ setAgencyData({ ...agencyData, primaryColor: e.target.value })} + className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0" + /> +
+
+ setAgencyData({ ...agencyData, primaryColor: e.target.value })} + className="uppercase" + maxLength={7} + /> +
+
+
+
+
+
+ setAgencyData({ ...agencyData, secondaryColor: e.target.value })} + className="absolute -top-2 -left-2 w-24 h-24 cursor-pointer p-0 border-0" + /> +
+
+ setAgencyData({ ...agencyData, secondaryColor: e.target.value })} + className="uppercase" + maxLength={7} + /> +
+
+
+
+
+ +
+
{/* Info adicional */} @@ -950,9 +1030,9 @@ export default function ConfiguracoesPage() {

Em breve: gerenciamento completo de usuários e permissões

- + @@ -970,52 +1050,42 @@ export default function ConfiguracoesPage() {
- - setPasswordData({ ...passwordData, currentPassword: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" placeholder="Digite sua senha atual" />
- - setPasswordData({ ...passwordData, newPassword: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" placeholder="Digite a nova senha (mínimo 8 caracteres)" />
- - setPasswordData({ ...passwordData, confirmPassword: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" placeholder="Digite a nova senha novamente" />
- +
@@ -1071,13 +1141,12 @@ export default function ConfiguracoesPage() {

{successMessage}

- + @@ -1092,12 +1161,12 @@ export default function ConfiguracoesPage() {

Envie um e-mail para suporte@aggios.app ou abra um chamado para ajuste desses dados.

- + diff --git a/front-end-agency/app/(agency)/dashboard/page.tsx b/front-end-agency/app/(agency)/dashboard/page.tsx index a00b492..d055489 100644 --- a/front-end-agency/app/(agency)/dashboard/page.tsx +++ b/front-end-agency/app/(agency)/dashboard/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect, useState } from 'react'; +import { getUser } from '@/lib/auth'; import { RocketLaunchIcon, ChartBarIcon, @@ -16,6 +18,21 @@ import { } from '@heroicons/react/24/outline'; export default function DashboardPage() { + const [userName, setUserName] = useState(''); + const [greeting, setGreeting] = useState(''); + + useEffect(() => { + const user = getUser(); + if (user) { + setUserName(user.name.split(' ')[0]); // Primeiro nome + } + + const hour = new Date().getHours(); + if (hour >= 5 && hour < 12) setGreeting('Bom dia'); + else if (hour >= 12 && hour < 18) setGreeting('Boa tarde'); + else setGreeting('Boa noite'); + }, []); + const overviewStats = [ { name: 'Receita Total (Mês)', value: 'R$ 124.500', change: '+12%', changeType: 'increase', icon: ChartBarIcon, color: 'green' }, { name: 'Novos Leads', value: '45', change: '+5%', changeType: 'increase', icon: RocketLaunchIcon, color: 'blue' }, @@ -89,14 +106,25 @@ export default function DashboardPage() { return (
- {/* Header */} -
-

- Visão Geral da Agência -

-

- Acompanhe o desempenho de todos os módulos em tempo real -

+ {/* Header Personalizado */} +
+
+

+ {greeting}, {userName || 'Administrador'}! 👋 +

+

+ Aqui está o resumo da sua agência hoje. Tudo parece estar sob controle. +

+
+
+ + + Sistema Operacional + + + {new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })} + +
{/* Top Stats */} diff --git a/front-end-agency/app/(agency)/layout.tsx b/front-end-agency/app/(agency)/layout.tsx index 0e4bf9d..76e4f83 100644 --- a/front-end-agency/app/(agency)/layout.tsx +++ b/front-end-agency/app/(agency)/layout.tsx @@ -1,125 +1,34 @@ -"use client"; +import { Metadata } from 'next'; +import { getAgencyLogo, getAgencyColors } from '@/lib/server-api'; +import { AgencyLayoutClient } from './AgencyLayoutClient'; -import { DashboardLayout } from '@/components/layout/DashboardLayout'; -import { - HomeIcon, - RocketLaunchIcon, - ChartBarIcon, - BriefcaseIcon, - LifebuoyIcon, - CreditCardIcon, - DocumentTextIcon, - FolderIcon, - ShareIcon, -} from '@heroicons/react/24/outline'; +// Forçar renderização dinâmica (não estática) para este layout +// Necessário porque usamos headers() para pegar o subdomínio +export const dynamic = 'force-dynamic'; -const AGENCY_MENU_ITEMS = [ - { id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon }, - { - id: 'crm', - label: 'CRM', - href: '/crm', - icon: RocketLaunchIcon, - subItems: [ - { label: 'Dashboard', href: '/crm' }, - { label: 'Clientes', href: '/crm/clientes' }, - { label: 'Funis', href: '/crm/funis' }, - { label: 'Negociações', href: '/crm/negociacoes' }, - ] - }, - { - id: 'erp', - label: 'ERP', - href: '/erp', - icon: ChartBarIcon, - subItems: [ - { label: 'Dashboard', href: '/erp' }, - { label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' }, - { label: 'Contas a Pagar', href: '/erp/contas-pagar' }, - { label: 'Contas a Receber', href: '/erp/contas-receber' }, - ] - }, - { - id: 'projetos', - label: 'Projetos', - href: '/projetos', - icon: BriefcaseIcon, - subItems: [ - { label: 'Dashboard', href: '/projetos' }, - { label: 'Meus Projetos', href: '/projetos/lista' }, - { label: 'Tarefas', href: '/projetos/tarefas' }, - { label: 'Cronograma', href: '/projetos/cronograma' }, - ] - }, - { - id: 'helpdesk', - label: 'Helpdesk', - href: '/helpdesk', - icon: LifebuoyIcon, - subItems: [ - { label: 'Dashboard', href: '/helpdesk' }, - { label: 'Chamados', href: '/helpdesk/chamados' }, - { label: 'Base de Conhecimento', href: '/helpdesk/kb' }, - ] - }, - { - id: 'pagamentos', - label: 'Pagamentos', - href: '/pagamentos', - icon: CreditCardIcon, - subItems: [ - { label: 'Dashboard', href: '/pagamentos' }, - { label: 'Cobranças', href: '/pagamentos/cobrancas' }, - { label: 'Assinaturas', href: '/pagamentos/assinaturas' }, - ] - }, - { - id: 'contratos', - label: 'Contratos', - href: '/contratos', - icon: DocumentTextIcon, - subItems: [ - { label: 'Dashboard', href: '/contratos' }, - { label: 'Ativos', href: '/contratos/ativos' }, - { label: 'Modelos', href: '/contratos/modelos' }, - ] - }, - { - id: 'documentos', - label: 'Documentos', - href: '/documentos', - icon: FolderIcon, - subItems: [ - { label: 'Meus Arquivos', href: '/documentos' }, - { label: 'Compartilhados', href: '/documentos/compartilhados' }, - { label: 'Lixeira', href: '/documentos/lixeira' }, - ] - }, - { - id: 'social', - label: 'Redes Sociais', - href: '/social', - icon: ShareIcon, - subItems: [ - { label: 'Dashboard', href: '/social' }, - { label: 'Agendamento', href: '/social/agendamento' }, - { label: 'Relatórios', href: '/social/relatorios' }, - ] - }, -]; +/** + * generateMetadata - Executado no servidor antes do render + * Define o favicon dinamicamente baseado no subdomínio da agência + */ +export async function generateMetadata(): Promise { + const logoUrl = await getAgencyLogo(); -import AuthGuard from '@/components/auth/AuthGuard'; + return { + icons: { + icon: logoUrl || '/favicon.ico', + shortcut: logoUrl || '/favicon.ico', + apple: logoUrl || '/favicon.ico', + }, + }; +} -export default function AgencyLayout({ +export default async function AgencyLayout({ children, }: { children: React.ReactNode; }) { - return ( - - - {children} - - - ); + // Buscar cores da agência no servidor + const colors = await getAgencyColors(); + + return {children}; } diff --git a/front-end-agency/app/(auth)/recuperar-senha/page.tsx b/front-end-agency/app/(auth)/recuperar-senha/page.tsx index 998d468..11e1277 100644 --- a/front-end-agency/app/(auth)/recuperar-senha/page.tsx +++ b/front-end-agency/app/(auth)/recuperar-senha/page.tsx @@ -1,14 +1,26 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import { Button, Input } from "@/components/ui"; import toast, { Toaster } from 'react-hot-toast'; +import { EnvelopeIcon } from "@heroicons/react/24/outline"; export default function RecuperarSenhaPage() { const [isLoading, setIsLoading] = useState(false); const [email, setEmail] = useState(""); const [emailSent, setEmailSent] = useState(false); + const [subdomain, setSubdomain] = useState(''); + const [isSuperAdmin, setIsSuperAdmin] = useState(false); + + useEffect(() => { + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + const sub = hostname.split('.')[0]; + setSubdomain(sub); + setIsSuperAdmin(sub === 'dash'); + } + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -77,8 +89,10 @@ export default function RecuperarSenhaPage() {
{/* Logo mobile */}
-
-

aggios

+
+

+ {isSuperAdmin ? 'aggios' : subdomain} +

@@ -100,7 +114,7 @@ export default function RecuperarSenhaPage() { label="Email" type="email" placeholder="seu@email.com" - leftIcon="ri-mail-line" + leftIcon={} value={email} onChange={(e) => setEmail(e.target.value)} required @@ -109,142 +123,71 @@ export default function RecuperarSenhaPage() { - - {/* Back to login */} -
- - - Voltar para o login - -
+
+ + Voltar para o login + +
+ ) : ( - <> - {/* Success Message */} -
-
- -
- -

- Email enviado! -

- -

- Enviamos um link de recuperação para: -

- -

- {email} -

- -
-
- -
-

- Verifique sua caixa de entrada -

-

- Clique no link que enviamos para redefinir sua senha. - Se não receber em alguns minutos, verifique sua pasta de spam. -

-
-
-
- - - +
+
+ +
+

+ Verifique seu email +

+

+ Enviamos um link de recuperação para {email} +

+ +
- Voltar para o login
- +
)}
{/* Lado Direito - Branding */} -
-
- {/* Logo */} -
-
-

- aggios -

-
-
- - {/* Conteúdo */} -
-
- -
-

Recuperação segura

-

- Protegemos seus dados com os mais altos padrões de segurança. - Seu link de recuperação é único e expira em 24 horas. +

+
+
+

+ {isSuperAdmin ? 'aggios' : subdomain} +

+

+ Recupere o acesso à sua conta de forma segura e rápida.

- - {/* Features */} -
-
-
- -
-
-

Criptografia de ponta

-

Seus dados são protegidos com tecnologia de última geração

-
-
-
-
- -
-
-

Link temporário

-

O link expira em 24h para sua segurança

-
-
-
-
- -
-
-

Suporte disponível

-

Nossa equipe está pronta para ajudar caso precise

-
-
-
- - {/* Círculos decorativos */} -
-
); } + diff --git a/front-end-agency/app/LayoutWrapper.tsx b/front-end-agency/app/LayoutWrapper.tsx index 7b82691..7cb0a9c 100644 --- a/front-end-agency/app/LayoutWrapper.tsx +++ b/front-end-agency/app/LayoutWrapper.tsx @@ -1,25 +1,53 @@ 'use client'; import { ReactNode } from 'react'; -import { useEffect } from 'react'; -import { usePathname } from 'next/navigation'; -const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)'; +// Helper to lighten color +const lightenColor = (color: string, percent: number) => { + const num = parseInt(color.replace("#", ""), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) + amt, + B = ((num >> 8) & 0x00ff) + amt, + G = (num & 0x0000ff) + amt; + return ( + "#" + + ( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (B < 255 ? (B < 1 ? 0 : B) : 255) * 0x100 + + (G < 255 ? (G < 1 ? 0 : G) : 255) + ) + .toString(16) + .slice(1) + ); +}; -const setGradientVariables = (gradient: string) => { - document.documentElement.style.setProperty('--gradient-primary', gradient); - document.documentElement.style.setProperty('--gradient', gradient); - document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right')); - document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right')); +const setBrandColors = (primary: string, secondary: string) => { + document.documentElement.style.setProperty('--brand-color', primary); + document.documentElement.style.setProperty('--brand-color-strong', secondary); + + // Create a lighter version of primary for hover + const primaryLight = lightenColor(primary, 20); // Lighten by 20% + document.documentElement.style.setProperty('--brand-color-hover', primaryLight); + + // Set RGB variables if needed by other components + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null; + }; + + const primaryRgb = hexToRgb(primary); + const secondaryRgb = hexToRgb(secondary); + const primaryLightRgb = hexToRgb(primaryLight); + + if (primaryRgb) document.documentElement.style.setProperty('--brand-rgb', primaryRgb); + if (secondaryRgb) document.documentElement.style.setProperty('--brand-strong-rgb', secondaryRgb); + if (primaryLightRgb) document.documentElement.style.setProperty('--brand-hover-rgb', primaryLightRgb); }; export default function LayoutWrapper({ children }: { children: ReactNode }) { - const pathname = usePathname(); - - useEffect(() => { - // Em toda troca de rota, volta para o tema padrão; layouts específicos (ex.: agência) aplicam o próprio na sequência - setGradientVariables(DEFAULT_GRADIENT); - }, [pathname]); - + // Temporariamente desativado o carregamento dinâmico de cores/tema para eliminar possíveis + // efeitos colaterais de hidratação e 429 no middleware/backend. Se precisar reativar, mover + // para nível de servidor (next/head ou metadata) para evitar mutações de DOM no cliente. return <>{children}; } diff --git a/front-end-agency/app/api/agency/logo/route.ts b/front-end-agency/app/api/agency/logo/route.ts index e452212..22ea0b1 100644 --- a/front-end-agency/app/api/agency/logo/route.ts +++ b/front-end-agency/app/api/agency/logo/route.ts @@ -4,19 +4,33 @@ const BACKEND_URL = process.env.API_INTERNAL_URL || 'http://aggios-backend:8080' export async function POST(request: NextRequest) { try { + console.log('🔵 [Next.js] Logo upload route called'); + const authorization = request.headers.get('authorization'); if (!authorization) { + console.log('❌ [Next.js] No authorization header'); return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } + console.log('✅ [Next.js] Authorization header present'); + // Get form data from request const formData = await request.formData(); + const logo = formData.get('logo'); + const type = formData.get('type'); - console.log('Forwarding logo upload to backend:', BACKEND_URL); + console.log('📦 [Next.js] FormData received:', { + hasLogo: !!logo, + logoType: logo ? (logo as File).type : null, + logoSize: logo ? (logo as File).size : null, + type: type + }); + + console.log('🚀 [Next.js] Forwarding to backend:', BACKEND_URL); // Forward to backend const response = await fetch(`${BACKEND_URL}/api/agency/logo`, { @@ -27,7 +41,7 @@ export async function POST(request: NextRequest) { body: formData, }); - console.log('Backend response status:', response.status); + console.log('📡 [Next.js] Backend response status:', response.status); if (!response.ok) { const errorText = await response.text(); diff --git a/front-end-agency/app/api/tenant/public-config/route.ts b/front-end-agency/app/api/tenant/public-config/route.ts new file mode 100644 index 0000000..cef180a --- /dev/null +++ b/front-end-agency/app/api/tenant/public-config/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.API_INTERNAL_URL || 'http://backend:8080'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const subdomain = searchParams.get('subdomain'); + + if (!subdomain) { + return NextResponse.json( + { error: 'Subdomain is required' }, + { status: 400 } + ); + } + + // Buscar configuração pública do tenant + const response = await fetch( + `${API_BASE_URL}/api/tenant/config?subdomain=${subdomain}`, + { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + return NextResponse.json( + { error: 'Tenant not found' }, + { status: 404 } + ); + } + + const data = await response.json(); + + // Retornar apenas dados públicos + return NextResponse.json({ + name: data.name, + primary_color: data.primary_color, + secondary_color: data.secondary_color, + logo_url: data.logo_url, + }); + } catch (error) { + console.error('Error fetching tenant config:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/front-end-agency/app/layout.tsx b/front-end-agency/app/layout.tsx index 08d0c54..0c48857 100644 --- a/front-end-agency/app/layout.tsx +++ b/front-end-agency/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter, Open_Sans, Fira_Code } from "next/font/google"; import "./globals.css"; import LayoutWrapper from "./LayoutWrapper"; import { ThemeProvider } from "next-themes"; +import { getAgencyLogo } from "@/lib/server-api"; const inter = Inter({ variable: "--font-inter", @@ -22,10 +23,19 @@ const firaCode = Fira_Code({ weight: ["400", "600"], }); -export const metadata: Metadata = { - title: "Aggios - Dashboard", - description: "Plataforma SaaS para agências digitais", -}; +export async function generateMetadata(): Promise { + const logoUrl = await getAgencyLogo(); + + return { + title: "Aggios - Dashboard", + description: "Plataforma SaaS para agências digitais", + icons: { + icon: logoUrl || '/favicon.ico', + shortcut: logoUrl || '/favicon.ico', + apple: logoUrl || '/favicon.ico', + }, + }; +} export default function RootLayout({ children, @@ -37,7 +47,7 @@ export default function RootLayout({ - + {children} diff --git a/front-end-agency/app/login/page.tsx b/front-end-agency/app/login/page.tsx index 60052b4..9e9c771 100644 --- a/front-end-agency/app/login/page.tsx +++ b/front-end-agency/app/login/page.tsx @@ -3,26 +3,29 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { Button, Input, Checkbox } from "@/components/ui"; -import toast, { Toaster } from 'react-hot-toast'; import { saveAuth, isAuthenticated, getToken, clearAuth } from '@/lib/auth'; import { API_ENDPOINTS } from '@/lib/api'; import dynamic from 'next/dynamic'; +import { LoginBranding } from '@/components/auth/LoginBranding'; +import { + EnvelopeIcon, + LockClosedIcon, + ShieldCheckIcon, + BoltIcon, + UserGroupIcon, + ChartBarIcon, + ExclamationCircleIcon, + CheckCircleIcon +} from "@heroicons/react/24/outline"; const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false }); -const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)'; - -const setGradientVariables = (gradient: string) => { - document.documentElement.style.setProperty('--gradient-primary', gradient); - document.documentElement.style.setProperty('--gradient', gradient); - document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right')); - document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right')); -}; - export default function LoginPage() { const [isLoading, setIsLoading] = useState(false); const [isSuperAdmin, setIsSuperAdmin] = useState(false); const [subdomain, setSubdomain] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); const [formData, setFormData] = useState({ email: "", password: "", @@ -37,22 +40,6 @@ export default function LoginPage() { setSubdomain(sub); setIsSuperAdmin(superAdmin); - // Aplicar tema: dash sempre padrão; tenants aplicam o salvo ou vindo via query param - const searchParams = new URLSearchParams(window.location.search); - const themeParam = searchParams.get('theme'); - - if (superAdmin) { - setGradientVariables(DEFAULT_GRADIENT); - } else { - const stored = localStorage.getItem(`agency-theme:${sub}`); - const gradient = themeParam || stored || DEFAULT_GRADIENT; - setGradientVariables(gradient); - - if (themeParam) { - localStorage.setItem(`agency-theme:${sub}`, gradient); - } - } - if (isAuthenticated()) { // Validar token antes de redirecionar para evitar loops const token = getToken(); @@ -80,19 +67,27 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setErrorMessage(''); + setSuccessMessage(''); + // Validações do lado do cliente if (!formData.email) { - toast.error('Por favor, insira seu email'); + setErrorMessage('Por favor, insira seu email para continuar.'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { - toast.error('Por favor, insira um email válido'); + setErrorMessage('Ops! O formato do email não parece correto. Por favor, verifique e tente novamente.'); return; } if (!formData.password) { - toast.error('Por favor, insira sua senha'); + setErrorMessage('Por favor, insira sua senha para acessar sua conta.'); + return; + } + + if (formData.password.length < 3) { + setErrorMessage('A senha parece muito curta. Por favor, verifique se digitou corretamente.'); return; } @@ -111,8 +106,19 @@ export default function LoginPage() { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Credenciais inválidas'); + const error = await response.json().catch(() => ({})); + + // Mensagens humanizadas para cada tipo de erro + if (response.status === 401 || response.status === 403) { + setErrorMessage('Email ou senha incorretos. Por favor, verifique seus dados e tente novamente.'); + } else if (response.status >= 500) { + setErrorMessage('Estamos com problemas no servidor no momento. Por favor, tente novamente em alguns instantes.'); + } else { + setErrorMessage(error.message || 'Algo deu errado ao tentar fazer login. Por favor, tente novamente.'); + } + + setIsLoading(false); + return; } const data = await response.json(); @@ -121,57 +127,60 @@ export default function LoginPage() { console.log('Login successful:', data.user); - toast.success('Login realizado com sucesso! Redirecionando...'); + setSuccessMessage('Login realizado com sucesso! Redirecionando você agora...'); setTimeout(() => { const target = isSuperAdmin ? '/superadmin' : '/dashboard'; window.location.href = target; }, 1000); } catch (error: any) { - toast.error(error.message || 'Erro ao fazer login. Verifique suas credenciais.'); + console.error('Login error:', error); + setErrorMessage('Não conseguimos conectar ao servidor. Verifique sua conexão com a internet e tente novamente.'); setIsLoading(false); } }; return ( <> - +
{/* Lado Esquerdo - Formulário */}
{/* Logo mobile */}
-
+

{isSuperAdmin ? 'aggios' : subdomain}

@@ -198,13 +207,36 @@ export default function LoginPage() { {/* Form */}
+ {/* Mensagem de Erro */} + {errorMessage && ( +
+ +

+ {errorMessage} +

+
+ )} + + {/* Mensagem de Sucesso */} + {successMessage && ( +
+ +

+ {successMessage} +

+
+ )} + } value={formData.email} - onChange={(e) => setFormData({ ...formData, email: e.target.value })} + onChange={(e) => { + setFormData({ ...formData, email: e.target.value }); + setErrorMessage(''); // Limpa o erro ao digitar + }} required /> @@ -212,9 +244,12 @@ export default function LoginPage() { label="Senha" type="password" placeholder="Digite sua senha" - leftIcon="ri-lock-line" + leftIcon={} value={formData.password} - onChange={(e) => setFormData({ ...formData, password: e.target.value })} + onChange={(e) => { + setFormData({ ...formData, password: e.target.value }); + setErrorMessage(''); // Limpa o erro ao digitar + }} required /> @@ -228,7 +263,7 @@ export default function LoginPage() { Esqueceu a senha? @@ -251,7 +286,7 @@ export default function LoginPage() { Cadastre sua agência @@ -262,7 +297,7 @@ export default function LoginPage() {
{/* Lado Direito - Branding */} -
+

@@ -276,22 +311,22 @@ export default function LoginPage() {

- +

Seguro

Proteção de dados

- +

Rápido

Performance otimizada

- +

Colaborativo

Trabalho em equipe

- +

Insights

Relatórios detalhados

diff --git a/front-end-agency/app/tokens.css b/front-end-agency/app/tokens.css index b0ccdf7..f7eb4eb 100644 --- a/front-end-agency/app/tokens.css +++ b/front-end-agency/app/tokens.css @@ -9,6 +9,8 @@ /* Cores sólidas de marca (usadas em textos/bordas) */ --brand-color: #ff3a05; --brand-color-strong: #ff0080; + --brand-rgb: 255 58 5; + --brand-strong-rgb: 255 0 128; /* Superfícies e tipografia */ --color-surface-light: #ffffff; diff --git a/front-end-agency/components/DynamicFavicon.tsx b/front-end-agency/components/DynamicFavicon.tsx index 6d89531..722a5b7 100644 --- a/front-end-agency/components/DynamicFavicon.tsx +++ b/front-end-agency/components/DynamicFavicon.tsx @@ -1,33 +1,42 @@ "use client"; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; interface DynamicFaviconProps { logoUrl?: string; } export default function DynamicFavicon({ logoUrl }: DynamicFaviconProps) { + const [mounted, setMounted] = useState(false); + useEffect(() => { - if (!logoUrl) return; + setMounted(true); + }, []); - // Remove favicons antigos - const existingLinks = document.querySelectorAll("link[rel*='icon']"); - existingLinks.forEach(link => link.remove()); + useEffect(() => { + if (!mounted || !logoUrl) return; - // Adiciona novo favicon - const link = document.createElement('link'); - link.type = 'image/x-icon'; - link.rel = 'shortcut icon'; - link.href = logoUrl; - document.getElementsByTagName('head')[0].appendChild(link); + // Usar requestAnimationFrame para garantir que a hidratação terminou + requestAnimationFrame(() => { + // Remove favicons antigos + const existingLinks = document.querySelectorAll("link[rel*='icon']"); + existingLinks.forEach(link => link.remove()); - // Adiciona Apple touch icon - const appleLink = document.createElement('link'); - appleLink.rel = 'apple-touch-icon'; - appleLink.href = logoUrl; - document.getElementsByTagName('head')[0].appendChild(appleLink); + // Adiciona novo favicon + const link = document.createElement('link'); + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + link.href = logoUrl; + document.getElementsByTagName('head')[0].appendChild(link); - }, [logoUrl]); + // Adiciona Apple touch icon + const appleLink = document.createElement('link'); + appleLink.rel = 'apple-touch-icon'; + appleLink.href = logoUrl; + document.getElementsByTagName('head')[0].appendChild(appleLink); + }); + + }, [mounted, logoUrl]); return null; } diff --git a/front-end-agency/components/auth/AuthGuard.tsx b/front-end-agency/components/auth/AuthGuard.tsx index e4da0d1..3005e39 100644 --- a/front-end-agency/components/auth/AuthGuard.tsx +++ b/front-end-agency/components/auth/AuthGuard.tsx @@ -7,9 +7,16 @@ import { isAuthenticated } from '@/lib/auth'; export default function AuthGuard({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); - const [authorized, setAuthorized] = useState(false); + const [authorized, setAuthorized] = useState(null); + const [mounted, setMounted] = useState(false); useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + const checkAuth = () => { const isAuth = isAuthenticated(); @@ -35,12 +42,24 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) { window.addEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange); - }, [router, pathname]); + }, [router, pathname, mounted]); + + // Enquanto verifica (ou não está montado), mostra um loading simples + // Isso evita problemas de hidratação mantendo a estrutura DOM consistente + if (!mounted || authorized === null) { + return ( +
+
+
+ ); + } - // Enquanto verifica, não renderiza nada ou um loading - // Para evitar "flash" de conteúdo não autorizado if (!authorized) { - return null; + return ( +
+
+
+ ); } return <>{children}; diff --git a/front-end-agency/components/auth/LoginBranding.tsx b/front-end-agency/components/auth/LoginBranding.tsx new file mode 100644 index 0000000..b53717b --- /dev/null +++ b/front-end-agency/components/auth/LoginBranding.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * LoginBranding - Aplica cor primária da agência na página de login + * Busca cor do localStorage ou da API se não houver cache + */ +export function LoginBranding() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null; + }; + + const applyTheme = (primary: string) => { + if (!primary) return; + + const root = document.documentElement; + const primaryRgb = hexToRgb(primary); + + root.style.setProperty('--brand-color', primary); + root.style.setProperty('--gradient', `linear-gradient(135deg, ${primary}, ${primary})`); + + if (primaryRgb) { + root.style.setProperty('--brand-rgb', primaryRgb); + root.style.setProperty('--brand-strong-rgb', primaryRgb); + root.style.setProperty('--brand-hover-rgb', primaryRgb); + } + }; + + const updateFavicon = (url: string) => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + try { + console.log('🎨 LoginBranding: Atualizando favicon para:', url); + const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`; + + // Buscar TODOS os links de ícone existentes + const existingLinks = document.querySelectorAll("link[rel*='icon']"); + + if (existingLinks.length > 0) { + // Atualizar href de todos os links existentes (SEM REMOVER) + existingLinks.forEach(link => { + link.setAttribute('href', newHref); + }); + console.log(`✅ ${existingLinks.length} favicons atualizados`); + } else { + // Criar novo link apenas se não existir nenhum + const newLink = document.createElement('link'); + newLink.rel = 'icon'; + newLink.type = 'image/x-icon'; + newLink.href = newHref; + document.head.appendChild(newLink); + console.log('✅ Novo favicon criado'); + } + } catch (error) { + console.error('❌ Erro ao atualizar favicon:', error); + } + }; + + const loadBranding = async () => { + if (typeof window === 'undefined') return; + + const hostname = window.location.hostname; + const subdomain = hostname.split('.')[0]; + + // Para dash.localhost ou localhost sem subdomínio, não buscar + if (!subdomain || subdomain === 'localhost' || subdomain === 'www' || subdomain === 'dash') { + return; + } + + try { + // 1. Buscar DIRETO do backend (bypass da rota Next.js que está com problema) + console.log('LoginBranding: Buscando cores para:', subdomain); + const apiUrl = `/api/tenant/config?subdomain=${subdomain}`; + console.log('LoginBranding: URL:', apiUrl); + + const response = await fetch(apiUrl); + + if (response.ok) { + const data = await response.json(); + console.log('LoginBranding: Dados recebidos:', data); + + if (data.primary_color) { + applyTheme(data.primary_color); + localStorage.setItem('agency-primary-color', data.primary_color); + console.log('LoginBranding: Cor aplicada!'); + } + + if (data.logo_url) { + updateFavicon(data.logo_url); + localStorage.setItem('agency-logo-url', data.logo_url); + console.log('LoginBranding: Favicon aplicado!'); + } + return; + } else { + console.error('LoginBranding: API retornou:', response.status); + } + + // 2. Fallback para cache + console.log('LoginBranding: Tentando cache'); + const cachedPrimary = localStorage.getItem('agency-primary-color'); + const cachedLogo = localStorage.getItem('agency-logo-url'); + + if (cachedPrimary) { + applyTheme(cachedPrimary); + } + if (cachedLogo) { + updateFavicon(cachedLogo); + } + } catch (error) { + console.error('LoginBranding: Erro:', error); + const cachedPrimary = localStorage.getItem('agency-primary-color'); + const cachedLogo = localStorage.getItem('agency-logo-url'); + + if (cachedPrimary) { + applyTheme(cachedPrimary); + } + if (cachedLogo) { + updateFavicon(cachedLogo); + } + } + }; + + loadBranding(); + }, [mounted]); + + return null; +} diff --git a/front-end-agency/components/layout/AgencyBranding.tsx b/front-end-agency/components/layout/AgencyBranding.tsx new file mode 100644 index 0000000..5f44b91 --- /dev/null +++ b/front-end-agency/components/layout/AgencyBranding.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface AgencyBrandingProps { + colors?: { + primary: string; + secondary: string; + } | null; +} + +/** + * AgencyBranding - Aplica as cores da agência via CSS Variables + * O favicon agora é tratado via Metadata API no layout (server-side) + */ +export function AgencyBranding({ colors }: AgencyBrandingProps) { + const [mounted, setMounted] = useState(false); + + const [debugInfo, setDebugInfo] = useState('Iniciando...'); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null; + }; + + const applyTheme = (primary: string, secondary: string) => { + if (!primary || !secondary) return; + + const root = document.documentElement; + const primaryRgb = hexToRgb(primary); + const secondaryRgb = hexToRgb(secondary); + + const gradient = `linear-gradient(135deg, ${primary}, ${primary})`; + const gradientText = `linear-gradient(to right, ${primary}, ${primary})`; + + root.style.setProperty('--gradient', gradient); + root.style.setProperty('--gradient-text', gradientText); + root.style.setProperty('--gradient-primary', gradient); + root.style.setProperty('--color-gradient-brand', gradient); + + root.style.setProperty('--brand-color', primary); + root.style.setProperty('--brand-color-strong', secondary); + + if (primaryRgb) root.style.setProperty('--brand-rgb', primaryRgb); + if (secondaryRgb) root.style.setProperty('--brand-strong-rgb', secondaryRgb); + + // Salvar no localStorage para cache + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + const sub = hostname.split('.')[0]; + if (sub && sub !== 'www') { + localStorage.setItem(`agency-theme:${sub}`, gradient); + localStorage.setItem('agency-primary-color', primary); + localStorage.setItem('agency-secondary-color', secondary); + } + } + }; + + const updateFavicon = (url: string) => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + try { + setDebugInfo(`Tentando atualizar favicon: ${url}`); + console.log('🎨 AgencyBranding: Atualizando favicon para:', url); + + const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`; + + // Buscar TODOS os links de ícone existentes + const existingLinks = document.querySelectorAll("link[rel*='icon']"); + + if (existingLinks.length > 0) { + // Atualizar href de todos os links existentes (SEM REMOVER) + existingLinks.forEach(link => { + link.setAttribute('href', newHref); + }); + setDebugInfo(`Favicon atualizado (${existingLinks.length} links)`); + console.log(`✅ ${existingLinks.length} favicons atualizados`); + } else { + // Criar novo link apenas se não existir nenhum + const newLink = document.createElement('link'); + newLink.rel = 'icon'; + newLink.type = 'image/x-icon'; + newLink.href = newHref; + document.head.appendChild(newLink); + setDebugInfo('Novo favicon criado'); + console.log('✅ Novo favicon criado'); + } + } catch (error) { + setDebugInfo(`Erro: ${error}`); + console.error('❌ Erro ao atualizar favicon:', error); + } + }; + + // Se temos cores do servidor, aplicar imediatamente + if (colors) { + applyTheme(colors.primary, colors.secondary); + } else { + // Fallback: tentar pegar do cache do localStorage + const cachedPrimary = localStorage.getItem('agency-primary-color'); + const cachedSecondary = localStorage.getItem('agency-secondary-color'); + + if (cachedPrimary && cachedSecondary) { + applyTheme(cachedPrimary, cachedSecondary); + } + } + + // Atualizar favicon se houver logo salvo (após montar) + const cachedLogo = localStorage.getItem('agency-logo-url'); + if (cachedLogo) { + console.log('🔍 Logo encontrado no cache:', cachedLogo); + updateFavicon(cachedLogo); + } else { + setDebugInfo('Nenhum logo no cache'); + console.log('⚠️ Nenhum logo encontrado no cache'); + } + + // Listener para atualizações em tempo real (ex: da página de configurações) + const handleUpdate = () => { + console.log('🔔 Evento branding-update recebido!'); + setDebugInfo('Evento branding-update recebido'); + + const cachedPrimary = localStorage.getItem('agency-primary-color'); + const cachedSecondary = localStorage.getItem('agency-secondary-color'); + const cachedLogo = localStorage.getItem('agency-logo-url'); + + if (cachedPrimary && cachedSecondary) { + console.log('🎨 Aplicando cores do cache'); + applyTheme(cachedPrimary, cachedSecondary); + } + + if (cachedLogo) { + console.log('🖼️ Atualizando favicon do cache:', cachedLogo); + updateFavicon(cachedLogo); + } + }; + + window.addEventListener('branding-update', handleUpdate); + + return () => { + window.removeEventListener('branding-update', handleUpdate); + }; + }, [mounted, colors]); + + if (!mounted) return null; + + return ( +
+ DEBUG: {debugInfo} +
+ ); +} diff --git a/front-end-agency/components/layout/DashboardLayout.tsx b/front-end-agency/components/layout/DashboardLayout.tsx index 1d3d950..981ef0f 100644 --- a/front-end-agency/components/layout/DashboardLayout.tsx +++ b/front-end-agency/components/layout/DashboardLayout.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState } from 'react'; +import { usePathname } from 'next/navigation'; import { SidebarRail, MenuItem } from './SidebarRail'; import { TopBar } from './TopBar'; @@ -12,14 +13,12 @@ interface DashboardLayoutProps { export const DashboardLayout: React.FC = ({ children, menuItems }) => { // Estado centralizado do layout const [isExpanded, setIsExpanded] = useState(true); - const [activeTab, setActiveTab] = useState('dashboard'); + const pathname = usePathname(); return (
{/* Sidebar controla seu próprio estado visual via props */} setIsExpanded(!isExpanded)} menuItems={menuItems} @@ -32,7 +31,9 @@ export const DashboardLayout: React.FC = ({ children, menu {/* Conteúdo das páginas */}
- {children} +
+ {children} +
diff --git a/front-end-agency/components/layout/FaviconUpdater.tsx b/front-end-agency/components/layout/FaviconUpdater.tsx new file mode 100644 index 0000000..65397a9 --- /dev/null +++ b/front-end-agency/components/layout/FaviconUpdater.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getUser } from '@/lib/auth'; + +export function FaviconUpdater() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const updateFavicon = () => { + const user = getUser(); + if (user?.logoUrl) { + // Usar requestAnimationFrame para garantir que o DOM esteja estável após hidratação + requestAnimationFrame(() => { + const link: HTMLLinkElement = document.querySelector("link[rel*='icon']") || document.createElement('link'); + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + link.href = user.logoUrl!; + if (!link.parentNode) { + document.getElementsByTagName('head')[0].appendChild(link); + } + }); + } + }; + + // Atraso pequeno para garantir que a hidratação terminou + const timer = setTimeout(() => { + updateFavicon(); + }, 0); + + // Ouve mudanças no localStorage + const handleStorage = () => { + requestAnimationFrame(() => updateFavicon()); + }; + window.addEventListener('storage', handleStorage); + + // Custom event para atualização interna na mesma aba + window.addEventListener('auth-update', handleStorage); + + return () => { + clearTimeout(timer); + window.removeEventListener('storage', handleStorage); + window.removeEventListener('auth-update', handleStorage); + }; + }, [mounted]); + + return null; +} diff --git a/front-end-agency/components/layout/SidebarRail.tsx b/front-end-agency/components/layout/SidebarRail.tsx index 0917ec1..2e19659 100644 --- a/front-end-agency/components/layout/SidebarRail.tsx +++ b/front-end-agency/components/layout/SidebarRail.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { Menu, Transition } from '@headlessui/react'; -import { Fragment } from 'react'; +import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTheme } from 'next-themes'; +import { getUser, User, getToken, saveAuth } from '@/lib/auth'; +import { API_ENDPOINTS } from '@/lib/api'; import { ChevronLeftIcon, ChevronRightIcon, @@ -30,16 +31,12 @@ export interface MenuItem { } interface SidebarRailProps { - activeTab: string; - onTabChange: (tab: string) => void; isExpanded: boolean; onToggle: () => void; menuItems: MenuItem[]; } export const SidebarRail: React.FC = ({ - activeTab, - onTabChange, isExpanded, onToggle, menuItems, @@ -48,12 +45,93 @@ export const SidebarRail: React.FC = ({ const router = useRouter(); const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); + const [user, setUser] = useState(null); const [openSubmenu, setOpenSubmenu] = useState(null); + const sidebarRef = useRef(null); useEffect(() => { setMounted(true); + const currentUser = getUser(); + setUser(currentUser); + + // Buscar perfil da agência para atualizar logo e nome + const fetchProfile = async () => { + const token = getToken(); + if (!token) return; + + try { + const res = await fetch(API_ENDPOINTS.agencyProfile, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (res.ok) { + const data = await res.json(); + if (currentUser) { + const updatedUser = { + ...currentUser, + company: data.name || currentUser.company, + logoUrl: data.logo_url + }; + setUser(updatedUser); + saveAuth(token, updatedUser); // Persistir atualização + + // Atualizar localStorage do logo para uso do favicon + if (data.logo_url) { + console.log('📝 Salvando logo no localStorage:', data.logo_url); + localStorage.setItem('agency-logo-url', data.logo_url); + window.dispatchEvent(new Event('auth-update')); // Notificar favicon + window.dispatchEvent(new Event('branding-update')); // Notificar AgencyBranding + } + } + } + } catch (error) { + console.error('Error fetching agency profile:', error); + } + }; + + fetchProfile(); + + // Listener para atualizar logo em tempo real após upload + // REMOVIDO: Causa loop infinito com o dispatchEvent dentro do fetchProfile + // O AgencyBranding já cuida de atualizar o favicon/cores + // Se precisar atualizar o sidebar após upload, usar um evento específico 'logo-uploaded' + /* + const handleBrandingUpdate = () => { + console.log('SidebarRail: branding-update event received'); + fetchProfile(); // Re-buscar perfil do backend + }; + + window.addEventListener('branding-update', handleBrandingUpdate); + + return () => { + window.removeEventListener('branding-update', handleBrandingUpdate); + }; + */ }, []); + // Fechar submenu ao clicar fora + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) { + // Verifica se o submenu aberto corresponde à rota atual + // Se estivermos navegando dentro do módulo (ex: CRM), o menu deve permanecer fixo + const activeItem = menuItems.find(item => item.id === openSubmenu); + const isRouteActive = activeItem && activeItem.subItems?.some(sub => pathname === sub.href || pathname.startsWith(sub.href)); + + if (!isRouteActive) { + setOpenSubmenu(null); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [openSubmenu, pathname, menuItems]); + // Auto-open submenu if active useEffect(() => { if (isExpanded && pathname) { @@ -69,7 +147,7 @@ export const SidebarRail: React.FC = ({ const handleLogout = () => { localStorage.removeItem('token'); localStorage.removeItem('user'); - router.push('/login'); + window.location.href = '/login'; }; const toggleTheme = () => { @@ -79,46 +157,59 @@ export const SidebarRail: React.FC = ({ // Encontrar o item ativo para renderizar o submenu const activeMenuItem = menuItems.find(item => item.id === openSubmenu); + // Lógica de largura do Rail: Se tiver submenu aberto, força recolhimento visual (80px) + // Se não, respeita o estado isExpanded + const railWidth = isExpanded && !openSubmenu ? 'w-[240px]' : 'w-[80px]'; + const showLabels = isExpanded && !openSubmenu; + return ( -
+
+ {/* Rail Principal (Ícones + Labels Opcionais) */}
{/* Toggle Button - Floating on the border */} - + {/* Só mostra o toggle se não tiver submenu aberto, para evitar confusão */} + {!openSubmenu && ( + + )} {/* Header com Logo */} -
- {/* Logo */} +
- A + {user?.logoUrl ? ( + {user.company + ) : ( + (user?.company?.[0] || 'A').toUpperCase() + )}
- {/* Título com animação */} -
- Aggios +
+ + {user?.company || 'Aggios'} +
{/* Navegação */} -
+
{menuItems.map((item) => ( = ({ icon={item.icon} href={item.href} active={pathname === item.href || (item.href !== '/dashboard' && pathname?.startsWith(item.href))} - onClick={() => { + onClick={(e: any) => { if (item.subItems) { - setOpenSubmenu(openSubmenu === item.id ? null : item.id); + // Se já estiver aberto, fecha e previne navegação (opcional) + if (openSubmenu === item.id) { + // Se quisermos permitir fechar sem navegar: + // e.preventDefault(); + // setOpenSubmenu(null); + + // Mas se o usuário quer ir para a home do módulo, deixamos navegar. + // O useEffect vai reabrir se a rota for do módulo. + // Para forçar o fechamento, teríamos que ter lógica mais complexa. + // Vamos assumir que clicar no pai sempre leva pra home do pai. + // E o useEffect cuida de abrir o menu. + // Então NÃO fazemos nada aqui se for abrir. + } else { + // Se for abrir, deixamos o Link navegar. + // O useEffect vai abrir o menu quando a rota mudar. + // NÃO setamos o estado aqui para evitar conflito com a navegação. + } } else { - onTabChange(item.id); setOpenSubmenu(null); } }} - isExpanded={isExpanded} + showLabel={showLabels} hasSubItems={!!item.subItems} isOpen={openSubmenu === item.id} /> ))}
- {/* Separador antes do menu de usuário */} -
+ {/* Separador */} +
- {/* User Menu - Footer */} -
- - - -
- Agência -
-
- - - -
- - {({ active }) => ( - - )} - - - {({ active }) => ( - - - Configurações - - )} - - - {({ active }) => ( - - )} - -
- - {({ active }) => ( - - )} - + {/* User Menu */} +
+ {mounted && ( + + + +
+ + {user?.name || 'Usuário'} +
- - -
+ + +
+ + + + + + +
+ + + +
+ +
+ )} + {!mounted && ( +
+ +
+ )}
- {/* Submenu Flyout Panel */} + {/* Painel Secundário (Drawer) - Abre ao lado do Rail */}
{activeMenuItem && ( <> -
+

{activeMenuItem.label} @@ -254,7 +345,7 @@ export const SidebarRail: React.FC = ({ setOpenSubmenu(null)} // Fecha ao clicar + // onClick={() => setOpenSubmenu(null)} // Removido para manter fixo className={` flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors mb-1 ${pathname === sub.href @@ -281,25 +372,31 @@ interface RailButtonProps { icon: React.ComponentType<{ className?: string }>; href: string; active: boolean; - onClick: () => void; - isExpanded: boolean; + onClick: (e?: any) => void; + showLabel: boolean; hasSubItems?: boolean; isOpen?: boolean; } -const RailButton: React.FC = ({ label, icon: Icon, href, active, onClick, isExpanded, hasSubItems, isOpen }) => { - const Wrapper = hasSubItems ? 'button' : Link; - const props = hasSubItems ? { onClick, type: 'button' } : { href, onClick }; - +const RailButton: React.FC = ({ label, icon: Icon, href, active, onClick, showLabel, hasSubItems, isOpen }) => { // Determine styling based on state - let baseClasses = "flex items-center p-2 rounded-lg transition-all duration-300 group relative overflow-hidden w-full "; + // Sempre usa Link se tiver href, para garantir navegação correta e prefetching + const Wrapper = href ? Link : 'button'; + // Desabilitar prefetch para evitar sobrecarga no middleware/backend e loops de redirecionamento + const props = href ? { href, onClick, prefetch: false } : { onClick, type: 'button' }; - if (active && !hasSubItems) { - // Active leaf item (Dashboard, etc) - baseClasses += "text-white shadow-md"; - } else if (isOpen) { - // Open submenu parent - Highlight to show active state - baseClasses += "bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white"; + let baseClasses = "flex items-center p-2 rounded-lg transition-all duration-300 group relative overflow-hidden "; + if (showLabel) { + baseClasses += "w-full justify-start "; + } else { + baseClasses += "w-10 h-10 justify-center mx-auto "; + } + + // Lógica unificada de ativo + const isActiveItem = active || isOpen; + + if (isActiveItem) { + baseClasses += "bg-brand-500 text-white shadow-sm"; } else { // Inactive item baseClasses += "hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-900 dark:hover:text-white text-gray-600 dark:text-gray-400"; @@ -308,29 +405,26 @@ const RailButton: React.FC = ({ label, icon: Icon, href, active return ( {/* Ícone */} - + - {/* Lógica Mágica do Texto: Max-Width Transition */} + {/* Texto (Visível apenas se expandido) */}
+ overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out flex items-center flex-1 + ${showLabel ? 'max-w-[150px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'} + `}> {label} {hasSubItems && ( - + )}
- {/* Indicador de Ativo (Barra lateral pequena quando fechado) */} - {active && !isExpanded && !hasSubItems && ( -
+ {/* Indicador de Ativo (Ponto lateral) - Apenas se recolhido e NÃO tiver gradiente (redundante agora, mas mantido por segurança) */} + {active && !hasSubItems && !showLabel && !isActiveItem && ( +
)} ); diff --git a/front-end-agency/components/layout/TopBar.tsx b/front-end-agency/components/layout/TopBar.tsx index 99faa68..cfdfbf9 100644 --- a/front-end-agency/components/layout/TopBar.tsx +++ b/front-end-agency/components/layout/TopBar.tsx @@ -3,20 +3,18 @@ import React, { useState } from 'react'; import { usePathname } from 'next/navigation'; import Link from 'next/link'; -import { MagnifyingGlassIcon, ChevronRightIcon, HomeIcon } from '@heroicons/react/24/outline'; +import { MagnifyingGlassIcon, ChevronRightIcon, HomeIcon, BellIcon, Cog6ToothIcon } from '@heroicons/react/24/outline'; import CommandPalette from '@/components/ui/CommandPalette'; export const TopBar: React.FC = () => { const pathname = usePathname(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); - // Gerar breadcrumbs a partir do pathname const generateBreadcrumbs = () => { const paths = pathname?.split('/').filter(Boolean) || []; const breadcrumbs: Array<{ name: string; href: string; icon?: React.ComponentType<{ className?: string }> }> = [ { name: 'Home', href: '/dashboard', icon: HomeIcon } ]; - let currentPath = ''; paths.forEach((path, index) => { currentPath += `/${path}`; @@ -82,19 +80,30 @@ export const TopBar: React.FC = () => {
+
+ + + + +
+ {/* Command Palette */} ); diff --git a/front-end-agency/components/ui/Button.tsx b/front-end-agency/components/ui/Button.tsx index cc70193..f9d90a4 100644 --- a/front-end-agency/components/ui/Button.tsx +++ b/front-end-agency/components/ui/Button.tsx @@ -29,30 +29,48 @@ const Button = forwardRef( "inline-flex items-center justify-center font-medium rounded-[6px] transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; const variants = { - primary: "text-white hover:opacity-90 active:opacity-80", + primary: "bg-brand-500 text-white hover:opacity-90 active:opacity-80 shadow-sm hover:shadow-md transition-all", secondary: - "bg-[#E5E5E5] dark:bg-gray-700 text-[#000000] dark:text-white hover:opacity-90 active:opacity-80", + "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 active:bg-gray-300 dark:active:bg-gray-600", outline: - "border border-[#E5E5E5] dark:border-gray-600 text-[#000000] dark:text-white hover:bg-[#E5E5E5]/10 dark:hover:bg-gray-700/50 active:bg-[#E5E5E5]/20 dark:active:bg-gray-700", - ghost: "text-[#000000] dark:text-white hover:bg-[#E5E5E5]/20 dark:hover:bg-gray-700/30 active:bg-[#E5E5E5]/30 dark:active:bg-gray-700/50", + "border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 active:bg-gray-100 dark:active:bg-gray-700", + ghost: "text-gray-700 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700", }; const sizes = { - sm: "h-9 px-3 text-[13px]", - md: "h-10 px-4 text-[14px]", - lg: "h-12 px-6 text-[14px]", + sm: "h-8 px-3 text-xs", + md: "h-10 px-4 text-sm", + lg: "h-12 px-6 text-base", }; return (
+ ); } diff --git a/front-end-agency/components/ui/Input.tsx b/front-end-agency/components/ui/Input.tsx index 2e9389b..fd48ae8 100644 --- a/front-end-agency/components/ui/Input.tsx +++ b/front-end-agency/components/ui/Input.tsx @@ -1,13 +1,14 @@ "use client"; -import { InputHTMLAttributes, forwardRef, useState } from "react"; +import { InputHTMLAttributes, forwardRef, useState, ReactNode } from "react"; +import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline"; interface InputProps extends InputHTMLAttributes { label?: string; error?: string; helperText?: string; - leftIcon?: string; - rightIcon?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; onRightIconClick?: () => void; } @@ -41,26 +42,26 @@ const Input = forwardRef( )}
{leftIcon && ( - +
+ {leftIcon} +
)} ( onClick={() => setShowPassword(!showPassword)} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer" > - + {showPassword ? ( + + ) : ( + + )} )} {!isPassword && rightIcon && ( @@ -82,13 +85,13 @@ const Input = forwardRef( onClick={onRightIconClick} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer" > - +
{rightIcon}
)}
{error && (

- + {error}

)} diff --git a/front-end-agency/lib/api.ts b/front-end-agency/lib/api.ts index 6d606f3..4608581 100644 --- a/front-end-agency/lib/api.ts +++ b/front-end-agency/lib/api.ts @@ -2,8 +2,9 @@ * API Configuration - URLs e funções de requisição */ -// URL base da API - pode ser alterada por variável de ambiente -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.localhost'; +// URL base da API - usa path relativo para passar pelo middleware do Next.js +// que adiciona os headers de tenant (X-Tenant-Subdomain) +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ''; /** * Endpoints da API @@ -18,6 +19,8 @@ export const API_ENDPOINTS = { // Admin / Agencies adminAgencyRegister: `${API_BASE_URL}/api/admin/agencies/register`, + agencyProfile: `${API_BASE_URL}/api/agency/profile`, + tenantConfig: `${API_BASE_URL}/api/tenant/config`, // Health health: `${API_BASE_URL}/health`, diff --git a/front-end-agency/lib/auth.ts b/front-end-agency/lib/auth.ts index 7e93aa1..20fd144 100644 --- a/front-end-agency/lib/auth.ts +++ b/front-end-agency/lib/auth.ts @@ -10,6 +10,7 @@ export interface User { tenantId?: string; company?: string; subdomain?: string; + logoUrl?: string; } const TOKEN_KEY = 'token'; diff --git a/front-end-agency/lib/colors.ts b/front-end-agency/lib/colors.ts new file mode 100644 index 0000000..cb19522 --- /dev/null +++ b/front-end-agency/lib/colors.ts @@ -0,0 +1,183 @@ +/** + * Utilitários para manipulação de cores e garantia de acessibilidade + */ + +/** + * Converte hex para RGB + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * Converte RGB para hex + */ +export function rgbToHex(r: number, g: number, b: number): string { + return '#' + [r, g, b].map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }).join(''); +} + +/** + * Calcula luminosidade relativa (0-1) - WCAG 2.0 + */ +export function getLuminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + + const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((val) => { + const v = val / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Calcula contraste entre duas cores (1-21) - WCAG 2.0 + */ +export function getContrast(color1: string, color2: string): number { + const lum1 = getLuminance(color1); + const lum2 = getLuminance(color2); + const lighter = Math.max(lum1, lum2); + const darker = Math.min(lum1, lum2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Verifica se a cor é clara (luminosidade > 0.5) + */ +export function isLight(hex: string): boolean { + return getLuminance(hex) > 0.5; +} + +/** + * Escurece uma cor em uma porcentagem + */ +export function darken(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + + const factor = 1 - amount; + return rgbToHex( + rgb.r * factor, + rgb.g * factor, + rgb.b * factor + ); +} + +/** + * Clareia uma cor em uma porcentagem + */ +export function lighten(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + + const factor = amount; + return rgbToHex( + rgb.r + (255 - rgb.r) * factor, + rgb.g + (255 - rgb.g) * factor, + rgb.b + (255 - rgb.b) * factor + ); +} + +/** + * Gera cor de hover automática baseada na luminosidade + * Se a cor for clara, escurece 15% + * Se a cor for escura, clareia 15% + */ +export function generateHoverColor(hex: string): string { + return isLight(hex) ? darken(hex, 0.15) : lighten(hex, 0.15); +} + +/** + * Determina se deve usar texto branco ou preto sobre uma cor de fundo + * Prioriza branco para cores vibrantes/saturadas + */ +export function getTextColor(backgroundColor: string): string { + const contrastWithWhite = getContrast(backgroundColor, '#FFFFFF'); + const contrastWithBlack = getContrast(backgroundColor, '#000000'); + + // Se o contraste com branco for >= 3.5, prefere branco (mais comum em UIs modernas) + // WCAG AA requer 4.5:1, mas 3:1 para textos grandes + if (contrastWithWhite >= 3.5) { + return '#FFFFFF'; + } + + // Se não, usa a cor com melhor contraste + return contrastWithWhite > contrastWithBlack ? '#FFFFFF' : '#000000'; +} + +/** + * Gera paleta completa de cores com hover e variações + */ +export function generateColorPalette(primaryHex: string, secondaryHex: string) { + const primaryRgb = hexToRgb(primaryHex); + const secondaryRgb = hexToRgb(secondaryHex); + + if (!primaryRgb || !secondaryRgb) { + throw new Error('Cores inválidas'); + } + + const primaryHover = generateHoverColor(primaryHex); + const secondaryHover = generateHoverColor(secondaryHex); + + const primaryRgbString = `${primaryRgb.r} ${primaryRgb.g} ${primaryRgb.b}`; + const secondaryRgbString = `${secondaryRgb.r} ${secondaryRgb.g} ${secondaryRgb.b}`; + const hoverRgb = hexToRgb(primaryHover); + const hoverRgbString = hoverRgb ? `${hoverRgb.r} ${hoverRgb.g} ${hoverRgb.b}` : secondaryRgbString; + + return { + primary: primaryHex, + secondary: secondaryHex, + primaryHover, + secondaryHover, + primaryRgb: primaryRgbString, + secondaryRgb: secondaryRgbString, + hoverRgb: hoverRgbString, + gradient: `linear-gradient(135deg, ${primaryHex}, ${secondaryHex})`, + textOnPrimary: getTextColor(primaryHex), + textOnSecondary: getTextColor(secondaryHex), + isLightPrimary: isLight(primaryHex), + isLightSecondary: isLight(secondaryHex), + contrast: getContrast(primaryHex, secondaryHex), + }; +} + +/** + * Valida se as cores têm contraste suficiente + */ +export function validateColorContrast(primary: string, secondary: string): { + valid: boolean; + warnings: string[]; +} { + const warnings: string[] = []; + const contrast = getContrast(primary, secondary); + + if (contrast < 3) { + warnings.push('As cores são muito similares e podem causar problemas de legibilidade'); + } + + const primaryContrast = getContrast(primary, '#FFFFFF'); + if (primaryContrast < 4.5 && !isLight(primary)) { + warnings.push('A cor primária pode ter baixo contraste com texto branco'); + } + + const secondaryContrast = getContrast(secondary, '#FFFFFF'); + if (secondaryContrast < 4.5 && !isLight(secondary)) { + warnings.push('A cor secundária pode ter baixo contraste com texto branco'); + } + + return { + valid: warnings.length === 0, + warnings, + }; +} diff --git a/front-end-agency/lib/server-api.ts b/front-end-agency/lib/server-api.ts new file mode 100644 index 0000000..5363e34 --- /dev/null +++ b/front-end-agency/lib/server-api.ts @@ -0,0 +1,79 @@ +/** + * Server-side API functions + * Estas funções são executadas APENAS no servidor (não no cliente) + */ + +import { cookies, headers } from 'next/headers'; + +const API_BASE_URL = process.env.API_INTERNAL_URL || 'http://backend:8080'; + +interface AgencyBrandingData { + logo_url?: string; + primary_color?: string; + secondary_color?: string; + name?: string; +} + +/** + * Busca os dados de branding da agência no servidor + * Usa o subdomínio do request para identificar a agência + */ +export async function getAgencyBranding(): Promise { + try { + // Pegar o hostname do request + const headersList = await headers(); + const hostname = headersList.get('host') || ''; + const subdomain = hostname.split('.')[0]; + + if (!subdomain || subdomain === 'localhost' || subdomain === 'www') { + return null; + } + + // Buscar dados da agência pela API + const url = `${API_BASE_URL}/api/tenant/config?subdomain=${subdomain}`; + console.log(`[ServerAPI] Fetching agency config from: ${url}`); + + const response = await fetch(url, { + cache: 'no-store', // Sempre buscar dados atualizados + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error(`[ServerAPI] Failed to fetch agency branding for ${subdomain}: ${response.status}`); + return null; + } + + const data = await response.json(); + console.log(`[ServerAPI] Agency branding data for ${subdomain}:`, JSON.stringify(data)); + return data as AgencyBrandingData; + } catch (error) { + console.error('[ServerAPI] Error fetching agency branding:', error); + return null; + } +} + +/** + * Busca apenas o logo da agência (para metadata) + */ +export async function getAgencyLogo(): Promise { + const branding = await getAgencyBranding(); + return branding?.logo_url || null; +} + +/** + * Busca as cores da agência (para passar ao client component) + */ +export async function getAgencyColors(): Promise<{ primary: string; secondary: string } | null> { + const branding = await getAgencyBranding(); + + if (branding?.primary_color && branding?.secondary_color) { + return { + primary: branding.primary_color, + secondary: branding.secondary_color, + }; + } + + return null; +} diff --git a/front-end-agency/middleware.ts b/front-end-agency/middleware.ts index e6637fa..cb1d05f 100644 --- a/front-end-agency/middleware.ts +++ b/front-end-agency/middleware.ts @@ -13,23 +13,51 @@ export async function middleware(request: NextRequest) { // Validar subdomínio de agência ({subdomain}.localhost) if (hostname.includes('.')) { try { - const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`); + const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`, { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + } + }); + if (!res.ok) { - const baseHost = hostname.split('.').slice(1).join('.') || hostname; - const redirectUrl = new URL(url.toString()); - redirectUrl.hostname = baseHost; - redirectUrl.pathname = '/'; - return NextResponse.redirect(redirectUrl); + console.error(`Tenant check failed for ${subdomain}: ${res.status}`); + // Se for 404, realmente não existe. Se for 500, pode ser erro temporário. + // Por segurança, vamos redirecionar apenas se tivermos certeza que falhou a validação (ex: 404) + // ou se o backend estiver inalcançável de forma persistente. + // Para evitar loops durante desenvolvimento, vamos permitir passar se for erro de servidor (5xx) + // mas redirecionar se for 404. + + if (res.status === 404) { + const baseHost = hostname.split('.').slice(1).join('.') || hostname; + const redirectUrl = new URL(url.toString()); + redirectUrl.hostname = baseHost; + redirectUrl.pathname = '/'; + return NextResponse.redirect(redirectUrl); + } } } catch (err) { - const baseHost = hostname.split('.').slice(1).join('.') || hostname; - const redirectUrl = new URL(url.toString()); - redirectUrl.hostname = baseHost; - redirectUrl.pathname = '/'; - return NextResponse.redirect(redirectUrl); + console.error('Middleware error:', err); + // Em caso de erro de rede (backend fora do ar), permitir carregar a página + // para não travar o frontend completamente (pode mostrar erro na tela depois) + // return NextResponse.next(); } } + // Para requisições de API, adicionar headers com informações do tenant + if (url.pathname.startsWith('/api/')) { + // Cria um header customizado com o subdomain + const requestHeaders = new Headers(request.headers); + requestHeaders.set('X-Tenant-Subdomain', subdomain); + requestHeaders.set('X-Original-Host', hostname); + + return NextResponse.rewrite(url, { + request: { + headers: requestHeaders, + }, + }); + } + // Permitir acesso normal return NextResponse.next(); } @@ -38,11 +66,10 @@ export const config = { matcher: [ /* * Match all request paths except for the ones starting with: - * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) */ - '/((?!api|_next/static|_next/image|favicon.ico).*)', + '/((?!_next/static|_next/image|favicon.ico).*)', ], }; diff --git a/front-end-agency/next.config.ts b/front-end-agency/next.config.ts index abfc7eb..5270267 100644 --- a/front-end-agency/next.config.ts +++ b/front-end-agency/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + reactStrictMode: false, // Desabilitar StrictMode para evitar double render que causa removeChild experimental: { externalDir: true, }, @@ -23,6 +24,10 @@ const nextConfig: NextConfig = { key: "X-Forwarded-For", value: "127.0.0.1", }, + { + key: "X-Forwarded-Host", + value: "${host}", + }, ], }, ]; diff --git a/front-end-agency/tailwind.preset.js b/front-end-agency/tailwind.preset.js index b542a8a..fb8da4f 100644 --- a/front-end-agency/tailwind.preset.js +++ b/front-end-agency/tailwind.preset.js @@ -10,17 +10,18 @@ module.exports = { }, colors: { brand: { - 50: '#fff4ef', - 100: '#ffe8df', - 200: '#ffd0c0', - 300: '#ffb093', - 400: '#ff8a66', - 500: '#ff3a05', - 600: '#ff1f45', - 700: '#ff0080', - 800: '#d10069', - 900: '#9e0050', - 950: '#4b0028', + 50: 'rgb(var(--brand-rgb) / 0.05)', + 100: 'rgb(var(--brand-rgb) / 0.1)', + 200: 'rgb(var(--brand-rgb) / 0.2)', + 300: 'rgb(var(--brand-rgb) / 0.4)', + 400: 'rgb(var(--brand-rgb) / 0.8)', + 500: 'rgb(var(--brand-rgb) / )', + 600: 'rgb(var(--brand-strong-rgb) / )', + 700: 'rgb(var(--brand-strong-rgb) / 0.8)', + 800: 'rgb(var(--brand-strong-rgb) / 0.6)', + 900: 'rgb(var(--brand-strong-rgb) / 0.4)', + 950: 'rgb(var(--brand-strong-rgb) / 0.2)', + hover: 'rgb(var(--brand-hover-rgb) / )', }, surface: { light: '#ffffff',