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:
@@ -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`,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
tenantId?: string;
|
||||
company?: string;
|
||||
subdomain?: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
183
front-end-agency/lib/colors.ts
Normal file
183
front-end-agency/lib/colors.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
front-end-agency/lib/server-api.ts
Normal file
79
front-end-agency/lib/server-api.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user