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
This commit is contained in:
Erik Silva
2025-12-13 15:05:51 -03:00
parent 04c954c3d9
commit 2f1cf2bb2a
42 changed files with 2215 additions and 872 deletions

View File

@@ -2,8 +2,9 @@
* API Configuration - URLs e funções de requisição
*/
// URL base da API - pode ser alterada por variável de ambiente
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.localhost';
// URL base da API - usa path relativo para passar pelo middleware do Next.js
// que adiciona os headers de tenant (X-Tenant-Subdomain)
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
/**
* Endpoints da API
@@ -18,6 +19,8 @@ export const API_ENDPOINTS = {
// Admin / Agencies
adminAgencyRegister: `${API_BASE_URL}/api/admin/agencies/register`,
agencyProfile: `${API_BASE_URL}/api/agency/profile`,
tenantConfig: `${API_BASE_URL}/api/tenant/config`,
// Health
health: `${API_BASE_URL}/health`,

View File

@@ -10,6 +10,7 @@ export interface User {
tenantId?: string;
company?: string;
subdomain?: string;
logoUrl?: string;
}
const TOKEN_KEY = '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,79 @@
/**
* Server-side API functions
* Estas funções são executadas APENAS no servidor (não no cliente)
*/
import { cookies, headers } from 'next/headers';
const API_BASE_URL = process.env.API_INTERNAL_URL || 'http://backend:8080';
interface AgencyBrandingData {
logo_url?: string;
primary_color?: string;
secondary_color?: string;
name?: string;
}
/**
* Busca os dados de branding da agência no servidor
* Usa o subdomínio do request para identificar a agência
*/
export async function getAgencyBranding(): Promise<AgencyBrandingData | null> {
try {
// Pegar o hostname do request
const headersList = await headers();
const hostname = headersList.get('host') || '';
const subdomain = hostname.split('.')[0];
if (!subdomain || subdomain === 'localhost' || subdomain === 'www') {
return null;
}
// Buscar dados da agência pela API
const url = `${API_BASE_URL}/api/tenant/config?subdomain=${subdomain}`;
console.log(`[ServerAPI] Fetching agency config from: ${url}`);
const response = await fetch(url, {
cache: 'no-store', // Sempre buscar dados atualizados
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.error(`[ServerAPI] Failed to fetch agency branding for ${subdomain}: ${response.status}`);
return null;
}
const data = await response.json();
console.log(`[ServerAPI] Agency branding data for ${subdomain}:`, JSON.stringify(data));
return data as AgencyBrandingData;
} catch (error) {
console.error('[ServerAPI] Error fetching agency branding:', error);
return null;
}
}
/**
* Busca apenas o logo da agência (para metadata)
*/
export async function getAgencyLogo(): Promise<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;
}