15 Commits

Author SHA1 Message Date
Erik Silva
2a112f169d refactor: redesign planos interface with design system patterns
- Create CreatePlanModal component with Headless UI Dialog
- Implement dark mode support throughout plans UI
- Update plans/page.tsx with professional card layout
- Update plans/[id]/page.tsx with consistent styling
- Add proper spacing, typography, and color consistency
- Implement smooth animations and transitions
- Add success/error message feedback
- Improve form UX with better input styling
2025-12-13 19:26:38 -03:00
Erik Silva
2f1cf2bb2a v1.4: Segurança multi-tenant, file serving via API e UX humanizada
-  Validação cross-tenant no login e rotas protegidas
-  File serving via /api/files/{bucket}/{path} (eliminação DNS)
-  Mensagens de erro humanizadas inline (sem pop-ups)
-  Middleware tenant detection via headers customizados
-  Upload de logos retorna URLs via API
-  README atualizado com changelog v1.4 completo
2025-12-13 15:05:51 -03:00
Erik Silva
04c954c3d9 feat: Implementação de submenus laterais (flyout), correções de UI e proteção de rotas (AuthGuard) 2025-12-12 15:24:38 -03:00
Erik Silva
83ce15bb36 docs: update README with v1.2 features (flat design, advanced filters) 2025-12-11 23:40:39 -03:00
Erik Silva
dc98d5dccc feat: redesign superadmin agencies list, implement flat design, add date filters, and fix UI bugs 2025-12-11 23:39:54 -03:00
Erik Silva
053e180321 chore: snapshot before agency split 2025-12-09 17:21:25 -03:00
Erik Silva
6ec29c7eef chore: remove unused tenant variable 2025-12-09 03:06:06 -03:00
Erik Silva
1ea381224d fix: remove duplicate tenant service method and handle not found 2025-12-09 03:05:38 -03:00
Erik Silva
9e80aa1d70 feat: block unknown subdomains via tenant check 2025-12-09 03:04:28 -03:00
Erik Silva
74857bf106 fix: place suporte dialog inside page render 2025-12-09 02:58:42 -03:00
Erik Silva
0fee59082b feat: lock CNPJ/email edits, add support dialog and CEP-first layout 2025-12-09 02:53:19 -03:00
Erik Silva
331d50e677 fix: unify tenant context keys and load tenant_id from JWT 2025-12-09 02:42:43 -03:00
Erik Silva
00d0793dab fix: update route handler to use async params for Next.js 16 2025-12-09 02:29:03 -03:00
Erik Silva
fc310c0616 fix: add Next.js API route handler to proxy with correct host headers 2025-12-09 02:24:56 -03:00
Erik Silva
9ece6e88fe debug: add tenant context logging to GetProfile 2025-12-09 02:21:06 -03:00
184 changed files with 27875 additions and 2852 deletions

View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(docker-compose up:*)",
"Bash(docker-compose ps:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(docker logs:*)",
"Bash(docker exec:*)",
"Bash(npx tsc:*)",
"Bash(docker-compose restart:*)",
"Bash(npm install:*)",
"Bash(docker-compose build:*)"
]
}
}

36
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
// ============================================
// CONFIGURAÇÕES TAILWIND CSS
// ============================================
"tailwindCSS.validate": false, // DESATIVA validação para remover avisos chatos
"tailwindCSS.showPixelEquivalents": false,
// ⚠️ ATENÇÃO: AVISOS "suggestCanonicalClasses" SÃO BUGS DO PLUGIN
// O Tailwind CSS IntelliSense está bugado e sugere sintaxe ERRADA.
//
// ✅ Sintaxe CORRETA (Tailwind v4):
// - [var(--brand-color)] ← Use isso!
// - bg-gradient-to-r ← Use isso!
//
// ❌ Sintaxe ERRADA (sugestão bugada):
// - (--brand-color) ← NÃO funciona!
// - bg-linear-to-r ← NÃO funciona!
//
// Por isso desativamos a validação acima (tailwindCSS.validate: false)
// ============================================
// CONFIGURAÇÕES CSS
// ============================================
"css.validate": true,
"css.lint.unknownAtRules": "ignore",
// ============================================
// MELHORIAS NO EDITOR
// ============================================
"editor.quickSuggestions": {
"strings": true
}
}

View File

@@ -0,0 +1,174 @@
# Arquitetura Multi-tenant - Modelo de Negócio Aggios
## Visão Geral da Plataforma
A plataforma Aggios utiliza uma arquitetura multi-tenant em três camadas principais:
```
┌─────────────────────────────────────────────────┐
│ aggios.app (Site Institucional) │
│ - Marketing │
│ - Cadastro de novas agências │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ dash.aggios.app (SuperAdmin) │
│ - Você (dono da plataforma) │
│ - Gerencia TODAS as agências │
│ - Vê analytics globais │
└─────────────────────────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ idealpages. │ │ outraagencia. │
│ aggios.app │ │ aggios.app │
├──────────────────┤ ├──────────────────┤
│ Painel da │ │ Painel da │
│ IdeaPages │ │ Outra Agência │
│ │ │ │
│ • CRM │ │ • CRM │
│ • ERP │ │ • ERP │
│ • Projetos │ │ • Projetos │
│ • White Label │ │ • White Label │
│ (seu logo) │ │ (logo deles) │
└──────────────────┘ └──────────────────┘
│ │
▼ ▼
Clientes da Clientes da
IdeaPages Outra Agência
```
## Como Funciona na Prática
### 1. Sua Agência (Exemplo: IdeaPages)
- **URL**: `idealpages.aggios.app`
- **White Label**: Logo e cores da IdeaPages
- **Clientes**: Cadastrados DENTRO da agência IdeaPages
- **Isolamento**: Cada cliente é isolado por tenant_id (multi-tenant)
### 2. Quando um Cliente Precisa do CRM
**Você SEMPRE manda a URL da sua agência**, não aggios.app!
- Cliente cria conta em `idealpages.aggios.app`
- Cliente acessa `idealpages.aggios.app` com login próprio
- Cliente vê **SEU logo** (IdeaPages)
- Cliente vê **SEU white label**
- Cliente só vê os dados DELE (isolamento por tenant)
### 3. Estrutura de Clientes
```
IdeaPages (você - agência)
├── Cliente 1 (Empresa ABC)
│ ├── Vê: Logo IdeaPages
│ ├── Acessa: idealpages.aggios.app
│ └── Usa: CRM, ERP, Projetos (dados isolados)
├── Cliente 2 (Tech Solutions)
│ ├── Vê: Logo IdeaPages
│ ├── Acessa: idealpages.aggios.app
│ └── Usa: CRM, ERP, Projetos (dados isolados)
└── Cliente 3 (Marketing Pro)
├── Vê: Logo IdeaPages
├── Acessa: idealpages.aggios.app
└── Usa: CRM, ERP, Projetos (dados isolados)
```
## Benefícios para a Agência
**White Label Completo**: Cliente vê sua marca, não "Aggios"
**Controle Total**: Você gerencia todos os seus clientes
**Isolamento de Dados**: Cada cliente só vê os próprios dados
**Escalável**: Adicione quantos clientes quiser na mesma agência
**Identidade Visual**: Logo e cores personalizadas por agência
## Fluxo de Trabalho
1. **Agência se cadastra** → Cria subdomínio (ex: idealpages.aggios.app)
2. **Agência personaliza** → Upload de logo, cores, identidade visual
3. **Agência adiciona clientes** → Cada cliente recebe credenciais
4. **Cliente acessa** → idealpages.aggios.app (vê marca da agência)
5. **Cliente usa módulos** → CRM, ERP, Projetos (dados isolados)
## Resposta Direta
**Pergunta**: "Cliente precisa do CRM, mando aggios.app ou idealpages.aggios.app?"
**Resposta**: **`idealpages.aggios.app`** ✅
O cliente SEMPRE acessa o painel da sua agência, onde verá sua marca e terá acesso aos módulos que você liberar.
---
## Sistema de Links de Cadastro Personalizados
### Visão Geral
Sistema que permite ao SuperAdmin criar links de cadastro customizados, escolhendo:
- **Campos do formulário**: Quais informações coletar
- **Módulos habilitados**: Quais funcionalidades o cliente terá acesso
- **Branding**: Logo e cores personalizadas
### Fluxo de Uso
1. **SuperAdmin** acessa `dash.aggios.app/superadmin/signup-templates`
2. **Cria template** selecionando:
- Campos: email, senha, subdomínio, CNPJ, telefone, etc.
- Módulos: CRM, ERP, PROJECTS, FINANCIAL, etc.
- Slug: URL amigável (ex: `crm-rapido`)
3. **Compartilha link**: `aggios.app/cadastro/crm-rapido`
4. **Cliente acessa** e vê formulário personalizado
5. **Após cadastro**, tenant criado com módulos específicos
### Exemplo Real: DH Projects
```
Template: "CRM Rápido"
Slug: crm-rapido
Campos: email, senha, subdomínio, nome da empresa
Módulos: CRM
Link gerado: aggios.app/cadastro/crm-rapido
Cliente preenche:
- Email: contato@dhprojects.com
- Senha: ********
- Subdomínio: dhprojects
- Empresa: DH Projects
Resultado:
✅ Tenant criado: dhprojects.aggios.app
✅ Módulo CRM habilitado
✅ Outros módulos desabilitados
```
### Estrutura Técnica
**Backend:**
- Tabela: `signup_templates`
- Repository: `SignupTemplateRepository`
- Handlers: `/api/admin/signup-templates` (CRUD)
- Handler público: `/api/signup-templates/slug/{slug}` (renderiza form)
**Frontend:**
- Gerenciamento: `dash.aggios.app/superadmin/signup-templates`
- Cadastro público: `aggios.app/cadastro/{slug}`
**Campos Disponíveis:**
- email, password, subdomain (obrigatórios)
- company_name, cnpj, phone, address, city, state, zipcode (opcionais)
**Módulos Disponíveis:**
- CRM, ERP, PROJECTS, FINANCIAL, INVENTORY, HR
### Benefícios
✅ Cadastro rápido para clientes específicos
✅ Coleta apenas informações necessárias
✅ Habilita somente módulos contratados
✅ Reduz fricção no onboarding
✅ Personalização por caso de uso

149
1. docs/nova-interface.md Normal file
View File

@@ -0,0 +1,149 @@
# System Instruction: Arquitetura de Layout com Sidebar Expansível
**Role:** Senior React Developer & UI Specialist
**Tech Stack:** React, Tailwind CSS (Sem bibliotecas de ícones ou fontes externas).
**Objetivo:**
Implementar um sistema de layout "Dashboard" composto por um **Menu Lateral (Sidebar)** que expande e colapsa suavemente e uma área de conteúdo principal.
**Requisitos Críticos de Animação:**
1. A transição de largura da sidebar deve ser suave (transition-all duration-300).
2. O texto dos botões **não deve quebrar** ou desaparecer bruscamente. Use a técnica de transição de `max-width` e `opacity` para que o texto deslize suavemente para fora.
3. Não utilize bibliotecas de animação (Framer Motion, etc), apenas Tailwind CSS puro.
---
## 1. Componente: `DashboardLayout.tsx` (Container Principal)
Este componente deve gerenciar o estado global do menu (aberto/fechado) para evitar "prop drilling" desnecessário.
```tsx
import React, { useState } from 'react';
import { SidebarRail } from './SidebarRail';
interface DashboardLayoutProps {
children: React.ReactNode;
}
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
// Estado centralizado do layout
const [isExpanded, setIsExpanded] = useState(true);
const [activeTab, setActiveTab] = useState('home');
return (
<div className="flex h-screen w-full bg-gray-900 text-slate-900 overflow-hidden p-3 gap-3">
{/* Sidebar controla seu próprio estado visual via props */}
<SidebarRail
activeTab={activeTab}
onTabChange={setActiveTab}
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
/>
{/* Área de Conteúdo (Children) */}
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white rounded-3xl shadow-xl relative">
{children}
</main>
</div>
);
};
```
## 2. Componente: `SidebarRail.tsx` (Lógica de Animação)
Aqui reside a lógica visual. Substitua os ícones por `<span>Icon</span>` ou SVGs genéricos para manter o código agnóstico.
**Pontos de atenção no código abaixo:**
* `w-[220px]` vs `w-[72px]`: Define a largura física.
* `max-w-[150px]` vs `max-w-0`: Define a animação do texto.
* `whitespace-nowrap`: Impede que o texto pule de linha enquanto fecha.
```tsx
import React from 'react';
interface SidebarRailProps {
activeTab: string;
onTabChange: (tab: string) => void;
isExpanded: boolean;
onToggle: () => void;
}
export const SidebarRail: React.FC<SidebarRailProps> = ({ activeTab, onTabChange, isExpanded, onToggle }) => {
return (
<div
className={`
h-full bg-zinc-900 rounded-3xl flex flex-col py-6 gap-4 text-gray-400 shrink-0 border border-white/10 shadow-xl
transition-[width] duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] px-3
${isExpanded ? 'w-[220px]' : 'w-[72px]'}
`}
>
{/* Header / Toggle */}
<div className={`flex items-center w-full relative transition-all duration-300 mb-4 ${isExpanded ? 'justify-between px-1' : 'justify-center'}`}>
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-bold shrink-0 z-10">
Logo
</div>
{/* Título com animação de opacidade e largura */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap absolute left-14 ${isExpanded ? 'opacity-100 max-w-[100px]' : 'opacity-0 max-w-0'}`}>
<span className="font-bold text-white text-lg">App Name</span>
</div>
</div>
{/* Navegação */}
<div className="flex flex-col gap-2 w-full">
<RailButton
label="Dashboard"
active={activeTab === 'home'}
onClick={() => onTabChange('home')}
isExpanded={isExpanded}
/>
<RailButton
label="Settings"
active={activeTab === 'settings'}
onClick={() => onTabChange('settings')}
isExpanded={isExpanded}
/>
</div>
{/* Footer / Toggle Button */}
<div className="mt-auto">
<button
onClick={onToggle}
className="w-full p-2 rounded-xl hover:bg-white/10 text-gray-400 hover:text-white transition-colors flex items-center justify-center"
>
{/* Ícone de Toggle Genérico */}
<span>{isExpanded ? '<<' : '>>'}</span>
</button>
</div>
</div>
);
};
// Subcomponente do Botão (Essencial para a animação do texto)
const RailButton = ({ label, active, onClick, isExpanded }: any) => (
<button
onClick={onClick}
className={`
flex items-center p-2.5 rounded-xl transition-all duration-300 group relative overflow-hidden
${active ? 'bg-white/10 text-white' : 'hover:bg-white/5 hover:text-gray-200'}
${isExpanded ? '' : 'justify-center'}
`}
>
{/* Placeholder do Ícone */}
<div className="shrink-0 flex items-center justify-center w-6 h-6 bg-gray-700/50 rounded text-[10px]">Icon</div>
{/* Lógica Mágica do Texto: Max-Width Transition */}
<div className={`
overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out
${isExpanded ? 'max-w-[150px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'}
`}>
<span className="font-medium text-sm">{label}</span>
</div>
{/* Indicador de Ativo (Barra lateral pequena quando fechado) */}
{active && !isExpanded && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3 bg-white rounded-r-full -ml-3" />
)}
</button>
);
```

0
1. docs/planos-aggios.md Normal file
View File

173
1. docs/planos-roadmap.md Normal file
View File

@@ -0,0 +1,173 @@
# Sistema de Planos - Roadmap
## Status: Estrutura Frontend Criada ✅
### O que foi criado no Frontend:
1. **Menu Item** adicionado em `/superadmin/layout.tsx`
- Nova rota: `/superadmin/plans`
2. **Página Principal de Planos** (`/superadmin/plans/page.tsx`)
- Lista todos os planos em grid
- Mostra: nome, descrição, faixa de usuários, preços, features, diferenciais
- Botão "Novo Plano"
- Botões Editar e Deletar
- Status visual (ativo/inativo)
3. **Página de Edição de Plano** (`/superadmin/plans/[id]/page.tsx`)
- Formulário completo para editar:
- Informações básicas (nome, slug, descrição)
- Faixa de usuários (min/max)
- Preços (mensal/anual)
- Armazenamento (GB)
- Status (ativo/inativo)
- TODO: Editor de Features e Diferenciais
---
## Próximos Passos - Backend
### 1. Modelo de Dados (Domain)
```go
// internal/domain/plan.go
type Plan struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
MinUsers int `json:"min_users"`
MaxUsers int `json:"max_users"` // -1 = unlimited
MonthlyPrice *decimal.Decimal `json:"monthly_price"`
AnnualPrice *decimal.Decimal `json:"annual_price"`
Features pq.StringArray `json:"features"` // CRM, ERP, etc
Differentiators pq.StringArray `json:"differentiators"`
StorageGB int `json:"storage_gb"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Subscription struct {
ID string `json:"id"`
AgencyID string `json:"agency_id"`
PlanID string `json:"plan_id"`
BillingType string `json:"billing_type"` // monthly/annual
CurrentUsers int `json:"current_users"`
Status string `json:"status"` // active/suspended/cancelled
StartDate time.Time `json:"start_date"`
RenewalDate time.Time `json:"renewal_date"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### 2. Migrations
- `001_create_plans_table.sql`
- `002_create_agency_subscriptions_table.sql`
- `003_add_plan_id_to_agencies.sql`
### 3. Repository
- `PlanRepository` (CRUD)
- `SubscriptionRepository` (CRUD)
### 4. Service
- `PlanService` (validações, lógica)
- `SubscriptionService` (validar limite de usuários, etc)
### 5. Handlers (API)
```
GET /api/admin/plans - Listar planos
POST /api/admin/plans - Criar plano
GET /api/admin/plans/:id - Obter plano
PUT /api/admin/plans/:id - Atualizar plano
DELETE /api/admin/plans/:id - Deletar plano
GET /api/admin/subscriptions - Listar subscrições
```
### 6. Seeds
- Seed dos 4 planos padrão (Ignição, Órbita, Cosmos, Enterprise)
---
## Dados Padrão para Seed
```json
[
{
"name": "Ignição",
"slug": "ignition",
"description": "Ideal para pequenas agências iniciantes",
"min_users": 1,
"max_users": 30,
"monthly_price": 199.99,
"annual_price": 1919.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": [],
"storage_gb": 1,
"is_active": true
},
{
"name": "Órbita",
"slug": "orbit",
"description": "Para agências em crescimento",
"min_users": 31,
"max_users": 100,
"monthly_price": 399.99,
"annual_price": 3839.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Suporte prioritário"],
"storage_gb": 1,
"is_active": true
},
{
"name": "Cosmos",
"slug": "cosmos",
"description": "Para agências consolidadas",
"min_users": 101,
"max_users": 300,
"monthly_price": 799.99,
"annual_price": 7679.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Gerente de conta dedicado", "API integrações"],
"storage_gb": 1,
"is_active": true
},
{
"name": "Enterprise",
"slug": "enterprise",
"description": "Solução customizada para grandes agências",
"min_users": 301,
"max_users": -1,
"monthly_price": null,
"annual_price": null,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Armazenamento customizado", "Treinamento personalizado"],
"storage_gb": 1,
"is_active": true
}
]
```
---
## Integração com Agências
Quando agência se cadastra:
1. Seleciona um plano
2. Sistema cria `Subscription` com status `active` ou `pending_payment`
3. Agência herda limite de usuários do plano
4. Ao criar usuário: validar se não ultrapassou limite
---
## Features Futuras
- [ ] Editor de Features e Diferenciais (drag-drop no frontend)
- [ ] Planos promocionais (duplicar existente, editar preço)
- [ ] Validações de limite de usuários por plano
- [ ] Dashboard com uso atual vs limite
- [ ] Alertas quando próximo do limite
- [ ] Integração com Stripe/PagSeguro
---
**Pronto para começar?**

View File

@@ -4,23 +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
- Login de superadmin via JWT e restrição de rotas protegidas no dashboard.
- Cadastro de agências: criação de tenant e usuário administrador atrelado.
- Listagem, detalhamento e exclusão de agências diretamente pelo painel superadmin.
- Proxy interno (`app/api/admin/agencies/[id]/route.ts`) garantindo chamadas autenticadas do Next para o backend.
- Site institucional com 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).
@@ -30,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
@@ -47,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
- Branch: dev-1.4 (Segurança Multi-tenant + File Serving)

View File

@@ -7,6 +7,7 @@ import (
"net/http"
_ "github.com/lib/pq"
"github.com/gorilla/mux"
"aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware"
@@ -53,20 +54,35 @@ func main() {
userRepo := repository.NewUserRepository(db)
tenantRepo := repository.NewTenantRepository(db)
companyRepo := repository.NewCompanyRepository(db)
signupTemplateRepo := repository.NewSignupTemplateRepository(db)
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
planRepo := repository.NewPlanRepository(db)
subscriptionRepo := repository.NewSubscriptionRepository(db)
// Initialize services
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
tenantService := service.NewTenantService(tenantRepo)
companyService := service.NewCompanyService(companyRepo)
planService := service.NewPlanService(planRepo, subscriptionRepo)
// 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)
planHandler := handlers.NewPlanHandler(planService)
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)
if err != nil {
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
}
// Create middleware chain
tenantDetector := middleware.TenantDetector(tenantRepo)
@@ -76,43 +92,106 @@ func main() {
authMiddleware := middleware.Auth(cfg)
// Setup routes
mux := http.NewServeMux()
router := mux.NewRouter()
// Health check (no auth)
mux.HandleFunc("/health", healthHandler.Check)
mux.HandleFunc("/api/health", healthHandler.Check)
// Serve static files (uploads)
fs := http.FileServer(http.Dir("./uploads"))
router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
// Auth routes (public with rate limiting)
mux.HandleFunc("/api/auth/login", authHandler.Login)
// ==================== PUBLIC ROUTES ====================
// Protected auth routes
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
// Health check
router.HandleFunc("/health", healthHandler.Check)
router.HandleFunc("/api/health", healthHandler.Check)
// Agency management (SUPERADMIN only)
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
// Auth
router.HandleFunc("/api/auth/login", authHandler.Login)
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
// Client registration (ADMIN_AGENCIA only - requires auth)
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
// Public agency template registration (for creating new agencies)
router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST")
// Public client signup via templates
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
// Public plans (for signup flow)
router.HandleFunc("/api/plans", planHandler.ListActivePlans).Methods("GET")
router.HandleFunc("/api/plans/{id}", planHandler.GetActivePlan).Methods("GET")
// File upload (public for signup, will also work with auth)
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
// 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")
// ==================== PROTECTED ROUTES ====================
// Auth (protected)
router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST")
// SUPERADMIN: Agency management
router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST")
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
// SUPERADMIN: Agency template management
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
// SUPERADMIN: Client signup template management
router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
signupTemplateHandler.ListTemplates(w, r)
} else if r.Method == http.MethodPost {
signupTemplateHandler.CreateTemplate(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
signupTemplateHandler.GetTemplateByID(w, r)
case http.MethodPut, http.MethodPatch:
signupTemplateHandler.UpdateTemplate(w, r)
case http.MethodDelete:
signupTemplateHandler.DeleteTemplate(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plans management
planHandler.RegisterRoutes(router)
// ADMIN_AGENCIA: Client registration
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
// Agency profile routes (protected)
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
agencyProfileHandler.GetProfile(w, r)
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
case http.MethodPut, http.MethodPatch:
agencyProfileHandler.UpdateProfile(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
}))).Methods("GET", "PUT", "PATCH")
// Protected routes (require authentication)
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
// Agency logo upload (protected)
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
// 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")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
// Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port)

View File

@@ -6,5 +6,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.63
golang.org/x/crypto v0.27.0
)

View File

@@ -5,7 +5,6 @@ import (
"errors"
"log"
"net/http"
"strings"
"time"
"aggios-app/backend/internal/config"
@@ -13,6 +12,7 @@ import (
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/google/uuid"
)
@@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
}
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
tenant, admin, err := h.agencyService.RegisterAgency(req)
if err != nil {
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
json.NewEncoder(w).Encode(response)
}
// PublicRegister handles public agency registration
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.PublicRegisterAgencyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Error decoding request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
log.Printf("📦 Full Payload: %+v", req)
// Map to internal request
phone := ""
if len(req.Contacts) > 0 {
phone = req.Contacts[0].Whatsapp
}
internalReq := domain.RegisterAgencyRequest{
AgencyName: req.CompanyName,
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Description: req.Description,
Website: req.Website,
Industry: req.Industry,
Phone: phone,
TeamSize: req.TeamSize,
CEP: req.CEP,
State: req.State,
City: req.City,
Neighborhood: req.Neighborhood,
Street: req.Street,
Number: req.Number,
Complement: req.Complement,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
AdminEmail: req.Email,
AdminPassword: req.Password,
AdminName: req.FullName,
}
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
if err != nil {
log.Printf("❌ Error registering agency: %v", err)
switch err {
case service.ErrSubdomainTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
// Generate JWT token for the new admin
claims := jwt.MapClaims{
"user_id": admin.ID.String(),
"email": admin.Email,
"role": admin.Role,
"tenant_id": tenant.ID.String(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
protocol := "http://"
if h.cfg.App.Environment == "production" {
protocol = "https://"
}
response := map[string]interface{}{
"token": tokenString,
"id": admin.ID,
"email": admin.Email,
"name": admin.Name,
"role": admin.Role,
"tenantId": tenant.ID,
"company": tenant.Name,
"subdomain": tenant.Subdomain,
"message": "Agency registered successfully",
"access_url": protocol + tenant.Domain,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// RegisterClient handles client registration (ADMIN_AGENCIA only)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
return
}
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
if agencyID == "" || agencyID == r.URL.Path {
http.NotFound(w, r)
vars := mux.Vars(r)
agencyID := vars["id"]
if agencyID == "" {
http.Error(w, "Missing agency ID", http.StatusBadRequest)
return
}
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodPatch:
var updateData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if isActive, ok := updateData["is_active"].(bool); ok {
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {

View File

@@ -0,0 +1,238 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"aggios-app/backend/internal/api/middleware"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadLogo handles logo file uploads
func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
// Only accept POST
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("Logo upload request received from tenant")
// Get tenant ID from context
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
if tenantIDVal == nil {
log.Printf("No tenant ID in context")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Try to get as uuid.UUID first, if that fails try string and parse
var tenantID uuid.UUID
var ok bool
tenantID, ok = tenantIDVal.(uuid.UUID)
if !ok {
// Try as string
tenantIDStr, isString := tenantIDVal.(string)
if !isString {
log.Printf("Invalid tenant ID type: %T", tenantIDVal)
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
var err error
tenantID, err = uuid.Parse(tenantIDStr)
if err != nil {
log.Printf("Failed to parse tenant ID: %v", err)
http.Error(w, "Invalid tenant ID format", http.StatusBadRequest)
return
}
}
log.Printf("Processing logo upload for tenant: %s", tenantID)
// Parse multipart form (2MB max)
const maxLogoSize = 2 * 1024 * 1024
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("logo")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type
contentType := header.Header.Get("Content-Type")
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" {
http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest)
return
}
// Get logo type (logo or horizontal)
logoType := r.FormValue("type")
if logoType != "logo" && logoType != "horizontal" {
logoType = "logo"
}
// Get current logo URL from database to delete old file
var currentLogoURL string
var queryErr error
if logoType == "horizontal" {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
} else {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
}
if queryErr != nil && queryErr.Error() != "sql: no rows in result set" {
log.Printf("Warning: Failed to get current logo URL: %v", queryErr)
}
// 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
}
// Ensure bucket exists
bucketName := "aggios-logos"
ctx := context.Background()
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
log.Printf("Failed to check bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
log.Printf("Failed to create bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
// Set bucket policy to public-read
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}]
}`, bucketName)
err = minioClient.SetBucketPolicy(ctx, bucketName, policy)
if err != nil {
log.Printf("Warning: Failed to set bucket policy: %v", err)
}
}
// Read file content
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext)
// Upload to MinIO
_, err = minioClient.PutObject(
ctx,
bucketName,
filename,
bytes.NewReader(fileBytes),
int64(len(fileBytes)),
minio.PutObjectOptions{
ContentType: contentType,
},
)
if err != nil {
log.Printf("Failed to upload to MinIO: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// 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://api.localhost/api/files/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
oldFilename := ""
if len(currentLogoURL) > 0 {
// 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):]
}
}
}
if oldFilename != "" {
err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{})
if err != nil {
log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err)
// Don't fail the request if deletion fails
} else {
log.Printf("Old logo deleted successfully: %s", oldFilename)
}
}
}
// 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 {
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
}
if err2 != nil {
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{
"logo_url": logoURL,
"message": "Logo uploaded successfully",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -2,8 +2,11 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
@@ -11,43 +14,61 @@ 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,
}
}
type AgencyProfileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
type UpdateAgencyProfileRequest struct {
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
// GetProfile returns the current agency profile
@@ -57,10 +78,11 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
return
}
// Get tenant from context (set by middleware)
tenantID := r.Context().Value("tenantID")
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
return
}
@@ -82,20 +104,32 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name)
log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s",
tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial)
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
@@ -109,8 +143,8 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
// Get tenant from context
tenantID := r.Context().Value("tenantID")
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
@@ -131,18 +165,26 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
// Prepare updates
updates := map[string]interface{}{
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"neighborhood": req.Neighborhood,
"number": req.Number,
"complement": req.Complement,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
"team_size": req.TeamSize,
"primary_color": req.PrimaryColor,
"secondary_color": req.SecondaryColor,
"logo_url": req.LogoURL,
"logo_horizontal_url": req.LogoHorizontalURL,
}
// Update in database
@@ -159,21 +201,30 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,239 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type AgencyTemplateHandler struct {
templateRepo *repository.AgencyTemplateRepository
agencyService *service.AgencyService
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
}
func NewAgencyTemplateHandler(
templateRepo *repository.AgencyTemplateRepository,
agencyService *service.AgencyService,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
) *AgencyTemplateHandler {
return &AgencyTemplateHandler{
templateRepo: templateRepo,
agencyService: agencyService,
userRepo: userRepo,
tenantRepo: tenantRepo,
}
}
// GetTemplateBySlug - Public endpoint to get template details
func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
http.Error(w, "Missing slug parameter", http.StatusBadRequest)
return
}
template, err := h.templateRepo.FindBySlug(slug)
if err != nil {
log.Printf("Template not found: %v", err)
http.Error(w, "Template not found or expired", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// PublicRegisterAgency - Public endpoint for agency registration via template
func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) {
var req domain.AgencyRegistrationViaTemplate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 1. Validar template
template, err := h.templateRepo.FindBySlug(req.TemplateSlug)
if err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Invalid or expired template", http.StatusBadRequest)
return
}
// 2. Validar campos obrigatórios
if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// 3. Validar senha
if len(req.AdminPassword) < 8 {
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
// 4. Verificar se email já existe
existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail)
if existingUser != nil {
http.Error(w, "Email already registered", http.StatusConflict)
return
}
// 5. Verificar se subdomain já existe
existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain)
if existingTenant != nil {
http.Error(w, "Subdomain already taken", http.StatusConflict)
return
}
// 6. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// 7. Criar tenant (agência)
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Website: req.Website,
Phone: req.Phone,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
}
// Endereço (se fornecido)
if req.Address != nil {
tenant.Address = req.Address["street"]
tenant.Number = req.Address["number"]
tenant.Complement = req.Address["complement"]
tenant.Neighborhood = req.Address["neighborhood"]
tenant.City = req.Address["city"]
tenant.State = req.Address["state"]
tenant.Zip = req.Address["cep"]
}
// Personalização do template
if template.CustomPrimaryColor.Valid {
tenant.PrimaryColor = template.CustomPrimaryColor.String
}
if template.CustomLogoURL.Valid {
tenant.LogoURL = template.CustomLogoURL.String
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating agency", http.StatusInternalServerError)
return
}
// 8. Criar usuário admin da agência
user := &domain.User{
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating admin user", http.StatusInternalServerError)
return
}
// 9. Incrementar contador de uso do template
if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil {
log.Printf("Warning: failed to increment usage count: %v", err)
}
// 10. Preparar resposta com redirect
redirectURL := template.RedirectURL.String
if redirectURL == "" {
redirectURL = "http://" + req.Subdomain + ".localhost/login"
}
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage.String,
"tenant_id": tenant.ID,
"user_id": user.ID,
"redirect_url": redirectURL,
"subdomain": req.Subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateTemplate - SUPERADMIN only
func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var req domain.CreateAgencyTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields)
modulesJSON, _ := json.Marshal(req.AvailableModules)
template := &domain.AgencySignupTemplate{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
FormFields: formFieldsJSON,
AvailableModules: modulesJSON,
IsActive: true,
}
if req.CustomPrimaryColor != "" {
template.CustomPrimaryColor.Valid = true
template.CustomPrimaryColor.String = req.CustomPrimaryColor
}
if req.CustomLogoURL != "" {
template.CustomLogoURL.Valid = true
template.CustomLogoURL.String = req.CustomLogoURL
}
if req.RedirectURL != "" {
template.RedirectURL.Valid = true
template.RedirectURL.String = req.RedirectURL
}
if req.SuccessMessage != "" {
template.SuccessMessage.Valid = true
template.SuccessMessage.String = req.SuccessMessage
}
if err := h.templateRepo.Create(template); err != nil {
log.Printf("Error creating template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// ListTemplates - SUPERADMIN only
func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.templateRepo.List()
if err != nil {
http.Error(w, "Error fetching templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}

View File

@@ -3,9 +3,11 @@ package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
@@ -55,28 +57,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
// Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📧 Login attempt for email: %s", req.Email)
response, err := h.authService.Login(req)
if err != nil {
log.Printf("❌ authService.Login error: %v", err)
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
@@ -85,6 +97,24 @@ 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)
}

View File

@@ -0,0 +1,104 @@
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
}
// Whitelist de buckets públicos permitidos
allowedBuckets := map[string]bool{
"aggios-logos": true,
}
if !allowedBuckets[bucket] {
log.Printf("🚫 Access denied to bucket: %s", bucket)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Proteção contra path traversal
if strings.Contains(filePath, "..") {
log.Printf("🚫 Path traversal attempt detected: %s", filePath)
http.Error(w, "Invalid path", 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)
}

View File

@@ -0,0 +1,38 @@
package handlers
import (
"encoding/json"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type HashRequest struct {
Password string `json:"password"`
}
type HashResponse struct {
Hash string `json:"hash"`
}
func GenerateHash(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req HashRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to generate hash", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)})
}

View File

@@ -0,0 +1,268 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// PlanHandler handles plan-related endpoints
type PlanHandler struct {
planService *service.PlanService
}
// NewPlanHandler creates a new plan handler
func NewPlanHandler(planService *service.PlanService) *PlanHandler {
return &PlanHandler{
planService: planService,
}
}
// RegisterRoutes registers plan routes
func (h *PlanHandler) RegisterRoutes(r *mux.Router) {
// Note: Route protection is done in main.go with authMiddleware wrapper
r.HandleFunc("/api/admin/plans", h.CreatePlan).Methods(http.MethodPost)
r.HandleFunc("/api/admin/plans", h.ListPlans).Methods(http.MethodGet)
r.HandleFunc("/api/admin/plans/{id}", h.GetPlan).Methods(http.MethodGet)
r.HandleFunc("/api/admin/plans/{id}", h.UpdatePlan).Methods(http.MethodPut)
r.HandleFunc("/api/admin/plans/{id}", h.DeletePlan).Methods(http.MethodDelete)
// Public routes (for signup flow)
r.HandleFunc("/api/plans", h.ListActivePlans).Methods(http.MethodGet)
r.HandleFunc("/api/plans/{id}", h.GetActivePlan).Methods(http.MethodGet)
}
// CreatePlan creates a new plan (admin only)
func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 CREATE PLAN - Method: %s", r.Method)
var req domain.CreatePlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Invalid request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
plan, err := h.planService.CreatePlan(&req)
if err != nil {
log.Printf("❌ Error creating plan: %v", err)
switch err {
case service.ErrPlanSlugTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrInvalidUserRange:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan created successfully",
"plan": plan,
})
log.Printf("✅ Plan created: %s", plan.ID)
}
// GetPlan retrieves a plan by ID (admin only)
func (h *PlanHandler) GetPlan(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlan(id)
if err != nil {
if err == service.ErrPlanNotFound {
http.Error(w, "Plan not found", http.StatusNotFound)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}
// ListPlans retrieves all plans (admin only)
func (h *PlanHandler) ListPlans(w http.ResponseWriter, r *http.Request) {
plans, err := h.planService.ListPlans()
if err != nil {
log.Printf("❌ Error listing plans: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": plans,
})
log.Printf("✅ Listed %d plans", len(plans))
}
// ListActivePlans retrieves all active plans (public)
func (h *PlanHandler) ListActivePlans(w http.ResponseWriter, r *http.Request) {
plans, err := h.planService.ListActivePlans()
if err != nil {
log.Printf("❌ Error listing active plans: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": plans,
})
}
// GetActivePlan retrieves an active plan by ID (public)
func (h *PlanHandler) GetActivePlan(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlan(id)
if err != nil {
if err == service.ErrPlanNotFound {
http.Error(w, "Plan not found", http.StatusNotFound)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Check if plan is active
if !plan.IsActive {
http.Error(w, "Plan not available", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}
// UpdatePlan updates a plan (admin only)
func (h *PlanHandler) UpdatePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 UPDATE PLAN - Method: %s", r.Method)
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
var req domain.UpdatePlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Invalid request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
plan, err := h.planService.UpdatePlan(id, &req)
if err != nil {
log.Printf("❌ Error updating plan: %v", err)
switch err {
case service.ErrPlanNotFound:
http.Error(w, "Plan not found", http.StatusNotFound)
case service.ErrPlanSlugTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrInvalidUserRange:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan updated successfully",
"plan": plan,
})
log.Printf("✅ Plan updated: %s", plan.ID)
}
// DeletePlan deletes a plan (admin only)
func (h *PlanHandler) DeletePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 DELETE PLAN - Method: %s", r.Method)
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
err = h.planService.DeletePlan(id)
if err != nil {
log.Printf("❌ Error deleting plan: %v", err)
switch err {
case service.ErrPlanNotFound:
http.Error(w, "Plan not found", http.StatusNotFound)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan deleted successfully",
})
log.Printf("✅ Plan deleted: %s", idStr)
}
// GetPlanByUserCount returns a plan for a given user count
func (h *PlanHandler) GetPlanByUserCount(w http.ResponseWriter, r *http.Request) {
userCountStr := r.URL.Query().Get("user_count")
if userCountStr == "" {
http.Error(w, "user_count parameter required", http.StatusBadRequest)
return
}
userCount, err := strconv.Atoi(userCountStr)
if err != nil {
http.Error(w, "Invalid user_count", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlanByUserCount(userCount)
if err != nil {
http.Error(w, "No plan available for this user count", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}

View File

@@ -0,0 +1,180 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"context"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type SignupTemplateHandler struct {
repo *repository.SignupTemplateRepository
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
agencyService *service.AgencyService
}
func NewSignupTemplateHandler(
repo *repository.SignupTemplateRepository,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
agencyService *service.AgencyService,
) *SignupTemplateHandler {
return &SignupTemplateHandler{
repo: repo,
userRepo: userRepo,
tenantRepo: tenantRepo,
agencyService: agencyService,
}
}
// CreateTemplate cria um novo template (SuperAdmin)
func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Pegar user_id do contexto (do middleware de autenticação)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok || userIDStr == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Error parsing user_id: %v", err)
http.Error(w, "Invalid user ID", http.StatusUnauthorized)
return
}
template.CreatedBy = userID
template.IsActive = true
ctx := context.Background()
if err := h.repo.Create(ctx, &template); err != nil {
log.Printf("Error creating signup template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(template)
}
// ListTemplates lista todos os templates (SuperAdmin)
func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
templates, err := h.repo.List(ctx)
if err != nil {
log.Printf("Error listing signup templates: %v", err)
http.Error(w, "Error listing templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}
// GetTemplateBySlug retorna um template pelo slug (público)
func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
ctx := context.Background()
template, err := h.repo.FindBySlug(ctx, slug)
if err != nil {
log.Printf("Error finding signup template by slug %s: %v", slug, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// GetTemplateByID retorna um template pelo ID (SuperAdmin)
func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
template, err := h.repo.FindByID(ctx, id)
if err != nil {
log.Printf("Error finding signup template by ID %s: %v", idStr, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// UpdateTemplate atualiza um template (SuperAdmin)
func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
template.ID = id
ctx := context.Background()
if err := h.repo.Update(ctx, &template); err != nil {
log.Printf("Error updating signup template: %v", err)
http.Error(w, "Error updating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// DeleteTemplate deleta um template (SuperAdmin)
func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
if err := h.repo.Delete(ctx, id); err != nil {
log.Printf("Error deleting signup template: %v", err)
http.Error(w, "Error deleting template", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,121 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"context"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
// PublicSignupRequest representa o cadastro público via template
type PublicSignupRequest struct {
TemplateSlug string `json:"template_slug"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Subdomain string `json:"subdomain"`
CompanyName string `json:"company_name"`
}
// PublicRegister handles public registration via template
func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
var req PublicSignupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
ctx := context.Background()
// 1. Buscar o template
template, err := h.repo.FindBySlug(ctx, req.TemplateSlug)
if err != nil {
log.Printf("Error finding template: %v", err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
// 2. Incrementar usage_count
if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil {
log.Printf("Error incrementing usage count: %v", err)
}
// 3. Verificar se email já existe
emailExists, err := h.userRepo.EmailExists(req.Email)
if err != nil {
log.Printf("Error checking email: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if emailExists {
http.Error(w, "Email already registered", http.StatusBadRequest)
return
}
// 4. Verificar se subdomain já existe (se fornecido)
if req.Subdomain != "" {
exists, err := h.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
log.Printf("Error checking subdomain: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "Subdomain already taken", http.StatusBadRequest)
return
}
}
// 5. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
// 6. Criar tenant (empresa/cliente)
tenant := &domain.Tenant{
Name: req.CompanyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
Description: "Registered via " + template.Name,
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating account", http.StatusInternalServerError)
return
}
// 7. Criar usuário admin do tenant
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating user", http.StatusInternalServerError)
return
}
// 8. Resposta de sucesso
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage,
"tenant_id": tenant.ID,
"user_id": user.ID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/domain"
@@ -40,3 +41,68 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants)
}
// CheckExists returns 200 if tenant exists by subdomain, otherwise 404
func (h *TenantHandler) CheckExists(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
}
_, 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
}
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,
}
log.Printf("📤 Returning tenant config for %s: logo_url=%s", subdomain, tenant.LogoURL)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadHandler handles file upload endpoints
type UploadHandler struct {
minioClient *minio.Client
cfg *config.Config
}
// NewUploadHandler creates a new upload handler
func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) {
// Initialize MinIO client
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
Secure: cfg.Minio.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
}
// Ensure bucket exists
ctx := context.Background()
bucketName := cfg.Minio.BucketName
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to check bucket existence: %w", err)
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
return nil, fmt.Errorf("failed to create bucket: %w", err)
}
}
return &UploadHandler{
minioClient: minioClient,
cfg: cfg,
}, nil
}
// UploadResponse represents the upload response
type UploadResponse struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
FileSize int64 `json:"file_size"`
}
// Upload handles file upload
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Try to get user ID from context (optional for signup flow)
userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string)
// Use temp tenant for unauthenticated uploads (signup flow)
tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
if userIDStr != "" {
// TODO: Query database to get tenant_id from user_id when authenticated
}
// Parse multipart form (max 10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large (max 10MB)", http.StatusBadRequest)
return
}
// Get file from form
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type (images only)
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "Only images are allowed", http.StatusBadRequest)
return
}
// Generate unique file ID
fileID := uuid.New()
ext := filepath.Ext(header.Filename)
objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext)
// Upload to MinIO
ctx := context.Background()
_, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
return
}
// Generate public URL (replace internal hostname with localhost for browser access)
fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName)
// Return response
response := UploadResponse{
FileID: fileID.String(),
FileName: header.Filename,
FileURL: fileURL,
FileSize: header.Size,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -2,6 +2,7 @@ package middleware
import (
"context"
"log"
"net/http"
"strings"
@@ -13,6 +14,7 @@ import (
type contextKey string
const UserIDKey contextKey = "userID"
const TenantIDKey contextKey = "tenantID"
// Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
@@ -39,15 +41,60 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Verificar se user_id existe e é do tipo correto
userIDClaim, ok := claims["user_id"]
if !ok || userIDClaim == nil {
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
return
}
userID, ok := userIDClaim.(string)
if !ok {
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
return
}
// tenant_id pode ser nil para SuperAdmin
var tenantIDFromJWT string
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
tenantIDFromJWT, _ = tenantIDClaim.(string)
}
// 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)
}
userID := claims["user_id"].(string)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 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))
})
}
}

View File

@@ -2,22 +2,39 @@ package middleware
import (
"context"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/repository"
)
type tenantContextKey string
const TenantIDKey tenantContextKey = "tenantID"
const SubdomainKey tenantContextKey = "subdomain"
const SubdomainKey contextKey = "subdomain"
// TenantDetector detects tenant from subdomain
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
// Get host from X-Forwarded-Host header (set by Next.js proxy) or Host header
// Priority order: X-Tenant-Subdomain (set by Next.js middleware) > X-Forwarded-Host > X-Original-Host > Host
tenantSubdomain := r.Header.Get("X-Tenant-Subdomain")
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:
@@ -26,19 +43,32 @@ 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)
// Add subdomain to context
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
@@ -46,7 +76,10 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
tenant, err := tenantRepo.FindBySubdomain(subdomain)
if err == nil && tenant != nil {
log.Printf("TenantDetector: found tenant %s for subdomain %s", tenant.ID.String(), subdomain)
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
} else {
log.Printf("TenantDetector: tenant not found for subdomain %s (err=%v)", subdomain, err)
}
}

View File

@@ -11,6 +11,7 @@ type Config struct {
JWT JWTConfig
Security SecurityConfig
App AppConfig
Minio MinioConfig
}
// AppConfig holds application-level settings
@@ -45,6 +46,16 @@ type SecurityConfig struct {
PasswordMinLength int
}
// 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
BucketName string
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
@@ -54,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{
@@ -90,6 +101,14 @@ func Load() *Config {
MaxAttemptsPerMin: maxAttempts,
PasswordMinLength: 8,
},
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",
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
},
}
}

View File

@@ -0,0 +1,66 @@
package domain
import (
"database/sql"
"time"
"github.com/google/uuid"
)
// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency)
type AgencySignupTemplate struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB
AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB
CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"`
CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"`
RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"`
SuccessMessage sql.NullString `json:"success_message" db:"success_message"`
IsActive bool `json:"is_active" db:"is_active"`
UsageCount int `json:"usage_count" db:"usage_count"`
MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"`
ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateAgencyTemplateRequest for creating a new agency template
type CreateAgencyTemplateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
FormFields []string `json:"form_fields"`
AvailableModules []string `json:"available_modules"`
CustomPrimaryColor string `json:"custom_primary_color"`
CustomLogoURL string `json:"custom_logo_url"`
RedirectURL string `json:"redirect_url"`
SuccessMessage string `json:"success_message"`
MaxUses int `json:"max_uses"`
}
// AgencyRegistrationViaTemplate for public registration via template
type AgencyRegistrationViaTemplate struct {
TemplateSlug string `json:"template_slug"`
// Agency info
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Website string `json:"website"`
Phone string `json:"phone"`
// Admin
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
// Optional fields
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
Address map[string]string `json:"address"`
}

View File

@@ -0,0 +1,78 @@
package domain
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/shopspring/decimal"
)
// Plan represents a subscription plan in the system
type Plan struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
MinUsers int `json:"min_users" db:"min_users"`
MaxUsers int `json:"max_users" db:"max_users"` // -1 means unlimited
MonthlyPrice *decimal.Decimal `json:"monthly_price" db:"monthly_price"`
AnnualPrice *decimal.Decimal `json:"annual_price" db:"annual_price"`
Features pq.StringArray `json:"features" db:"features"`
Differentiators pq.StringArray `json:"differentiators" db:"differentiators"`
StorageGB int `json:"storage_gb" db:"storage_gb"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreatePlanRequest represents the request to create a new plan
type CreatePlanRequest struct {
Name string `json:"name" validate:"required"`
Slug string `json:"slug" validate:"required"`
Description string `json:"description"`
MinUsers int `json:"min_users" validate:"required,min=1"`
MaxUsers int `json:"max_users" validate:"required"` // -1 for unlimited
MonthlyPrice *float64 `json:"monthly_price"`
AnnualPrice *float64 `json:"annual_price"`
Features []string `json:"features"`
Differentiators []string `json:"differentiators"`
StorageGB int `json:"storage_gb" validate:"required,min=1"`
IsActive bool `json:"is_active"`
}
// UpdatePlanRequest represents the request to update a plan
type UpdatePlanRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
Description *string `json:"description"`
MinUsers *int `json:"min_users"`
MaxUsers *int `json:"max_users"`
MonthlyPrice *float64 `json:"monthly_price"`
AnnualPrice *float64 `json:"annual_price"`
Features []string `json:"features"`
Differentiators []string `json:"differentiators"`
StorageGB *int `json:"storage_gb"`
IsActive *bool `json:"is_active"`
}
// Subscription represents an agency's subscription to a plan
type Subscription struct {
ID uuid.UUID `json:"id" db:"id"`
AgencyID uuid.UUID `json:"agency_id" db:"agency_id"`
PlanID uuid.UUID `json:"plan_id" db:"plan_id"`
BillingType string `json:"billing_type" db:"billing_type"` // monthly or annual
CurrentUsers int `json:"current_users" db:"current_users"`
Status string `json:"status" db:"status"` // active, suspended, cancelled
StartDate time.Time `json:"start_date" db:"start_date"`
RenewalDate time.Time `json:"renewal_date" db:"renewal_date"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateSubscriptionRequest represents the request to create a subscription
type CreateSubscriptionRequest struct {
AgencyID uuid.UUID `json:"agency_id" validate:"required"`
PlanID uuid.UUID `json:"plan_id" validate:"required"`
BillingType string `json:"billing_type" validate:"required,oneof=monthly annual"`
}

View File

@@ -0,0 +1,35 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// FormField representa um campo do formulário de cadastro
type FormField struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"` // email, password, text, tel, etc
Required bool `json:"required"`
Order int `json:"order"`
}
// SignupTemplate representa um template de cadastro personalizado
type SignupTemplate struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Slug string `json:"slug"`
FormFields []FormField `json:"form_fields"`
EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"]
RedirectURL string `json:"redirect_url,omitempty"`
SuccessMessage string `json:"success_message,omitempty"`
CustomLogoURL string `json:"custom_logo_url,omitempty"`
CustomPrimaryColor string `json:"custom_primary_color,omitempty"`
IsActive bool `json:"is_active"`
UsageCount int `json:"usage_count"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -18,14 +18,22 @@ type Tenant struct {
Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
Number string `json:"number,omitempty" db:"number"`
Complement string `json:"complement,omitempty" db:"complement"`
City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
TeamSize string `json:"team_size,omitempty" db:"team_size"`
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateTenantRequest represents the request to create a new tenant

View File

@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
Phone string `json:"phone"`
TeamSize string `json:"teamSize"`
// Endereço
CEP string `json:"cep"`
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
Number string `json:"number"`
Complement string `json:"complement"`
// Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
LogoHorizontalURL string `json:"logoHorizontalUrl"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// PublicRegisterAgencyRequest represents the public signup payload
type PublicRegisterAgencyRequest struct {
// User
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Company
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Address
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
// Contacts (simplified for now, taking the first one as phone if available)
Contacts []struct {
ID int `json:"id"`
Whatsapp string `json:"whatsapp"`
} `json:"contacts"`
// Domain
Subdomain string `json:"subdomain"`
// Branding
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`

View File

@@ -0,0 +1,168 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"encoding/json"
"fmt"
)
type AgencyTemplateRepository struct {
db *sql.DB
}
func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository {
return &AgencyTemplateRepository{db: db}
}
func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error {
query := `
INSERT INTO agency_signup_templates (
name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
return r.db.QueryRow(
query,
template.Name,
template.Slug,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.MaxUses,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
}
func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) {
var template domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
WHERE slug = $1 AND is_active = true
`
err := r.db.QueryRow(query, slug).Scan(
&template.ID, &template.Name, &template.Slug, &template.Description,
&template.FormFields, &template.AvailableModules,
&template.CustomPrimaryColor, &template.CustomLogoURL,
&template.RedirectURL, &template.SuccessMessage,
&template.IsActive, &template.UsageCount, &template.MaxUses,
&template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, err
}
// Validar se expirou
if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) {
return nil, fmt.Errorf("template expired")
}
// Validar limite de usos
if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) {
return nil, fmt.Errorf("template usage limit reached")
}
return &template, nil
}
func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) {
var templates []domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var t domain.AgencySignupTemplate
if err := rows.Scan(
&t.ID, &t.Name, &t.Slug, &t.Description,
&t.FormFields, &t.AvailableModules,
&t.CustomPrimaryColor, &t.CustomLogoURL,
&t.RedirectURL, &t.SuccessMessage,
&t.IsActive, &t.UsageCount, &t.MaxUses,
&t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, err
}
templates = append(templates, t)
}
return templates, rows.Err()
}
func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error {
query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error {
query := `
UPDATE agency_signup_templates
SET name = $1, description = $2, form_fields = $3, available_modules = $4,
custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7,
success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $11
`
_, err := r.db.Exec(
query,
template.Name,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.IsActive,
template.MaxUses,
template.ID,
)
return err
}
func (r *AgencyTemplateRepository) Delete(id string) error {
query := `DELETE FROM agency_signup_templates WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
// Helper: Convert form fields to JSON
func FormFieldsToJSON(fields []string) ([]byte, error) {
type FormField struct {
Name string `json:"name"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
}
var formFields []FormField
for _, field := range fields {
formFields = append(formFields, FormField{
Name: field,
Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword",
Enabled: true,
})
}
return json.Marshal(formFields)
}

View File

@@ -0,0 +1,283 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
"github.com/lib/pq"
)
// PlanRepository handles database operations for plans
type PlanRepository struct {
db *sql.DB
}
// NewPlanRepository creates a new plan repository
func NewPlanRepository(db *sql.DB) *PlanRepository {
return &PlanRepository{db: db}
}
// Create creates a new plan
func (r *PlanRepository) Create(plan *domain.Plan) error {
query := `
INSERT INTO plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at
`
now := time.Now()
plan.ID = uuid.New()
plan.CreatedAt = now
plan.UpdatedAt = now
features := pq.Array(plan.Features)
differentiators := pq.Array(plan.Differentiators)
return r.db.QueryRow(
query,
plan.ID,
plan.Name,
plan.Slug,
plan.Description,
plan.MinUsers,
plan.MaxUsers,
plan.MonthlyPrice,
plan.AnnualPrice,
features,
differentiators,
plan.StorageGB,
plan.IsActive,
plan.CreatedAt,
plan.UpdatedAt,
).Scan(&plan.ID, &plan.CreatedAt, &plan.UpdatedAt)
}
// GetByID retrieves a plan by ID
func (r *PlanRepository) GetByID(id uuid.UUID) (*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE id = $1
`
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := r.db.QueryRow(query, id).Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
return plan, nil
}
// GetBySlug retrieves a plan by slug
func (r *PlanRepository) GetBySlug(slug string) (*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE slug = $1
`
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := r.db.QueryRow(query, slug).Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
return plan, nil
}
// ListAll retrieves all plans
func (r *PlanRepository) ListAll() ([]*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
ORDER BY min_users ASC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var plans []*domain.Plan
for rows.Next() {
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := rows.Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
plans = append(plans, plan)
}
return plans, rows.Err()
}
// ListActive retrieves all active plans
func (r *PlanRepository) ListActive() ([]*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE is_active = true
ORDER BY min_users ASC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var plans []*domain.Plan
for rows.Next() {
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := rows.Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
plans = append(plans, plan)
}
return plans, rows.Err()
}
// Update updates a plan
func (r *PlanRepository) Update(plan *domain.Plan) error {
query := `
UPDATE plans
SET name = $2, slug = $3, description = $4, min_users = $5, max_users = $6, monthly_price = $7, annual_price = $8, features = $9, differentiators = $10, storage_gb = $11, is_active = $12, updated_at = $13
WHERE id = $1
RETURNING updated_at
`
plan.UpdatedAt = time.Now()
features := pq.Array(plan.Features)
differentiators := pq.Array(plan.Differentiators)
return r.db.QueryRow(
query,
plan.ID,
plan.Name,
plan.Slug,
plan.Description,
plan.MinUsers,
plan.MaxUsers,
plan.MonthlyPrice,
plan.AnnualPrice,
features,
differentiators,
plan.StorageGB,
plan.IsActive,
plan.UpdatedAt,
).Scan(&plan.UpdatedAt)
}
// Delete deletes a plan
func (r *PlanRepository) Delete(id uuid.UUID) error {
query := `DELETE FROM plans WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,280 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
type SignupTemplateRepository struct {
db *sql.DB
}
func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository {
return &SignupTemplateRepository{db: db}
}
// Create cria um novo template de cadastro
func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
INSERT INTO signup_templates (
name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
err = r.db.QueryRowContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.CreatedBy,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return fmt.Errorf("error creating signup template: %w", err)
}
return nil
}
// FindBySlug busca um template pelo slug
func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE slug = $1 AND is_active = true
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, slug).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// FindByID busca um template pelo ID
func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE id = $1
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, id).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// List lista todos os templates
func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("error listing signup templates: %w", err)
}
defer rows.Close()
var templates []*domain.SignupTemplate
for rows.Next() {
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := rows.Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("error scanning signup template: %w", err)
}
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
templates = append(templates, &template)
}
return templates, nil
}
// IncrementUsageCount incrementa o contador de uso
func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error {
query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
// Update atualiza um template
func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
UPDATE signup_templates SET
name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5,
redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9,
is_active = $10
WHERE id = $11
`
_, err = r.db.ExecContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.ID,
)
if err != nil {
return fmt.Errorf("error updating signup template: %w", err)
}
return nil
}
// Delete deleta um template
func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM signup_templates WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("error deleting signup template: %w", err)
}
return nil
}

View File

@@ -0,0 +1,203 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// SubscriptionRepository handles database operations for subscriptions
type SubscriptionRepository struct {
db *sql.DB
}
// NewSubscriptionRepository creates a new subscription repository
func NewSubscriptionRepository(db *sql.DB) *SubscriptionRepository {
return &SubscriptionRepository{db: db}
}
// Create creates a new subscription
func (r *SubscriptionRepository) Create(subscription *domain.Subscription) error {
query := `
INSERT INTO agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
now := time.Now()
subscription.ID = uuid.New()
subscription.CreatedAt = now
subscription.UpdatedAt = now
subscription.StartDate = now
// Set renewal date based on billing type
if subscription.BillingType == "annual" {
subscription.RenewalDate = now.AddDate(1, 0, 0)
} else {
subscription.RenewalDate = now.AddDate(0, 1, 0)
}
return r.db.QueryRow(
query,
subscription.ID,
subscription.AgencyID,
subscription.PlanID,
subscription.BillingType,
subscription.CurrentUsers,
subscription.Status,
subscription.StartDate,
subscription.RenewalDate,
subscription.CreatedAt,
subscription.UpdatedAt,
).Scan(&subscription.ID, &subscription.CreatedAt, &subscription.UpdatedAt)
}
// GetByID retrieves a subscription by ID
func (r *SubscriptionRepository) GetByID(id uuid.UUID) (*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
WHERE id = $1
`
subscription := &domain.Subscription{}
err := r.db.QueryRow(query, id).Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
return subscription, err
}
// GetByAgencyID retrieves a subscription by agency ID
func (r *SubscriptionRepository) GetByAgencyID(agencyID uuid.UUID) (*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
WHERE agency_id = $1 AND status = 'active'
LIMIT 1
`
subscription := &domain.Subscription{}
err := r.db.QueryRow(query, agencyID).Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
return subscription, err
}
// ListAll retrieves all subscriptions
func (r *SubscriptionRepository) ListAll() ([]*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var subscriptions []*domain.Subscription
for rows.Next() {
subscription := &domain.Subscription{}
err := rows.Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
if err != nil {
return nil, err
}
subscriptions = append(subscriptions, subscription)
}
return subscriptions, rows.Err()
}
// Update updates a subscription
func (r *SubscriptionRepository) Update(subscription *domain.Subscription) error {
query := `
UPDATE agency_subscriptions
SET plan_id = $2, billing_type = $3, current_users = $4, status = $5, renewal_date = $6, updated_at = $7
WHERE id = $1
RETURNING updated_at
`
subscription.UpdatedAt = time.Now()
return r.db.QueryRow(
query,
subscription.ID,
subscription.PlanID,
subscription.BillingType,
subscription.CurrentUsers,
subscription.Status,
subscription.RenewalDate,
subscription.UpdatedAt,
).Scan(&subscription.UpdatedAt)
}
// Delete deletes a subscription
func (r *SubscriptionRepository) Delete(id uuid.UUID) error {
query := `DELETE FROM agency_subscriptions WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateUserCount updates the current user count for a subscription
func (r *SubscriptionRepository) UpdateUserCount(agencyID uuid.UUID, userCount int) error {
query := `
UPDATE agency_subscriptions
SET current_users = $2, updated_at = $3
WHERE agency_id = $1 AND status = 'active'
`
_, err := r.db.Exec(query, agencyID, userCount, time.Now())
return err
}

View File

@@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}
// DB returns the underlying database connection
func (r *TenantRepository) DB() *sql.DB {
return r.db
}
// Create creates a new tenant
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
query := `
INSERT INTO tenants (
id, name, domain, subdomain, cnpj, razao_social, email, website,
address, city, state, zip, description, industry, created_at, updated_at
id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, neighborhood, number, complement, city, state, zip,
description, industry, team_size, primary_color, secondary_color,
logo_url, logo_horizontal_url, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING id, created_at, updated_at
`
@@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
tenant.CNPJ,
tenant.RazaoSocial,
tenant.Email,
tenant.Phone,
tenant.Website,
tenant.Address,
tenant.Neighborhood,
tenant.Number,
tenant.Complement,
tenant.City,
tenant.State,
tenant.Zip,
tenant.Description,
tenant.Industry,
tenant.TeamSize,
tenant.PrimaryColor,
tenant.SecondaryColor,
tenant.LogoURL,
tenant.LogoHorizontalURL,
tenant.CreatedAt,
tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
@@ -60,13 +76,15 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, city, state, zip, description, industry, is_active, created_at, updated_at
address, neighborhood, number, complement, city, state, zip, description, industry, team_size,
primary_color, secondary_color, logo_url, logo_horizontal_url,
is_active, created_at, updated_at
FROM tenants
WHERE id = $1
`
tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
@@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
&phone,
&website,
&address,
&neighborhood,
&number,
&complement,
&city,
&state,
&zip,
&description,
&industry,
&teamSize,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
@@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if address.Valid {
tenant.Address = address.String
}
if neighborhood.Valid {
tenant.Neighborhood = neighborhood.String
}
if number.Valid {
tenant.Number = number.String
}
if complement.Valid {
tenant.Complement = complement.String
}
if city.Valid {
tenant.City = city.String
}
@@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if industry.Valid {
tenant.Industry = industry.String
}
if teamSize.Valid {
tenant.TeamSize = teamSize.String
}
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
}
@@ -138,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,
)
@@ -157,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
@@ -171,7 +244,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
// FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
FROM tenants
ORDER BY created_at DESC
`
@@ -185,11 +258,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
var tenants []*domain.Tenant
for rows.Next() {
tenant := &domain.Tenant{}
var email, phone, cnpj, logoURL sql.NullString
err := rows.Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&email,
&phone,
&cnpj,
&logoURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
@@ -197,6 +276,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
if err != nil {
return nil, err
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
tenants = append(tenants, tenant)
}
@@ -209,7 +302,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
// Delete removes a tenant (and cascades to related data)
func (r *TenantRepository) Delete(id uuid.UUID) error {
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id)
// Start transaction
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Delete all users associated with this tenant first
_, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id)
if err != nil {
return err
}
// Delete the tenant
result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id)
if err != nil {
return err
}
@@ -223,7 +330,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error {
return sql.ErrNoRows
}
return nil
// Commit transaction
return tx.Commit()
}
// UpdateProfile updates tenant profile information
@@ -237,13 +345,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
phone = COALESCE($5, phone),
website = COALESCE($6, website),
address = COALESCE($7, address),
city = COALESCE($8, city),
state = COALESCE($9, state),
zip = COALESCE($10, zip),
description = COALESCE($11, description),
industry = COALESCE($12, industry),
updated_at = $13
WHERE id = $14
neighborhood = COALESCE($8, neighborhood),
number = COALESCE($9, number),
complement = COALESCE($10, complement),
city = COALESCE($11, city),
state = COALESCE($12, state),
zip = COALESCE($13, zip),
description = COALESCE($14, description),
industry = COALESCE($15, industry),
team_size = COALESCE($16, team_size),
primary_color = COALESCE($17, primary_color),
secondary_color = COALESCE($18, secondary_color),
logo_url = COALESCE($19, logo_url),
logo_horizontal_url = COALESCE($20, logo_horizontal_url),
updated_at = $21
WHERE id = $22
`
_, err := r.db.Exec(
@@ -255,14 +371,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
updates["phone"],
updates["website"],
updates["address"],
updates["neighborhood"],
updates["number"],
updates["complement"],
updates["city"],
updates["state"],
updates["zip"],
updates["description"],
updates["industry"],
updates["team_size"],
updates["primary_color"],
updates["secondary_color"],
updates["logo_url"],
updates["logo_horizontal_url"],
time.Now(),
id,
)
return err
}
// UpdateStatus updates the is_active status of a tenant
func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error {
query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, isActive, time.Now(), id)
return err
}

View File

@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"log"
"time"
"aggios-app/backend/internal/domain"
@@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error {
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
log.Printf("🔍 FindByEmail called with: %s", email)
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
@@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
)
if err == sql.ErrNoRows {
log.Printf("❌ User not found: %s", email)
return nil, nil
}
if err != nil {
log.Printf("❌ DB error finding user %s: %v", email, err)
return nil, err
}
return user, err
log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
return user, nil
}
// FindByID finds a user by ID

View File

@@ -60,24 +60,30 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
if req.Complement != "" {
address += " - " + req.Complement
}
if req.Neighborhood != "" {
address += " - " + req.Neighborhood
}
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Website: req.Website,
Address: address,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Phone: req.Phone,
Website: req.Website,
Address: address,
Neighborhood: req.Neighborhood,
Number: req.Number,
Complement: req.Complement,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
LogoHorizontalURL: req.LogoHorizontalURL,
}
if err := s.tenantRepo.Create(tenant); err != nil {
@@ -189,3 +195,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
return s.tenantRepo.Delete(id)
}
// UpdateAgencyStatus updates the is_active status of a tenant
func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.UpdateStatus(id, isActive)
}

View File

@@ -2,6 +2,7 @@ package service
import (
"errors"
"log"
"time"
"aggios-app/backend/internal/config"
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
return nil, err
}
if user == nil {
log.Printf("❌ User not found: %s", req.Email)
return nil, ErrInvalidCredentials
}
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
log.Printf("🔍 Provided password length: %d", len(req.Password))
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
return nil, ErrInvalidCredentials
}

View File

@@ -0,0 +1,286 @@
package service
import (
"database/sql"
"errors"
"fmt"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
var (
ErrPlanNotFound = errors.New("plan not found")
ErrPlanSlugTaken = errors.New("plan slug already exists")
ErrInvalidUserRange = errors.New("invalid user range: min_users must be less than or equal to max_users")
ErrSubscriptionNotFound = errors.New("subscription not found")
ErrUserLimitExceeded = errors.New("user limit exceeded for this plan")
ErrSubscriptionExists = errors.New("agency already has an active subscription")
)
// PlanService handles plan business logic
type PlanService struct {
planRepo *repository.PlanRepository
subscriptionRepo *repository.SubscriptionRepository
}
// NewPlanService creates a new plan service
func NewPlanService(planRepo *repository.PlanRepository, subscriptionRepo *repository.SubscriptionRepository) *PlanService {
return &PlanService{
planRepo: planRepo,
subscriptionRepo: subscriptionRepo,
}
}
// CreatePlan creates a new plan
func (s *PlanService) CreatePlan(req *domain.CreatePlanRequest) (*domain.Plan, error) {
// Validate user range
if req.MinUsers > req.MaxUsers && req.MaxUsers != -1 {
return nil, ErrInvalidUserRange
}
// Check if slug is unique
existing, _ := s.planRepo.GetBySlug(req.Slug)
if existing != nil {
return nil, ErrPlanSlugTaken
}
plan := &domain.Plan{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
MinUsers: req.MinUsers,
MaxUsers: req.MaxUsers,
Features: req.Features,
Differentiators: req.Differentiators,
StorageGB: req.StorageGB,
IsActive: req.IsActive,
}
// Convert prices if provided
if req.MonthlyPrice != nil {
price := decimal.NewFromFloat(*req.MonthlyPrice)
plan.MonthlyPrice = &price
}
if req.AnnualPrice != nil {
price := decimal.NewFromFloat(*req.AnnualPrice)
plan.AnnualPrice = &price
}
if err := s.planRepo.Create(plan); err != nil {
return nil, err
}
return plan, nil
}
// GetPlan retrieves a plan by ID
func (s *PlanService) GetPlan(id uuid.UUID) (*domain.Plan, error) {
plan, err := s.planRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
return plan, nil
}
// ListPlans retrieves all plans
func (s *PlanService) ListPlans() ([]*domain.Plan, error) {
return s.planRepo.ListAll()
}
// ListActivePlans retrieves all active plans
func (s *PlanService) ListActivePlans() ([]*domain.Plan, error) {
return s.planRepo.ListActive()
}
// UpdatePlan updates a plan
func (s *PlanService) UpdatePlan(id uuid.UUID, req *domain.UpdatePlanRequest) (*domain.Plan, error) {
plan, err := s.planRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
// Update fields if provided
if req.Name != nil {
plan.Name = *req.Name
}
if req.Slug != nil {
// Check if new slug is unique
existing, _ := s.planRepo.GetBySlug(*req.Slug)
if existing != nil && existing.ID != plan.ID {
return nil, ErrPlanSlugTaken
}
plan.Slug = *req.Slug
}
if req.Description != nil {
plan.Description = *req.Description
}
if req.MinUsers != nil {
plan.MinUsers = *req.MinUsers
}
if req.MaxUsers != nil {
plan.MaxUsers = *req.MaxUsers
}
if req.MonthlyPrice != nil {
price := decimal.NewFromFloat(*req.MonthlyPrice)
plan.MonthlyPrice = &price
}
if req.AnnualPrice != nil {
price := decimal.NewFromFloat(*req.AnnualPrice)
plan.AnnualPrice = &price
}
if req.Features != nil {
plan.Features = req.Features
}
if req.Differentiators != nil {
plan.Differentiators = req.Differentiators
}
if req.StorageGB != nil {
plan.StorageGB = *req.StorageGB
}
if req.IsActive != nil {
plan.IsActive = *req.IsActive
}
// Validate user range
if plan.MinUsers > plan.MaxUsers && plan.MaxUsers != -1 {
return nil, ErrInvalidUserRange
}
if err := s.planRepo.Update(plan); err != nil {
return nil, err
}
return plan, nil
}
// DeletePlan deletes a plan
func (s *PlanService) DeletePlan(id uuid.UUID) error {
// Check if plan exists
if _, err := s.planRepo.GetByID(id); err != nil {
if err == sql.ErrNoRows {
return ErrPlanNotFound
}
return err
}
return s.planRepo.Delete(id)
}
// CreateSubscription creates a new subscription for an agency
func (s *PlanService) CreateSubscription(req *domain.CreateSubscriptionRequest) (*domain.Subscription, error) {
// Check if plan exists
plan, err := s.planRepo.GetByID(req.PlanID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
// Check if agency already has active subscription
existing, err := s.subscriptionRepo.GetByAgencyID(req.AgencyID)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if existing != nil {
return nil, ErrSubscriptionExists
}
subscription := &domain.Subscription{
AgencyID: req.AgencyID,
PlanID: req.PlanID,
BillingType: req.BillingType,
Status: "active",
CurrentUsers: 0,
}
if err := s.subscriptionRepo.Create(subscription); err != nil {
return nil, err
}
// Load plan details
subscription.PlanID = plan.ID
return subscription, nil
}
// GetSubscription retrieves a subscription by ID
func (s *PlanService) GetSubscription(id uuid.UUID) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return subscription, nil
}
// GetAgencySubscription retrieves an agency's active subscription
func (s *PlanService) GetAgencySubscription(agencyID uuid.UUID) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return subscription, nil
}
// ListSubscriptions retrieves all subscriptions
func (s *PlanService) ListSubscriptions() ([]*domain.Subscription, error) {
return s.subscriptionRepo.ListAll()
}
// ValidateUserLimit checks if adding a user would exceed plan limit
func (s *PlanService) ValidateUserLimit(agencyID uuid.UUID, newUserCount int) error {
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
if err != nil {
if err == sql.ErrNoRows {
return ErrSubscriptionNotFound
}
return err
}
plan, err := s.planRepo.GetByID(subscription.PlanID)
if err != nil {
if err == sql.ErrNoRows {
return ErrPlanNotFound
}
return err
}
if plan.MaxUsers != -1 && newUserCount > plan.MaxUsers {
return fmt.Errorf("%w (limit: %d, requested: %d)", ErrUserLimitExceeded, plan.MaxUsers, newUserCount)
}
return nil
}
// GetPlanByUserCount returns the appropriate plan for a given user count
func (s *PlanService) GetPlanByUserCount(userCount int) (*domain.Plan, error) {
plans, err := s.planRepo.ListActive()
if err != nil {
return nil, err
}
// Find the plan that fits the user count
for _, plan := range plans {
if userCount >= plan.MinUsers && (plan.MaxUsers == -1 || userCount <= plan.MaxUsers) {
return plan, nil
}
}
return nil, fmt.Errorf("no plan found for user count: %d", userCount)
}

View File

@@ -6,10 +6,6 @@ services:
restart: unless-stopped
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.endpoint=tcp://host.docker.internal:2375"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=aggios-network"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
@@ -69,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:
@@ -111,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:
@@ -171,6 +183,31 @@ services:
networks:
- aggios-network
# Frontend - Agency (tenant-only)
agency:
build:
context: ./front-end-agency
dockerfile: Dockerfile
container_name: aggios-agency
restart: unless-stopped
labels:
- "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
- API_INTERNAL_URL=http://backend:8080
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- aggios-network
volumes:
postgres_data:
driver: local

41
front-end-agency/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build Next.js
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Copy built app from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Start app
CMD ["npm", "start"]

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -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 (
<AuthGuard>
<AgencyBranding colors={colors} />
<DashboardLayout menuItems={AGENCY_MENU_ITEMS}>
{children}
</DashboardLayout>
</AuthGuard>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
export default function ContratosPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Contratos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Contratos e Assinaturas em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import {
UsersIcon,
CurrencyDollarIcon,
ChartPieIcon,
ArrowTrendingUpIcon,
} from '@heroicons/react/24/outline';
export default function CRMPage() {
const stats = [
{ name: 'Leads Totais', value: '124', icon: UsersIcon, color: 'blue' },
{ name: 'Oportunidades', value: 'R$ 450k', icon: CurrencyDollarIcon, color: 'green' },
{ name: 'Taxa de Conversão', value: '24%', icon: ChartPieIcon, color: 'purple' },
{ name: 'Crescimento', value: '+12%', icon: ArrowTrendingUpIcon, color: 'orange' },
];
return (
<div className="p-6 h-full overflow-auto">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Mission Control (CRM)
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visão geral do relacionamento com clientes
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
{stat.name}
</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{stat.value}
</p>
</div>
<div
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
>
<Icon
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
/>
</div>
</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Funil de Vendas (Em breve)</p>
</div>
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Atividades Recentes (Em breve)</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState } from 'react';
import { getUser } from '@/lib/auth';
import {
RocketLaunchIcon,
ChartBarIcon,
BriefcaseIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
FolderIcon,
ShareIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
CheckCircleIcon,
ClockIcon,
} 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' },
{ name: 'Projetos Ativos', value: '12', change: '-1', changeType: 'decrease', icon: BriefcaseIcon, color: 'purple' },
{ name: 'Chamados Abertos', value: '3', change: '-2', changeType: 'decrease', icon: LifebuoyIcon, color: 'orange' },
];
const modules = [
{
title: 'CRM & Vendas',
icon: RocketLaunchIcon,
color: 'blue',
stats: [
{ label: 'Propostas Enviadas', value: '8' },
{ label: 'Aguardando Aprovação', value: '3' },
{ label: 'Taxa de Conversão', value: '24%' },
]
},
{
title: 'Financeiro & ERP',
icon: ChartBarIcon,
color: 'green',
stats: [
{ label: 'A Receber', value: 'R$ 45.200' },
{ label: 'A Pagar', value: 'R$ 12.800' },
{ label: 'Fluxo de Caixa', value: 'Positivo' },
]
},
{
title: 'Projetos & Tarefas',
icon: BriefcaseIcon,
color: 'purple',
stats: [
{ label: 'Em Andamento', value: '12' },
{ label: 'Atrasados', value: '1' },
{ label: 'Concluídos (Mês)', value: '4' },
]
},
{
title: 'Helpdesk',
icon: LifebuoyIcon,
color: 'orange',
stats: [
{ label: 'Novos Chamados', value: '3' },
{ label: 'Tempo Médio Resposta', value: '2h' },
{ label: 'Satisfação', value: '4.8/5' },
]
},
{
title: 'Documentos & Contratos',
icon: DocumentTextIcon,
color: 'indigo',
stats: [
{ label: 'Contratos Ativos', value: '28' },
{ label: 'A Vencer (30 dias)', value: '2' },
{ label: 'Docs Armazenados', value: '1.2GB' },
]
},
{
title: 'Redes Sociais',
icon: ShareIcon,
color: 'pink',
stats: [
{ label: 'Posts Agendados', value: '14' },
{ label: 'Engajamento', value: '+8.5%' },
{ label: 'Novos Seguidores', value: '120' },
]
}
];
return (
<div className="p-6 h-full overflow-auto">
<div className="space-y-8">
{/* Header Personalizado */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-heading font-bold text-gray-900 dark:text-white">
{greeting}, {userName || 'Administrador'}! 👋
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Aqui está o resumo da sua agência hoje. Tudo parece estar sob controle.
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-800 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
Sistema Operacional
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
</span>
</div>
</div>
{/* Top Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm"
>
<div className="flex items-center justify-between">
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
</div>
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
}`}>
{stat.changeType === 'increase' ? (
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
) : (
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
)}
{stat.change}
</div>
</div>
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
</div>
</div>
);
})}
</div>
{/* Modules Grid */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Performance por Módulo
</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => {
const Icon = module.icon;
return (
<div
key={module.title}
className="rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 p-6 hover:border-gray-200 dark:hover:border-zinc-700 transition-colors"
>
<div className="flex items-center gap-3 mb-6">
<div className={`p-2 rounded-lg bg-${module.color}-50 dark:bg-${module.color}-900/20`}>
<Icon className={`h-5 w-5 text-${module.color}-600 dark:text-${module.color}-400`} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{module.title}
</h3>
</div>
<div className="space-y-4">
{module.stats.map((stat, idx) => (
<div key={idx} className="flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{stat.label}</span>
<span className="font-medium text-gray-900 dark:text-white">{stat.value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function DocumentosPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão Eletrônica de Documentos (GED) em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function ERPPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Sistema Integrado de Gestão Empresarial em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function HelpdeskPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Helpdesk</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Central de Suporte e Chamados em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Metadata } from 'next';
import { getAgencyLogo, getAgencyColors } from '@/lib/server-api';
import { AgencyLayoutClient } from './AgencyLayoutClient';
// 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';
/**
* generateMetadata - Executado no servidor antes do render
* Define o favicon dinamicamente baseado no subdomínio da agência
*/
export async function generateMetadata(): Promise<Metadata> {
const logoUrl = await getAgencyLogo();
return {
icons: {
icon: logoUrl || '/favicon.ico',
shortcut: logoUrl || '/favicon.ico',
apple: logoUrl || '/favicon.ico',
},
};
}
export default async function AgencyLayout({
children,
}: {
children: React.ReactNode;
}) {
// Buscar cores da agência no servidor
const colors = await getAgencyColors();
return <AgencyLayoutClient colors={colors}>{children}</AgencyLayoutClient>;
}

View File

@@ -0,0 +1,10 @@
export default function PagamentosPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Pagamentos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Pagamentos e Cobranças em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AgencyRootPage() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,10 @@
export default function ProjetosPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Projetos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Projetos em breve</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function SocialPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Gestão de Redes Sociais</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Planejamento e Publicação de Posts em breve</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
"use client";
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<string>('');
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();
// Validações básicas
if (!email) {
toast.error('Por favor, insira seu email');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast.error('Por favor, insira um email válido');
return;
}
setIsLoading(true);
try {
// Simular envio de email
await new Promise((resolve) => setTimeout(resolve, 2000));
setEmailSent(true);
toast.success('Email de recuperação enviado com sucesso!');
} catch (error) {
toast.error('Erro ao enviar email. Tente novamente.');
} finally {
setIsLoading(false);
}
};
return (
<>
<Toaster
position="top-center"
toastOptions={{
duration: 5000,
style: {
background: '#FFFFFF',
color: '#000000',
padding: '16px',
borderRadius: '8px',
border: '1px solid #E5E5E5',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
error: {
icon: '⚠️',
style: {
background: '#ef4444',
color: '#FFFFFF',
border: 'none',
},
},
success: {
icon: '✓',
style: {
background: '#10B981',
color: '#FFFFFF',
border: 'none',
},
},
}}
/>
<div className="flex min-h-screen">
{/* Lado Esquerdo - Formulário */}
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
<div className="w-full max-w-md">
{/* Logo mobile */}
<div className="lg:hidden text-center mb-8">
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--brand-color)' }}>
<h1 className="text-3xl font-bold text-white">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
</div>
</div>
{!emailSent ? (
<>
{/* Header */}
<div className="mb-8">
<h2 className="text-[28px] font-bold text-zinc-900 dark:text-white">
Recuperar Senha
</h2>
<p className="text-[14px] text-zinc-600 dark:text-zinc-400 mt-2">
Digite seu email e enviaremos um link para redefinir sua senha
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Email"
type="email"
placeholder="seu@email.com"
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoading}
>
Enviar link de recuperação
</Button>
<div className="text-center">
<Link
href="/login"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Voltar para o login
</Link>
</div>
</form>
</>
) : (
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i className="ri-mail-check-line text-3xl text-green-600"></i>
</div>
<h2 className="text-[24px] font-bold text-zinc-900 dark:text-white mb-2">
Verifique seu email
</h2>
<p className="text-zinc-600 dark:text-zinc-400 mb-8">
Enviamos um link de recuperação para <strong>{email}</strong>
</p>
<Button
variant="outline"
className="w-full"
onClick={() => setEmailSent(false)}
>
Tentar outro email
</Button>
<div className="mt-6">
<Link
href="/login"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Voltar para o login
</Link>
</div>
</div>
)}
</div>
</div>
{/* Lado Direito - Branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--brand-color)' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold mb-6">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
<p className="text-xl opacity-90">
Recupere o acesso à sua conta de forma segura e rápida.
</p>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { ReactNode } from 'react';
// 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 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 }) {
// 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}</>;
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "GET",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
const body = await req.json();
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "PUT",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
const body = await req.json();
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "POST",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
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('📦 [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`, {
method: 'POST',
headers: {
'Authorization': authorization,
},
body: formData,
});
console.log('📡 [Next.js] Backend response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Backend error:', errorText);
return NextResponse.json(
{ error: errorText || 'Failed to upload logo' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Logo upload error:', error);
return NextResponse.json(
{ error: 'Internal server error: ' + (error instanceof Error ? error.message : String(error)) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const response = await fetch('http://aggios-backend:8080/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Erro ao processar login' },
{ status: 500 }
);
}
}

View File

@@ -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 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,183 @@
@config "../tailwind.config.js";
@import "tailwindcss";
@import "./tokens.css";
@variant dark (&:where(.dark, .dark *));
:root {
color-scheme: light;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
html.dark {
color-scheme: dark;
}
@layer base {
* {
font-family: var(--font-arimo), ui-sans-serif, system-ui, sans-serif;
}
a,
button,
[role="button"],
input[type="submit"],
input[type="reset"],
input[type="button"],
label[for] {
cursor: pointer;
}
body {
background-color: var(--color-surface-muted);
color: var(--color-text-primary);
transition: background-color 0.25s ease, color 0.25s ease;
}
::selection {
background-color: var(--color-brand-500);
color: var(--color-text-inverse);
}
/* Seleção em campos de formulário usa o gradiente padrão da marca */
input::selection,
textarea::selection,
select::selection {
background: var(--color-gradient-brand);
color: var(--color-text-inverse);
}
.surface-card {
background-color: var(--color-surface-card);
border: 1px solid var(--color-border-strong);
box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
}
.glass-panel {
background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
backdrop-filter: blur(20px);
}
.gradient-text {
background: var(--color-gradient-brand);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { Arimo, 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 arimo = Arimo({
variable: "--font-arimo",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const openSans = Open_Sans({
variable: "--font-open-sans",
subsets: ["latin"],
weight: ["600", "700"],
});
const firaCode = Fira_Code({
variable: "--font-fira-code",
subsets: ["latin"],
weight: ["400", "600"],
});
export async function generateMetadata(): Promise<Metadata> {
const logoUrl = await getAgencyLogo();
// Adicionar timestamp para forçar atualização do favicon
const faviconUrl = logoUrl
? `${logoUrl}?v=${Date.now()}`
: '/favicon.ico';
return {
title: "Aggios - Dashboard",
description: "Plataforma SaaS para agências digitais",
icons: {
icon: faviconUrl,
shortcut: faviconUrl,
apple: faviconUrl,
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
</head>
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`} suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<LayoutWrapper>
{children}
</LayoutWrapper>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,340 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button, Input, Checkbox } from "@/components/ui";
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 });
export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [subdomain, setSubdomain] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const [formData, setFormData] = useState({
email: "",
password: "",
rememberMe: false,
});
useEffect(() => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const sub = hostname.split('.')[0];
const superAdmin = sub === 'dash';
setSubdomain(sub);
setIsSuperAdmin(superAdmin);
if (isAuthenticated()) {
// Validar token antes de redirecionar para evitar loops
const token = getToken();
fetch(API_ENDPOINTS.me, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (res.ok) {
const target = superAdmin ? '/superadmin' : '/dashboard';
window.location.href = target;
} else {
// Token inválido ou expirado
clearAuth();
}
})
.catch((err) => {
console.error('Erro ao validar sessão:', err);
// Em caso de erro de rede, não redireciona nem limpa, deixa o usuário tentar logar
});
}
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage('');
setSuccessMessage('');
// Validações do lado do cliente
if (!formData.email) {
setErrorMessage('Por favor, insira seu email para continuar.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
setErrorMessage('Ops! O formato do email não parece correto. Por favor, verifique e tente novamente.');
return;
}
if (!formData.password) {
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;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
password: formData.password,
}),
});
if (!response.ok) {
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();
saveAuth(data.token, data.user);
console.log('Login successful:', data.user);
setSuccessMessage('Login realizado com sucesso! Redirecionando você agora...');
setTimeout(() => {
const target = isSuperAdmin ? '/superadmin' : '/dashboard';
window.location.href = target;
}, 1000);
} catch (error: any) {
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 (
<>
{/* Script inline para aplicar cor primária ANTES do React */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const cachedPrimary = localStorage.getItem('agency-primary-color');
if (cachedPrimary) {
function hexToRgb(hex) {
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(cachedPrimary);
if (primaryRgb) {
const root = document.documentElement;
root.style.setProperty('--brand-color', cachedPrimary);
root.style.setProperty('--gradient', 'linear-gradient(135deg, ' + cachedPrimary + ', ' + cachedPrimary + ')');
root.style.setProperty('--brand-rgb', primaryRgb);
root.style.setProperty('--brand-strong-rgb', primaryRgb);
root.style.setProperty('--brand-hover-rgb', primaryRgb);
}
}
} catch(e) {}
})();
`,
}}
/>
<LoginBranding />
<div className="flex min-h-screen">
{/* Lado Esquerdo - Formulário */}
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
<div className="w-full max-w-md">
{/* Logo mobile */}
<div className="lg:hidden text-center mb-8">
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--brand-color)' }}>
<h1 className="text-3xl font-bold text-white">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
</div>
</div>
{/* Theme Toggle */}
<div className="flex justify-end mb-4">
<ThemeToggle />
</div>
{/* Header */}
<div className="mb-8">
<h2 className="text-[28px] font-bold text-[#000000] dark:text-white">
{isSuperAdmin ? 'Painel Administrativo' : 'Bem-vindo de volta'}
</h2>
<p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2">
{isSuperAdmin
? 'Acesso exclusivo para administradores Aggios'
: 'Entre com suas credenciais para acessar o painel'
}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Mensagem de Erro */}
{errorMessage && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-300 leading-relaxed">
{errorMessage}
</p>
</div>
)}
{/* Mensagem de Sucesso */}
{successMessage && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800 dark:text-green-300 leading-relaxed">
{successMessage}
</p>
</div>
)}
<Input
label="Email"
type="email"
placeholder="seu@email.com"
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
value={formData.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value });
setErrorMessage(''); // Limpa o erro ao digitar
}}
required
/>
<Input
label="Senha"
type="password"
placeholder="Digite sua senha"
leftIcon={<LockClosedIcon className="w-5 h-5" />}
value={formData.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value });
setErrorMessage(''); // Limpa o erro ao digitar
}}
required
/>
<div className="flex items-center justify-between">
<Checkbox
id="rememberMe"
label="Lembrar de mim"
checked={formData.rememberMe}
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
/>
<Link
href="/recuperar-senha"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Esqueceu a senha?
</Link>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Entrando...' : 'Entrar'}
</Button>
{/* Link para cadastro - apenas para agências */}
{!isSuperAdmin && (
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
Ainda não tem conta?{' '}
<a
href="http://dash.localhost/cadastro"
className="font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Cadastre sua agência
</a>
</p>
)}
</form>
</div>
</div>
{/* Lado Direito - Branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--brand-color)' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold mb-6">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
<p className="text-xl opacity-90 mb-8">
{isSuperAdmin
? 'Gerencie todas as agências em um só lugar'
: 'Gerencie seus clientes com eficiência'
}
</p>
<div className="grid grid-cols-2 gap-6 text-left">
<div>
<ShieldCheckIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Seguro</h3>
<p className="text-sm opacity-80">Proteção de dados</p>
</div>
<div>
<BoltIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Rápido</h3>
<p className="text-sm opacity-80">Performance otimizada</p>
</div>
<div>
<UserGroupIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Colaborativo</h3>
<p className="text-sm opacity-80">Trabalho em equipe</p>
</div>
<div>
<ChartBarIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Insights</h3>
<p className="text-sm opacity-80">Relatórios detalhados</p>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui";
export default function NotFound() {
return (
<div className="flex min-h-screen">
{/* Lado Esquerdo - Conteúdo 404 */}
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
<div className="w-full max-w-md text-center">
{/* Logo mobile */}
<div className="lg:hidden mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-brand-500 to-brand-700">
<h1 className="text-3xl font-bold text-white">aggios</h1>
</div>
</div>
{/* 404 Number */}
<div className="mb-6">
<h1 className="text-[120px] font-bold leading-none gradient-text">
404
</h1>
</div>
{/* Message */}
<div className="mb-6">
<h2 className="text-[28px] font-bold text-[#000000] mb-2">
Página não encontrada
</h2>
<p className="text-[14px] text-[#7D7D7D] leading-relaxed">
Desculpe, a página que você está procurando não existe ou foi movida.
Verifique a URL ou volte para a página inicial.
</p>
</div>
{/* Actions */}
<div className="space-y-3">
<Button
variant="primary"
className="w-full"
size="lg"
leftIcon="ri-login-box-line"
onClick={() => window.location.href = '/login'}
>
Fazer login
</Button>
<Button
variant="outline"
className="w-full"
size="lg"
leftIcon="ri-user-add-line"
onClick={() => window.location.href = '/cadastro'}
>
Criar conta
</Button>
</div>
{/* Help Section */}
<div className="mt-8 p-5 bg-[#F5F5F5] rounded-lg text-left">
<h4 className="text-[13px] font-semibold text-[#000000] mb-3 flex items-center gap-2">
<i className="ri-questionnaire-line text-[16px] gradient-text" />
Precisa de ajuda?
</h4>
<ul className="text-[13px] text-[#7D7D7D] space-y-2">
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Verifique se a URL está correta</span>
</li>
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Tente buscar no menu principal</span>
</li>
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Entre em contato com o suporte se o problema persistir</span>
</li>
</ul>
</div>
</div>
</div>
{/* Lado Direito - Branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
{/* Logo */}
<div className="mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
aggios
</h1>
</div>
</div>
{/* Conteúdo */}
<div className="max-w-lg text-center">
<div className="w-20 h-20 rounded-2xl bg-white/20 flex items-center justify-center mb-6 mx-auto">
<i className="ri-compass-3-line text-4xl" />
</div>
<h2 className="text-4xl font-bold mb-4">Perdido? Estamos aqui!</h2>
<p className="text-white/80 text-lg mb-8">
Mesmo que esta página não exista, temos muitas outras funcionalidades incríveis
esperando por você no Aggios.
</p>
{/* Features */}
<div className="space-y-4 text-left">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-dashboard-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Dashboard Completo</h4>
<p className="text-white/70 text-sm">Visualize todos os seus projetos e métricas</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-team-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Gestão de Equipe</h4>
<p className="text-white/70 text-sm">Organize e acompanhe sua equipe</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-customer-service-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Suporte 24/7</h4>
<p className="text-white/70 text-sm">Estamos sempre disponíveis para ajudar</p>
</div>
</div>
</div>
</div>
</div>
{/* Círculos decorativos */}
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}

View File

@@ -0,0 +1,56 @@
@layer theme {
:root {
/* Gradientes */
--gradient: linear-gradient(135deg, #ff3a05, #ff0080);
--gradient-text: linear-gradient(to right, #ff3a05, #ff0080);
--gradient-primary: linear-gradient(135deg, #ff3a05, #ff0080);
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
/* 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;
--color-surface-dark: #0a0a0a;
--color-surface-muted: #f5f7fb;
--color-surface-card: #ffffff;
--color-border-strong: rgba(15, 23, 42, 0.08);
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-inverse: #f8fafc;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Espaçamento */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
.dark {
/* Invertendo superfícies e texto para dark mode */
--color-surface-light: #020617;
--color-surface-dark: #f8fafc;
--color-surface-muted: #0b1220;
--color-surface-card: #0f172a;
--color-border-strong: rgba(148, 163, 184, 0.25);
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5f5;
--color-text-inverse: #0f172a;
}
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from 'react';
interface DynamicFaviconProps {
logoUrl?: string;
}
export default function DynamicFavicon({ logoUrl }: DynamicFaviconProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !logoUrl) return;
// 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 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);
// 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;
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from 'react';
import { SwatchIcon } from '@heroicons/react/24/outline';
const themePresets = [
{
name: 'Azul (Marca)',
gradient: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
},
{
name: 'Azul/Roxo',
gradient: 'linear-gradient(135deg, #0066FF, #9333EA)',
},
{
name: 'Verde/Esmeralda',
gradient: 'linear-gradient(135deg, #10B981, #059669)',
},
{
name: 'Ciano/Azul',
gradient: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
},
{
name: 'Rosa/Roxo',
gradient: 'linear-gradient(135deg, #EC4899, #A855F7)',
},
{
name: 'Vermelho/Laranja',
gradient: 'linear-gradient(135deg, #EF4444, #F97316)',
},
{
name: 'Índigo/Violeta',
gradient: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
},
{
name: 'Âmbar/Amarelo',
gradient: 'linear-gradient(135deg, #F59E0B, #EAB308)',
},
];
export default function ThemeTester() {
const [isOpen, setIsOpen] = useState(false);
const applyTheme = (gradient: string) => {
document.documentElement.style.setProperty('--gradient-primary', gradient);
document.documentElement.style.setProperty('--gradient', gradient);
document.documentElement.style.setProperty('--gradient-text', gradient);
document.documentElement.style.setProperty('--color-gradient-brand', gradient);
};
return (
<div className="fixed bottom-4 right-4 z-50">
{/* Botão flutuante */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
style={{ background: 'var(--gradient-primary)' }}
title="Testar Temas"
>
<SwatchIcon className="w-6 h-6 text-white" />
</button>
{/* Painel de temas */}
{isOpen && (
<div className="absolute bottom-16 right-0 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white">Testar Gradientes</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Clique para aplicar temporariamente
</p>
</div>
<div className="p-3 max-h-96 overflow-y-auto space-y-2">
{themePresets.map((theme) => (
<button
key={theme.name}
onClick={() => applyTheme(theme.gradient)}
className="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group"
>
<div
className="w-12 h-12 rounded-lg shrink-0"
style={{ background: theme.gradient }}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white text-left">
{theme.name}
</span>
</button>
))}
</div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
💡 Recarregue a página para voltar ao tema original
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800" />;
}
const isDark = resolvedTheme === 'dark';
return (
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label={isDark ? 'Ativar tema claro' : 'Ativar tema escuro'}
title={isDark ? 'Alterar para modo claro' : 'Alterar para modo escuro'}
>
{isDark ? (
<SunIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
) : (
<MoonIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
</button>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { isAuthenticated } from '@/lib/auth';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [authorized, setAuthorized] = useState<boolean | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const checkAuth = () => {
const isAuth = isAuthenticated();
if (!isAuth) {
setAuthorized(false);
// Evitar redirect loop se já estiver no login (embora o AuthGuard deva ser usado apenas em rotas protegidas)
if (pathname !== '/login') {
router.push('/login');
}
} else {
setAuthorized(true);
}
};
checkAuth();
// Opcional: Adicionar listener para storage events para logout em outras abas
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token' || e.key === 'user') {
checkAuth();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [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 (
<div className="flex h-screen w-full items-center justify-center bg-gray-100 dark:bg-zinc-950">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-purple-600" />
</div>
);
}
if (!authorized) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100 dark:bg-zinc-950">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-purple-600" />
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useEffect } 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() {
useEffect(() => {
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 {
const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`;
const existingLinks = document.querySelectorAll("link[rel*='icon']");
if (existingLinks.length > 0) {
existingLinks.forEach(link => {
link.setAttribute('href', newHref);
});
} else {
const newLink = document.createElement('link');
newLink.rel = 'icon';
newLink.type = 'image/x-icon';
newLink.href = newHref;
document.head.appendChild(newLink);
}
} 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();
if (data.primary_color) {
applyTheme(data.primary_color);
localStorage.setItem('agency-primary-color', data.primary_color);
}
if (data.logo_url) {
updateFavicon(data.logo_url);
localStorage.setItem('agency-logo-url', data.logo_url);
}
return;
} else {
console.error('LoginBranding: API retornou:', response.status);
}
// 2. Fallback para 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();
}, []);
return null;
}

View File

@@ -0,0 +1,153 @@
"use client";
interface DashboardPreviewProps {
companyName: string;
subdomain: string;
primaryColor: string;
secondaryColor: string;
logoUrl?: string;
}
export default function DashboardPreview({
companyName,
subdomain,
primaryColor,
secondaryColor,
logoUrl
}: DashboardPreviewProps) {
return (
<div className="bg-white rounded-lg border-2 border-[#E5E5E5] overflow-hidden shadow-lg">
{/* Header do Preview */}
<div className="bg-[#F5F5F5] px-3 py-2 border-b border-[#E5E5E5] flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#28CA42]" />
</div>
<div className="flex-1 text-center">
<span className="text-xs text-[#7D7D7D]">
{subdomain || 'seu-dominio'}.aggios.app
</span>
</div>
</div>
{/* Conteúdo do Preview - Dashboard */}
<div className="aspect-video bg-[#F8F9FA] relative overflow-hidden">
{/* Sidebar */}
<div
className="absolute left-0 top-0 bottom-0 w-16 flex flex-col items-center py-4 gap-3"
style={{ backgroundColor: primaryColor }}
>
{/* Logo/Initial */}
<div className="w-10 h-10 rounded-lg bg-white/20 flex items-center justify-center text-white font-bold text-sm overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="w-full h-full object-cover" />
) : (
<span>{(companyName || 'E')[0].toUpperCase()}</span>
)}
</div>
{/* Menu Icons */}
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<i className="ri-dashboard-line text-white text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-folder-line text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-team-line text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-settings-3-line text-lg" />
</div>
</div>
{/* Main Content */}
<div className="ml-16 p-4">
{/* Top Bar */}
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-sm font-bold text-[#000000]">
{companyName || 'Sua Empresa'}
</h2>
<p className="text-xs text-[#7D7D7D]">Dashboard</p>
</div>
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${primaryColor}20` }}
>
<i className="ri-folder-line text-xs" style={{ color: primaryColor }} />
</div>
<span className="text-[10px] text-[#7D7D7D]">Projetos</span>
</div>
<p className="text-sm font-bold" style={{ color: primaryColor }}>24</p>
</div>
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: secondaryColor ? `${secondaryColor}20` : '#10B98120' }}
>
<i className="ri-team-line text-xs" style={{ color: secondaryColor || '#10B981' }} />
</div>
<span className="text-[10px] text-[#7D7D7D]">Clientes</span>
</div>
<p className="text-sm font-bold" style={{ color: secondaryColor || '#10B981' }}>15</p>
</div>
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded flex items-center justify-center bg-[#7D7D7D]/10">
<i className="ri-money-dollar-circle-line text-xs text-[#7D7D7D]" />
</div>
<span className="text-[10px] text-[#7D7D7D]">Receita</span>
</div>
<p className="text-sm font-bold text-[#7D7D7D]">R$ 45k</p>
</div>
</div>
{/* Chart Area */}
<div className="bg-white rounded-lg p-3 border border-[#E5E5E5]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-[#000000]">Desempenho</span>
<button
className="px-2 py-0.5 rounded text-[10px] text-white"
style={{ backgroundColor: primaryColor }}
>
Este mês
</button>
</div>
<div className="flex items-end gap-1 h-16">
{[40, 70, 45, 80, 60, 90, 75].map((height, i) => (
<div key={i} className="flex-1 flex flex-col justify-end">
<div
className="w-full rounded-t transition-all"
style={{
height: `${height}%`,
backgroundColor: i === 6 ? primaryColor : `${primaryColor}40`
}}
/>
</div>
))}
</div>
</div>
</div>
</div>
{/* Footer Preview */}
<div className="bg-[#F5F5F5] px-3 py-2 text-center border-t border-[#E5E5E5]">
<p className="text-[10px] text-[#7D7D7D]">
Preview do seu painel As cores e layout podem ser ajustados
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import DashboardPreview from "./DashboardPreview";
interface DynamicBrandingProps {
currentStep: number;
companyName?: string;
subdomain?: string;
primaryColor?: string;
secondaryColor?: string;
logoUrl?: string;
}
export default function DynamicBranding({
currentStep,
companyName = '',
subdomain = '',
primaryColor = '#0ea5e9',
secondaryColor = '#0284c7',
logoUrl = ''
}: DynamicBrandingProps) {
const [activeTestimonial, setActiveTestimonial] = useState(0);
const testimonials = [
{
text: "Com o Aggios, nossa produtividade aumentou 40%. Gestão de projetos nunca foi tão simples!",
author: "Maria Silva",
company: "DigitalWorks",
avatar: "MS"
},
{
text: "Reduzi 60% do tempo gasto com controle financeiro. Tudo centralizado em um só lugar.",
author: "João Santos",
company: "TechHub",
avatar: "JS"
},
{
text: "A melhor decisão para nossa agência. Dashboard intuitivo e relatórios incríveis!",
author: "Ana Costa",
company: "CreativeFlow",
avatar: "AC"
}
];
const stepContent = [
{
icon: "ri-user-heart-line",
title: "Bem-vindo ao Aggios!",
description: "Vamos criar sua conta em poucos passos",
benefits: [
"✓ Acesso completo ao painel",
"✓ Gestão ilimitada de projetos",
"✓ Suporte prioritário"
]
},
{
icon: "ri-building-line",
title: "Configure sua Empresa",
description: "Personalize de acordo com seu negócio",
benefits: [
"✓ Dashboard personalizado",
"✓ Gestão de equipe e clientes",
"✓ Controle financeiro integrado"
]
},
{
icon: "ri-map-pin-line",
title: "Quase lá!",
description: "Informações de localização e contato",
benefits: [
"✓ Multi-contatos configuráveis",
"✓ Integração com WhatsApp",
"✓ Notificações em tempo real"
]
},
{
icon: "ri-global-line",
title: "Seu Domínio Exclusivo",
description: "Escolha como acessar seu painel",
benefits: [
"✓ Subdomínio personalizado",
"✓ SSL incluído gratuitamente",
"✓ Domínio próprio (opcional)"
]
},
{
icon: "ri-palette-line",
title: "Personalize as Cores",
description: "Deixe com a cara da sua empresa",
benefits: [
"✓ Preview em tempo real",
"✓ Paleta de cores customizada",
"✓ Identidade visual única"
]
}
];
const content = stepContent[currentStep - 1] || stepContent[0];
// Auto-rotate testimonials
useEffect(() => {
const interval = setInterval(() => {
setActiveTestimonial((prev) => (prev + 1) % testimonials.length);
}, 5000);
return () => clearInterval(interval);
}, [testimonials.length]);
// Se for etapa 5, mostrar preview do dashboard
if (currentStep === 5) {
return (
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12">
{/* Logo */}
<div className="mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
aggios
</h1>
</div>
</div>
{/* Conteúdo */}
<div className="max-w-lg text-center">
<h2 className="text-3xl font-bold mb-2 text-white">Preview do seu Painel</h2>
<p className="text-white/80 text-lg">Veja como ficará seu dashboard personalizado</p>
</div>
{/* Preview */}
<div className="w-full max-w-3xl">
<DashboardPreview
companyName={companyName}
subdomain={subdomain}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
logoUrl={logoUrl}
/>
</div>
{/* Info */}
<div className="mt-6 text-center">
<p className="text-white/70 text-sm">
As cores e configurações são atualizadas em tempo real
</p>
</div>
{/* Decorative circles */}
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
</div>
);
}
return (
<div className="relative z-10 flex flex-col justify-between w-full p-12 text-white">
{/* Logo e Conteúdo da Etapa */}
<div className="flex flex-col justify-center flex-1">
{/* Logo */}
<div className="mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
aggios
</h1>
</div>
</div>
{/* Ícone e Título da Etapa */}
<div className="mb-6">
<div className="w-16 h-16 rounded-2xl bg-white/20 flex items-center justify-center mb-4">
<i className={`${content.icon} text-3xl`} />
</div>
<h2 className="text-3xl font-bold mb-2">{content.title}</h2>
<p className="text-white/80 text-lg">{content.description}</p>
</div>
{/* Benefícios */}
<div className="space-y-3 mb-8">
{content.benefits.map((benefit, index) => (
<div
key={index}
className="flex items-center gap-3 text-white/90 animate-fade-in"
style={{ animationDelay: `${index * 100}ms` }}
>
<span className="text-lg">{benefit}</span>
</div>
))}
</div>
</div>
{/* Carrossel de Depoimentos */}
<div className="relative">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<div className="mb-4">
<i className="ri-double-quotes-l text-3xl text-white/40" />
</div>
<p className="text-white/95 mb-4 min-h-[60px]">
{testimonials[activeTestimonial].text}
</p>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center font-semibold">
{testimonials[activeTestimonial].avatar}
</div>
<div>
<p className="font-semibold text-white">
{testimonials[activeTestimonial].author}
</p>
<p className="text-sm text-white/70">
{testimonials[activeTestimonial].company}
</p>
</div>
</div>
</div>
{/* Indicadores */}
<div className="flex gap-2 justify-center mt-4">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveTestimonial(index)}
className={`h-1.5 rounded-full transition-all ${index === activeTestimonial
? "w-8 bg-white"
: "w-1.5 bg-white/40 hover:bg-white/60"
}`}
aria-label={`Ir para depoimento ${index + 1}`}
/>
))}
</div>
</div>
{/* Decorative circles */}
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
</div>
);
}

View File

@@ -0,0 +1,126 @@
'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 é atualizado dinamicamente via DOM
*/
export function AgencyBranding({ colors }: AgencyBrandingProps) {
useEffect(() => {
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 {
const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`;
// Buscar TODOS os links de ícone (como estava funcionando antes)
const existingLinks = document.querySelectorAll("link[rel*='icon']");
if (existingLinks.length > 0) {
existingLinks.forEach(link => {
link.setAttribute('href', newHref);
});
console.log(`${existingLinks.length} favicons atualizados`);
} else {
const newLink = document.createElement('link');
newLink.rel = 'icon';
newLink.type = 'image/x-icon';
newLink.href = newHref;
document.head.appendChild(newLink);
console.log('✅ Favicon criado');
}
} catch (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
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedLogo) {
updateFavicon(cachedLogo);
}
// Listener para atualizações em tempo real
const handleUpdate = () => {
const cachedPrimary = localStorage.getItem('agency-primary-color');
const cachedSecondary = localStorage.getItem('agency-secondary-color');
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedPrimary && cachedSecondary) {
applyTheme(cachedPrimary, cachedSecondary);
}
if (cachedLogo) {
updateFavicon(cachedLogo);
}
};
window.addEventListener('branding-update', handleUpdate);
return () => {
window.removeEventListener('branding-update', handleUpdate);
};
}, [colors]);
// Componente não renderiza nada visualmente (apenas efeitos colaterais)
return null;
}

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState } from 'react';
import { usePathname } from 'next/navigation';
import { SidebarRail, MenuItem } from './SidebarRail';
import { TopBar } from './TopBar';
interface DashboardLayoutProps {
children: React.ReactNode;
menuItems: MenuItem[];
}
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menuItems }) => {
// Estado centralizado do layout
const [isExpanded, setIsExpanded] = useState(true);
const pathname = usePathname();
return (
<div className="flex h-screen w-full bg-gray-100 dark:bg-zinc-950 text-slate-900 dark:text-slate-100 overflow-hidden p-3 gap-3 transition-colors duration-300">
{/* Sidebar controla seu próprio estado visual via props */}
<SidebarRail
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
menuItems={menuItems}
/>
{/* Área de Conteúdo (Children) */}
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white dark:bg-zinc-900 rounded-2xl shadow-lg relative transition-colors duration-300 border border-transparent dark:border-zinc-800">
{/* TopBar com Breadcrumbs e Search */}
<TopBar />
{/* Conteúdo das páginas */}
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto w-full h-full">
{children}
</div>
</div>
</main>
</div>
);
};

View File

@@ -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;
}

View File

@@ -0,0 +1,435 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
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,
ChevronDownIcon,
UserCircleIcon,
ArrowRightOnRectangleIcon,
SunIcon,
MoonIcon,
Cog6ToothIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
export interface MenuItem {
id: string;
label: string;
href: string;
icon: any;
subItems?: {
label: string;
href: string;
}[];
}
interface SidebarRailProps {
isExpanded: boolean;
onToggle: () => void;
menuItems: MenuItem[];
}
export const SidebarRail: React.FC<SidebarRailProps> = ({
isExpanded,
onToggle,
menuItems,
}) => {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
const sidebarRef = useRef<HTMLDivElement>(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) {
// Usar localStorage como fallback se API não retornar logo
const cachedLogo = localStorage.getItem('agency-logo-url');
const finalLogoUrl = data.logo_url || cachedLogo;
const updatedUser = {
...currentUser,
company: data.name || currentUser.company,
logoUrl: finalLogoUrl
};
setUser(updatedUser);
saveAuth(token, updatedUser); // Persistir atualização
// Atualizar localStorage do logo (preservar se já existe)
if (finalLogoUrl) {
console.log('📝 Salvando logo no localStorage:', finalLogoUrl);
localStorage.setItem('agency-logo-url', finalLogoUrl);
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) {
const activeItem = menuItems.find(item =>
item.subItems?.some(sub => pathname === sub.href || pathname.startsWith(sub.href))
);
if (activeItem) {
setOpenSubmenu(activeItem.id);
}
}
}, [pathname, isExpanded, menuItems]);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
};
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
// 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 (
<div className={`flex h-full relative z-20 transition-all duration-300 ${openSubmenu ? 'shadow-xl' : 'shadow-lg'} rounded-2xl`} ref={sidebarRef}>
{/* Rail Principal (Ícones + Labels Opcionais) */}
<div
className={`
relative h-full bg-white dark:bg-zinc-900 flex flex-col py-4 gap-1 text-gray-600 dark:text-gray-400 shrink-0 z-30
transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] px-3 border border-gray-100 dark:border-zinc-800
${railWidth}
${openSubmenu ? 'rounded-l-2xl rounded-r-none border-r-0' : 'rounded-2xl'}
`}
>
{/* Toggle Button - Floating on the border */}
{/* Só mostra o toggle se não tiver submenu aberto, para evitar confusão */}
{!openSubmenu && (
<button
onClick={onToggle}
className="absolute -right-3 top-8 z-50 flex h-6 w-6 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 hover:text-gray-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200 transition-colors"
aria-label={isExpanded ? 'Recolher menu' : 'Expandir menu'}
>
{isExpanded ? (
<ChevronLeftIcon className="w-3 h-3" />
) : (
<ChevronRightIcon className="w-3 h-3" />
)}
</button>
)}
{/* Header com Logo */}
<div className={`flex items-center w-full mb-6 ${showLabels ? 'justify-start px-1' : 'justify-center'}`}>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-white font-bold shrink-0 shadow-md text-lg overflow-hidden bg-brand-500"
>
{user?.logoUrl ? (
<img src={user.logoUrl} alt={user.company || 'Logo'} className="w-full h-full object-cover" />
) : (
(user?.company?.[0] || 'A').toUpperCase()
)}
</div>
{/* Título com animação */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap ${showLabels ? 'opacity-100 max-w-[120px] ml-3' : 'opacity-0 max-w-0 ml-0'}`}>
<span className="font-heading font-bold text-lg text-gray-900 dark:text-white tracking-tight">
{user?.company || 'Aggios'}
</span>
</div>
</div>
{/* Navegação */}
<div className="flex flex-col gap-1 w-full flex-1 overflow-y-auto items-center">
{menuItems.map((item) => (
<RailButton
key={item.id}
label={item.label}
icon={item.icon}
href={item.href}
active={pathname === item.href || (item.href !== '/dashboard' && pathname?.startsWith(item.href))}
onClick={(e: any) => {
if (item.subItems) {
// 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 {
setOpenSubmenu(null);
}
}}
showLabel={showLabels}
hasSubItems={!!item.subItems}
isOpen={openSubmenu === item.id}
/>
))}
</div>
{/* Separador */}
<div className="h-px bg-gray-200 dark:bg-zinc-800 my-2 w-full" />
{/* User Menu */}
<div className={`flex ${showLabels ? 'justify-start' : 'justify-center'}`}>
{mounted && (
<Menu>
<MenuButton className={`w-full p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-all duration-300 flex items-center ${showLabels ? '' : 'justify-center'}`}>
<UserCircleIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 shrink-0" />
<div className={`overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out ${showLabels ? 'max-w-[150px] opacity-100 ml-2' : 'max-w-0 opacity-0 ml-0'}`}>
<span className="font-medium text-xs text-gray-900 dark:text-white">
{user?.name || 'Usuário'}
</span>
</div>
</MenuButton>
<MenuItems
anchor="top start"
transition
className={`w-48 origin-bottom-left rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 shadow-lg focus:outline-none overflow-hidden z-50 transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0`}
>
<div className="p-1">
<MenuItem>
<button
className="data-[focus]:bg-gray-100 dark:data-[focus]:bg-zinc-800 text-gray-700 dark:text-gray-300 group flex w-full items-center rounded-lg px-3 py-2 text-xs"
>
<UserCircleIcon className="mr-2 h-4 w-4" />
Ver meu perfil
</button>
</MenuItem>
<MenuItem>
<button
onClick={toggleTheme}
className="data-[focus]:bg-gray-100 dark:data-[focus]:bg-zinc-800 text-gray-700 dark:text-gray-300 group flex w-full items-center rounded-lg px-3 py-2 text-xs"
>
{theme === 'dark' ? (
<>
<SunIcon className="mr-2 h-4 w-4" />
Tema Claro
</>
) : (
<>
<MoonIcon className="mr-2 h-4 w-4" />
Tema Escuro
</>
)}
</button>
</MenuItem>
<div className="my-1 h-px bg-gray-200 dark:bg-zinc-800" />
<MenuItem>
<button
onClick={handleLogout}
className="data-[focus]:bg-red-50 dark:data-[focus]:bg-red-900/20 text-red-500 group flex w-full items-center rounded-lg px-3 py-2 text-xs"
>
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Sair
</button>
</MenuItem>
</div>
</MenuItems>
</Menu>
)}
{!mounted && (
<div className={`w-full p-2 rounded-lg flex items-center ${showLabels ? '' : 'justify-center'}`}>
<UserCircleIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 shrink-0" />
</div>
)}
</div>
</div>
{/* Painel Secundário (Drawer) - Abre ao lado do Rail */}
<div
className={`
h-full
bg-white dark:bg-zinc-900 rounded-r-2xl border-y border-r border-l border-gray-100 dark:border-zinc-800
transition-all duration-300 ease-in-out origin-left z-20 flex flex-col overflow-hidden
${openSubmenu ? 'w-64 opacity-100 translate-x-0' : 'w-0 opacity-0 -translate-x-10 border-none'}
`}
>
{activeMenuItem && (
<>
<div className="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 className="font-heading font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<activeMenuItem.icon className="w-5 h-5 text-brand-500" />
{activeMenuItem.label}
</h3>
<button
onClick={() => setOpenSubmenu(null)}
className="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-500 dark:text-gray-400 transition-colors"
aria-label="Fechar submenu"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-2 flex-1 overflow-y-auto">
{activeMenuItem.subItems?.map((sub) => (
<Link
key={sub.href}
href={sub.href}
// 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
? 'bg-brand-50 dark:bg-brand-900/10 text-brand-600 dark:text-brand-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-zinc-800 hover:text-gray-900 dark:hover:text-white'
}
`}
>
<span className={`w-1.5 h-1.5 rounded-full ${pathname === sub.href ? 'bg-brand-500' : 'bg-gray-300 dark:bg-zinc-600'}`} />
{sub.label}
</Link>
))}
</div>
</>
)}
</div>
</div>
);
};
// Subcomponente do Botão
interface RailButtonProps {
label: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
active: boolean;
onClick: (e?: any) => void;
showLabel: boolean;
hasSubItems?: boolean;
isOpen?: boolean;
}
const RailButton: React.FC<RailButtonProps> = ({ label, icon: Icon, href, active, onClick, showLabel, hasSubItems, isOpen }) => {
// Determine styling based on state
// 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' };
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";
}
return (
<Wrapper
{...props as any}
className={baseClasses}
title={!showLabel ? label : undefined} // Tooltip nativo apenas se recolhido
>
{/* Ícone */}
<Icon className={`shrink-0 w-5 h-5 ${isActiveItem ? 'text-white' : ''}`} />
{/* Texto (Visível apenas se expandido) */}
<div className={`
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'}
`}>
<span className="font-medium text-xs flex-1 text-left">{label}</span>
{hasSubItems && (
<ChevronRightIcon className={`w-3 h-3 transition-transform duration-200 ${isActiveItem ? 'text-white' : 'text-gray-400'}`} />
)}
</div>
{/* Indicador de Ativo (Ponto lateral) - Apenas se recolhido e NÃO tiver gradiente (redundante agora, mas mantido por segurança) */}
{active && !hasSubItems && !showLabel && !isActiveItem && (
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-4 bg-white rounded-r-full" />
)}
</Wrapper>
);
};

View File

@@ -0,0 +1,110 @@
'use client';
import React, { useState } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
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);
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}`;
// Mapeamento de nomes amigáveis
const nameMap: Record<string, string> = {
'dashboard': 'Dashboard',
'clientes': 'Clientes',
'projetos': 'Projetos',
'financeiro': 'Financeiro',
'configuracoes': 'Configurações',
'novo': 'Novo',
};
if (path !== 'dashboard') { // Evita duplicar Home/Dashboard se a rota for /dashboard
breadcrumbs.push({
name: nameMap[path] || path.charAt(0).toUpperCase() + path.slice(1),
href: currentPath,
});
}
});
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs();
return (
<>
<div className="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-6 py-3 flex items-center justify-between transition-colors">
{/* Breadcrumbs */}
<nav className="flex items-center gap-2 text-xs">
{breadcrumbs.map((crumb, index) => {
const Icon = crumb.icon;
const isLast = index === breadcrumbs.length - 1;
return (
<div key={crumb.href} className="flex items-center gap-2">
{Icon ? (
<Link
href={crumb.href}
className="flex items-center gap-1.5 text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200 transition-colors"
>
<Icon className="w-3.5 h-3.5" />
<span>{crumb.name}</span>
</Link>
) : (
<Link
href={crumb.href}
className={`${isLast ? 'text-gray-900 dark:text-white font-medium' : 'text-gray-500 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-200'} transition-colors`}
>
{crumb.name}
</Link>
)}
{!isLast && <ChevronRightIcon className="w-3 h-3 text-gray-400 dark:text-zinc-600" />}
</div>
);
})}
</nav>
{/* Search Bar Trigger */}
<div className="flex items-center gap-4">
<button
onClick={() => setIsCommandPaletteOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 dark:text-zinc-400 bg-gray-100 dark:bg-zinc-800 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
>
<MagnifyingGlassIcon className="w-4 h-4" />
<span className="hidden sm:inline">Buscar...</span>
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 bg-white dark:bg-zinc-900 rounded border border-gray-200 dark:border-zinc-700">
Ctrl K
</kbd>
</button>
<div className="flex items-center gap-2 border-l border-gray-200 dark:border-zinc-800 pl-4">
<button className="p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors relative">
<BellIcon className="w-5 h-5" />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white dark:border-zinc-900"></span>
</button>
<Link
href="/configuracoes"
className="p-2 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<Cog6ToothIcon className="w-5 h-5" />
</Link>
</div>
</div>
</div>
{/* Command Palette */}
<CommandPalette isOpen={isCommandPaletteOpen} setIsOpen={setIsCommandPaletteOpen} />
</>
);
};

View File

@@ -0,0 +1,89 @@
"use client";
import { ButtonHTMLAttributes, forwardRef } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
leftIcon?: string;
rightIcon?: string;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = "primary",
size = "md",
isLoading = false,
leftIcon,
rightIcon,
className = "",
disabled,
...props
},
ref
) => {
const baseStyles =
"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: "bg-brand-500 text-white hover:opacity-90 active:opacity-80 shadow-sm hover:shadow-md transition-all",
secondary:
"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-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-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
return (
<button
ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{!isLoading && leftIcon && (
<i className={`${leftIcon} mr-2 text-[20px]`} />
)}
{children}
{!isLoading && rightIcon && (
<i className={`${rightIcon} ml-2 text-[20px]`} />
)}
</button>
);
}
);
Button.displayName = "Button";
export default Button;

View File

@@ -0,0 +1,69 @@
"use client";
import { InputHTMLAttributes, forwardRef, useState } from "react";
interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string | React.ReactNode;
error?: string;
}
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ label, error, className = "", onChange, checked: controlledChecked, ...props }, ref) => {
const [isChecked, setIsChecked] = useState(controlledChecked || false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsChecked(e.target.checked);
if (onChange) {
onChange(e);
}
};
const checked = controlledChecked !== undefined ? controlledChecked : isChecked;
return (
<div className="w-full">
<label className="flex items-start gap-3 cursor-pointer group">
<div className="relative flex items-center justify-center mt-0.5">
<input
ref={ref}
type="checkbox"
className={`
appearance-none w-[18px] h-[18px] border rounded-sm
border-zinc-200 dark:border-gray-600 bg-white dark:bg-gray-700
checked:border-brand-500
focus:outline-none focus:border-brand-500
transition-colors cursor-pointer
${className}
`}
style={{
background: checked ? 'var(--gradient-primary)' : undefined,
}}
checked={checked}
onChange={handleChange}
{...props}
/>
<i
className={`ri-check-line absolute text-white text-[14px] pointer-events-none transition-opacity ${checked ? 'opacity-100' : 'opacity-0'
}`}
/>
</div>
{label && (
<span className="text-[14px] text-zinc-900 dark:text-white select-none">
{label}
</span>
)}
</label>
{error && (
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
<i className="ri-error-warning-line" />
{error}
</p>
)}
</div>
);
}
);
Checkbox.displayName = "Checkbox";
export default Checkbox;

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Combobox, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/navigation';
import {
HomeIcon,
RocketLaunchIcon,
ChartBarIcon,
BriefcaseIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
FolderIcon,
ShareIcon,
Cog6ToothIcon,
PlusIcon,
ArrowRightIcon
} from '@heroicons/react/24/outline';
interface CommandPaletteProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function CommandPalette({ isOpen, setIsOpen }: CommandPaletteProps) {
const [query, setQuery] = useState('');
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
// Atalho de teclado (Ctrl+K ou Cmd+K)
useEffect(() => {
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setIsOpen(true);
}
};
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('keydown', onKeydown);
};
}, [setIsOpen]);
const navigation = [
{ name: 'Visão Geral', href: '/dashboard', icon: HomeIcon, category: 'Navegação' },
{ name: 'CRM (Mission Control)', href: '/crm', icon: RocketLaunchIcon, category: 'Navegação' },
{ name: 'ERP', href: '/erp', icon: ChartBarIcon, category: 'Navegação' },
{ name: 'Projetos', href: '/projetos', icon: BriefcaseIcon, category: 'Navegação' },
{ name: 'Helpdesk', href: '/helpdesk', icon: LifebuoyIcon, category: 'Navegação' },
{ name: 'Pagamentos', href: '/pagamentos', icon: CreditCardIcon, category: 'Navegação' },
{ name: 'Contratos', href: '/contratos', icon: DocumentTextIcon, category: 'Navegação' },
{ name: 'Documentos', href: '/documentos', icon: FolderIcon, category: 'Navegação' },
{ name: 'Redes Sociais', href: '/social', icon: ShareIcon, category: 'Navegação' },
{ name: 'Configurações', href: '/configuracoes', icon: Cog6ToothIcon, category: 'Navegação' },
// Ações
{ name: 'Novo Projeto', href: '/projetos/novo', icon: PlusIcon, category: 'Ações' },
{ name: 'Novo Chamado', href: '/helpdesk/novo', icon: PlusIcon, category: 'Ações' },
{ name: 'Novo Contrato', href: '/contratos/novo', icon: PlusIcon, category: 'Ações' },
];
const filteredItems =
query === ''
? navigation
: navigation.filter((item) => {
return item.name.toLowerCase().includes(query.toLowerCase());
});
// Agrupar itens por categoria
const groups = filteredItems.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
}, {} as Record<string, typeof filteredItems>);
const handleSelect = (item: typeof navigation[0] | null) => {
if (!item) return;
setIsOpen(false);
router.push(item.href);
setQuery('');
};
return (
<Dialog open={isOpen} onClose={setIsOpen} className="relative z-50" initialFocus={inputRef}>
<DialogBackdrop
transition
className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity duration-300 data-[closed]:opacity-0"
/>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<DialogPanel
transition
className="mx-auto max-w-2xl transform overflow-hidden rounded-xl bg-white dark:bg-zinc-900 shadow-2xl transition-all duration-300 data-[closed]:opacity-0 data-[closed]:scale-95"
>
<Combobox onChange={handleSelect}>
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
<Combobox.Input
ref={inputRef}
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:ring-0 sm:text-sm font-medium"
placeholder="O que você procura?"
onChange={(event) => setQuery(event.target.value)}
displayValue={(item: any) => item?.name}
autoComplete="off"
/>
</div>
{filteredItems.length > 0 && (
<Combobox.Options static className="max-h-[60vh] scroll-py-2 overflow-y-auto py-2 text-sm text-zinc-800 dark:text-zinc-200">
{Object.entries(groups).map(([category, items]) => (
<div key={category}>
<div className="px-4 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-wider bg-zinc-50/50 dark:bg-zinc-800/50 mt-2 first:mt-0 mb-1">
{category}
</div>
{items.map((item) => (
<Combobox.Option
key={item.href}
value={item}
className={({ active }) =>
`cursor-pointer select-none px-4 py-2.5 transition-colors ${active
? '[background:var(--gradient)] text-white'
: ''
}`
}
>
{({ active }) => (
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${active
? 'bg-white/20 text-white'
: 'bg-zinc-50 dark:bg-zinc-900 text-zinc-400'
}`}>
<item.icon
className="h-4 w-4"
aria-hidden="true"
/>
</div>
<span className={`flex-auto truncate font-medium ${active ? 'text-white' : 'text-zinc-600 dark:text-zinc-400'}`}>
{item.name}
</span>
{active && (
<ArrowRightIcon className="h-4 w-4 text-white/70" />
)}
</div>
)}
</Combobox.Option>
))}
</div>
))}
</Combobox.Options>
)}
{query !== '' && filteredItems.length === 0 && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<MagnifyingGlassIcon className="mx-auto h-6 w-6 text-zinc-400" aria-hidden="true" />
<p className="mt-4 font-semibold text-zinc-900 dark:text-white">Nenhum resultado encontrado</p>
<p className="mt-2 text-zinc-500">Não conseguimos encontrar nada para &quot;{query}&quot;. Tente buscar por páginas ou ações.</p>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 bg-zinc-50 dark:bg-zinc-900/50">
<div className="flex gap-4 text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Selecionar
</span>
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Navegar
</span>
</div>
<div className="text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-auto px-1.5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800">Esc</kbd>
Fechar
</span>
</div>
</div>
</Combobox>
</DialogPanel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,95 @@
import { Fragment } from 'react';
import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
showClose?: boolean;
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
export default function Dialog({
isOpen,
onClose,
title,
children,
size = 'md',
showClose = true,
}: DialogProps) {
return (
<Transition appear show={isOpen} as={Fragment}>
<HeadlessDialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<HeadlessDialog.Panel
className={`w-full ${sizeClasses[size]} transform rounded-2xl bg-white dark:bg-gray-800 p-6 text-left align-middle shadow-xl transition-all border border-gray-200 dark:border-gray-700`}
>
{title && (
<div className="flex items-center justify-between mb-4">
<HeadlessDialog.Title
as="h3"
className="text-lg font-semibold text-gray-900 dark:text-white"
>
{title}
</HeadlessDialog.Title>
{showClose && (
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
)}
</div>
)}
{children}
</HeadlessDialog.Panel>
</Transition.Child>
</div>
</div>
</HeadlessDialog>
</Transition>
);
}
// Componente auxiliar para o corpo do dialog
Dialog.Body = function DialogBody({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <div className={`text-sm text-gray-600 dark:text-gray-300 ${className}`}>{children}</div>;
};
// Componente auxiliar para o rodapé do dialog
Dialog.Footer = function DialogFooter({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <div className={`mt-6 flex items-center justify-end space-x-3 ${className}`}>{children}</div>;
};

View File

@@ -0,0 +1,108 @@
"use client";
import { InputHTMLAttributes, forwardRef, useState, ReactNode } from "react";
import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
onRightIconClick?: () => void;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
error,
helperText,
leftIcon,
rightIcon,
onRightIconClick,
className = "",
type,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
return (
<div className="w-full">
{label && (
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
{label}
{props.required && <span className="text-brand-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] dark:text-gray-400 w-5 h-5">
{leftIcon}
</div>
)}
<input
ref={ref}
type={inputType}
className={`
w-full px-4 py-2.5 text-sm font-normal
border rounded-lg bg-white dark:bg-gray-800 dark:text-white
placeholder:text-gray-400 dark:placeholder:text-gray-500
transition-all duration-200
${leftIcon ? "pl-11" : ""}
${isPassword || rightIcon ? "pr-11" : ""}
${error
? "border-red-500 focus:border-red-500 focus:ring-4 focus:ring-red-500/10"
: "border-gray-200 dark:border-gray-700 focus:border-brand-500 focus:ring-4 focus:ring-brand-500/10"
}
outline-none
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
${className}
`}
{...props}
/>
{isPassword && (
<button
type="button"
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 ? (
<EyeSlashIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</button>
)}
{!isPassword && rightIcon && (
<button
type="button"
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"
>
<div className="w-5 h-5">{rightIcon}</div>
</button>
)}
</div>
{error && (
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
<ExclamationCircleIcon className="w-4 h-4" />
{error}
</p>
)}
{helperText && !error && (
<p className="mt-1 text-[13px] text-zinc-500">{helperText}</p>
)}
</div>
);
}
);
Input.displayName = "Input";
export default Input;

View File

@@ -0,0 +1,211 @@
"use client";
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect } from "react";
interface SelectOption {
value: string;
label: string;
}
interface SearchableSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: string;
options: SelectOption[];
placeholder?: string;
onChange?: (value: string) => void;
value?: string;
}
const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
(
{
label,
error,
helperText,
leftIcon,
options,
placeholder,
className = "",
onChange,
value,
required,
...props
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
options.find(opt => opt.value === value) || null
);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (value) {
const option = options.find(opt => opt.value === value);
if (option) {
setSelectedOption(option);
}
}
}, [value, options]);
const handleSelect = (option: SelectOption) => {
setSelectedOption(option);
setIsOpen(false);
setSearchTerm("");
if (onChange) {
onChange(option.value);
}
};
return (
<div className="w-full">
{/* Hidden select for form compatibility */}
<select
ref={ref}
value={selectedOption?.value || ""}
onChange={(e) => {
const option = options.find(opt => opt.value === e.target.value);
if (option) handleSelect(option);
}}
className="hidden"
required={required}
{...props}
>
<option value="" disabled>
{placeholder || "Selecione uma opção"}
</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{label && (
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
{label}
{required && <span className="text-brand-500 ml-1">*</span>}
</label>
)}
<div ref={containerRef} className="relative">
{leftIcon && (
<i
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none z-10`}
/>
)}
{/* Custom trigger */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`
w-full px-3.5 py-3 text-[14px] font-normal
border rounded-md bg-white dark:bg-zinc-800
text-zinc-900 dark:text-white text-left
transition-all
cursor-pointer
${leftIcon ? "pl-11" : ""}
pr-11
${error
? "border-red-500 focus:border-red-500"
: "border-zinc-200 dark:border-zinc-700 focus:border-brand-500"
}
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
${className}
`}
>
{selectedOption ? selectedOption.label : (
<span className="text-zinc-500 dark:text-zinc-400">{placeholder || "Selecione uma opção"}</span>
)}
</button>
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none transition-transform`} />
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg max-h-[300px] overflow-hidden">
{/* Search input */}
<div className="p-2 border-b border-zinc-200 dark:border-zinc-700">
<div className="relative">
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[16px]" />
<input
ref={searchInputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar..."
className="w-full pl-9 pr-3 py-2 text-[14px] border border-zinc-200 dark:border-zinc-700 rounded-md outline-none focus:border-brand-500 shadow-none bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400"
/>
</div>
</div>
{/* Options list */}
<div className="overflow-y-auto max-h-60">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option)}
className={`
w-full px-4 py-2.5 text-left text-[14px] transition-colors
hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer
${selectedOption?.value === option.value ? 'bg-brand-500/10 text-brand-600 font-medium' : 'text-zinc-900 dark:text-white'}
`}
>
{option.label}
</button>
))
) : (
<div className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400 text-[14px]">
Nenhum resultado encontrado
</div>
)}
</div>
</div>
)}
</div>
{helperText && !error && (
<p className="mt-1.5 text-[12px] text-zinc-600 dark:text-zinc-400">{helperText}</p>
)}
{error && (
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
<i className="ri-error-warning-line" />
{error}
</p>
)}
</div>
);
}
);
SearchableSelect.displayName = "SearchableSelect";
export default SearchableSelect;

View File

@@ -0,0 +1,89 @@
"use client";
import { SelectHTMLAttributes, forwardRef } from "react";
interface SelectOption {
value: string;
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: string;
options: SelectOption[];
placeholder?: string;
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{
label,
error,
helperText,
leftIcon,
options,
placeholder,
className = "",
...props
},
ref
) => {
return (
<div className="w-full">
{label && (
<label className="block text-[13px] font-semibold text-zinc-900 mb-2">
{label}
{props.required && <span className="text-brand-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{leftIcon && (
<i
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none z-10`}
/>
)}
<select
ref={ref}
className={`
w-full px-3.5 py-3 text-[14px] font-normal
border rounded-md bg-white
text-zinc-900
transition-all appearance-none
cursor-pointer
${leftIcon ? "pl-11" : ""}
pr-11
${error
? "border-red-500 focus:border-red-500"
: "border-zinc-200 focus:border-brand-500"
}
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
disabled:bg-zinc-100 disabled:cursor-not-allowed
${className}
`}
{...props}
>
<option value="" disabled>
{placeholder || "Selecione uma opção"}
</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<i className="ri-arrow-down-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none" />
</div>
{helperText && !error && (
<p className="mt-1.5 text-[12px] text-zinc-500">{helperText}</p>
)}
{error && <p className="mt-1.5 text-[12px] text-red-500">{error}</p>}
</div>
);
}
);
Select.displayName = "Select";
export default Select;

View File

@@ -0,0 +1,6 @@
export { default as Button } from "./Button";
export { default as Input } from "./Input";
export { default as Checkbox } from "./Checkbox";
export { default as Select } from "./Select";
export { default as SearchableSelect } from "./SearchableSelect";
export { default as Dialog } from "./Dialog";

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,59 @@
/**
* API Configuration - URLs e funções de requisição
*/
// 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
*/
export const API_ENDPOINTS = {
// Auth
register: `${API_BASE_URL}/api/auth/register`,
login: `${API_BASE_URL}/api/auth/login`,
logout: `${API_BASE_URL}/api/auth/logout`,
refresh: `${API_BASE_URL}/api/auth/refresh`,
me: `${API_BASE_URL}/api/me`,
// 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`,
apiHealth: `${API_BASE_URL}/api/health`,
} as const;
/**
* Wrapper para fetch com tratamento de erros
*/
export async function apiRequest<T = any>(
url: string,
options?: RequestInit
): Promise<T> {
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `Erro ${response.status}`);
}
return data;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Erro desconhecido na requisição');
}
}

View File

@@ -0,0 +1,80 @@
/**
* Auth utilities - Gerenciamento de autenticação no cliente
*/
export interface User {
id: string;
email: string;
name: string;
role: string;
tenantId?: string;
company?: string;
subdomain?: string;
logoUrl?: string;
}
const TOKEN_KEY = 'token';
const USER_KEY = 'user';
/**
* Salva token e dados do usuário no localStorage
*/
export function saveAuth(token: string, user: User): void {
if (typeof window === 'undefined') return;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/**
* Retorna o token JWT armazenado
*/
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
/**
* Retorna os dados do usuário armazenados
*/
export function getUser(): User | null {
if (typeof window === 'undefined') return null;
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
/**
* Verifica se o usuário está autenticado
*/
export function isAuthenticated(): boolean {
return !!getToken() && !!getUser();
}
/**
* Remove token e dados do usuário (logout)
*/
export function clearAuth(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
/**
* Retorna headers com Authorization para requisições autenticadas
*/
export function getAuthHeaders(): HeadersInit {
const token = getToken();
return {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,85 @@
/**
* 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<AgencyBrandingData | null> {
try {
// Pegar o hostname do request
const headersList = await headers();
const hostname = headersList.get('host') || '';
// Extrair subdomain (remover porta se houver)
const hostnameWithoutPort = hostname.split(':')[0];
const subdomain = hostnameWithoutPort.split('.')[0];
console.log(`[ServerAPI] Full hostname: ${hostname}, Without port: ${hostnameWithoutPort}, Subdomain: ${subdomain}`);
if (!subdomain || subdomain === 'localhost' || subdomain === 'www') {
console.log(`[ServerAPI] Invalid subdomain, skipping: ${subdomain}`);
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<string | null> {
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;
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const url = request.nextUrl;
const apiBase = process.env.API_INTERNAL_URL || 'http://backend:8080';
// Extrair subdomínio (remover porta se houver)
const hostnameWithoutPort = hostname.split(':')[0];
const subdomain = hostnameWithoutPort.split('.')[0];
// Rotas públicas que não precisam de validação de tenant
const publicPaths = ['/login', '/cadastro', '/'];
const isPublicPath = publicPaths.some(path => url.pathname === path || url.pathname.startsWith(path + '/'));
// Validar subdomínio de agência ({subdomain}.localhost) apenas se não for rota pública
if (hostname.includes('.') && !isPublicPath) {
try {
const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
}
});
if (!res.ok) {
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) {
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();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -0,0 +1,37 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: false, // Desabilitar StrictMode para evitar double render que causa removeChild
experimental: {
externalDir: true,
},
async rewrites() {
return {
beforeFiles: [
{
source: "/api/:path*",
destination: "http://backend:8080/api/:path*",
},
],
};
},
headers: async () => {
return [
{
source: "/api/:path*",
headers: [
{
key: "X-Forwarded-For",
value: "127.0.0.1",
},
{
key: "X-Forwarded-Host",
value: "${host}",
},
],
},
];
},
};
export default nextConfig;

Some files were not shown because too many files have changed in this diff Show More