feat(frontend): app do tenant, dashboard ao vivo, fluxo de login multi-tenant
Corrige um gap real: o login sempre mandava pra /platform mesmo pra usuarios sem role de plataforma, sem nunca chamar /auth/tenants ou select-tenant. Agora POST /api/post-login decide o destino server-side (plataforma / tenant unico / seletor com >1 tenant) antes de redirecionar, trocando o access token quando necessario sem nunca expor token ao client. Adiciona GET /reports/dashboard (secao 162) e a tela /app correspondente — chamadas/agentes/TME/TMA/rates ao vivo, "consumo do plano" e "valor estimado" seguindo a mesma disciplina de honestidade (null > numero inventado) do dashboard de plataforma. Shell (sidebar/topbar/drawer mobile) extraido pra components/shell, compartilhado entre os menus Platform e Tenant (secao 168-169) via wrappers client-only por area — corrige de quebra um erro real de serializacao RSC (passar NavSection[] com icones como prop de Server Component pra Client Component quebra: "Functions cannot be passed directly to Client Components"). Testado ponta a ponta com um tenant semeado (Acme Call Center): login → /app direto, platform admin barrado de /app e vice-versa, dashboard com numeros reais (zeros honestos), drawer mobile, dark mode. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
52
apps/frontend/src/app/api/post-login/route.ts
Normal file
52
apps/frontend/src/app/api/post-login/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { getSession, sessionCookieName } from "@/lib/session";
|
||||
|
||||
interface Me {
|
||||
isPlatformUser: boolean;
|
||||
}
|
||||
|
||||
interface TenantMembership {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide pra onde mandar o usuário logo após `/api/login` (agente.md
|
||||
* secao 31/168-169: platform admin nunca cai no app do tenant, e
|
||||
* vice-versa). Roda server-side porque precisa ler o cookie de sessão
|
||||
* recém-gravado e, no caso de 1 tenant só, trocar o access token por um
|
||||
* já com `tenantId` nas claims (`POST /auth/select-tenant`) — o client
|
||||
* nunca vê nenhum dos dois tokens.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ redirectTo: "/login" }, { status: 401 });
|
||||
|
||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||
if (me.isPlatformUser) {
|
||||
return NextResponse.json({ redirectTo: "/platform" });
|
||||
}
|
||||
|
||||
const tenants = await apiFetch<TenantMembership[]>("/auth/tenants", session.accessToken);
|
||||
if (tenants.length === 0) {
|
||||
return NextResponse.json({ redirectTo: "/login", message: "Este usuário não tem acesso a nenhum tenant." }, { status: 403 });
|
||||
}
|
||||
if (tenants.length > 1) {
|
||||
return NextResponse.json({ redirectTo: "/select-tenant" });
|
||||
}
|
||||
|
||||
const { accessToken } = await apiFetch<{ accessToken: string }>("/auth/select-tenant", session.accessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tenantId: tenants[0].tenantId }),
|
||||
});
|
||||
|
||||
const response = NextResponse.json({ redirectTo: "/app" });
|
||||
response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken, refreshToken: session.refreshToken }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
33
apps/frontend/src/app/app/layout.tsx
Normal file
33
apps/frontend/src/app/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TenantSidebar } from "@/components/tenant-shell/tenant-sidebar";
|
||||
import { TenantTopbar } from "@/components/tenant-shell/tenant-topbar";
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isPlatformUser: boolean;
|
||||
tenant: { id: string; code: string; name: string } | null;
|
||||
}
|
||||
|
||||
export default async function TenantAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||
|
||||
// Um usuário sem tenant ativo no token (platform admin, ou alguém com
|
||||
// mais de uma membership que ainda não escolheu) nunca deveria cair
|
||||
// direto aqui — /select-tenant resolve os dois casos antes de chegar.
|
||||
if (!me.tenant) redirect(me.isPlatformUser ? "/platform" : "/select-tenant");
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh overflow-hidden bg-background">
|
||||
<TenantSidebar railLabel={me.tenant.name} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TenantTopbar fallbackTitle={me.tenant.name} userLabel={me.email} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/frontend/src/app/app/page.tsx
Normal file
79
apps/frontend/src/app/app/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { InstrumentTile } from "@/components/ui/instrument-tile";
|
||||
import { formatInt, formatDuration, formatPercent, formatCurrency } from "@/lib/format";
|
||||
|
||||
interface TenantDashboard {
|
||||
callsToday: number;
|
||||
callsAnsweredToday: number;
|
||||
callsInProgress: number;
|
||||
callsWaitingForAgent: number;
|
||||
agentsAvailable: number;
|
||||
agentsBusy: number;
|
||||
agentsPaused: number;
|
||||
tmeSeconds: number | null;
|
||||
tmaSeconds: number | null;
|
||||
answerRate: number | null;
|
||||
abandonRate: number | null;
|
||||
dailyCallQuota: { used: number; max: number | null };
|
||||
monthlyConsumption: { amount: number; currency: string } | null;
|
||||
}
|
||||
|
||||
export default async function TenantDashboardPage() {
|
||||
const session = await requireSession();
|
||||
const dashboard = await apiFetch<TenantDashboard>("/reports/dashboard", session.accessToken);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Estado ao vivo — chamadas e agentes primeiro (agente.md secao 162) */}
|
||||
<section aria-label="Estado ao vivo" className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
<InstrumentTile label="Chamadas hoje" value={formatInt(dashboard.callsToday)} />
|
||||
<InstrumentTile label="Atendidas" value={formatInt(dashboard.callsAnsweredToday)} />
|
||||
<InstrumentTile label="Em andamento" value={formatInt(dashboard.callsInProgress)} live={dashboard.callsInProgress > 0} />
|
||||
<InstrumentTile label="Esperando agente" value={formatInt(dashboard.callsWaitingForAgent)} live={dashboard.callsWaitingForAgent > 0} />
|
||||
<InstrumentTile label="Agentes disponíveis" value={formatInt(dashboard.agentsAvailable)} />
|
||||
<InstrumentTile label="Agentes ocupados" value={formatInt(dashboard.agentsBusy)} />
|
||||
<InstrumentTile label="Agentes pausados" value={formatInt(dashboard.agentsPaused)} />
|
||||
</section>
|
||||
|
||||
{/* Qualidade de atendimento hoje */}
|
||||
<section aria-label="Qualidade de atendimento" className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<InstrumentTile
|
||||
label="TME"
|
||||
value={dashboard.tmeSeconds !== null ? formatDuration(dashboard.tmeSeconds) : null}
|
||||
pending="Nenhuma chamada atendida hoje ainda."
|
||||
/>
|
||||
<InstrumentTile
|
||||
label="TMA"
|
||||
value={dashboard.tmaSeconds !== null ? formatDuration(dashboard.tmaSeconds) : null}
|
||||
pending="Nenhuma chamada atendida hoje ainda."
|
||||
/>
|
||||
<InstrumentTile
|
||||
label="Answer rate"
|
||||
value={dashboard.answerRate !== null ? formatPercent(dashboard.answerRate) : null}
|
||||
pending="Nenhuma chamada hoje ainda."
|
||||
/>
|
||||
<InstrumentTile
|
||||
label="Abandon rate"
|
||||
value={dashboard.abandonRate !== null ? formatPercent(dashboard.abandonRate) : null}
|
||||
pending="Nenhuma chamada hoje ainda."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Consumo do plano / valor estimado (agente.md secao 162) */}
|
||||
<section aria-label="Consumo do plano" className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InstrumentTile
|
||||
label="Consumo do plano"
|
||||
value={formatInt(dashboard.dailyCallQuota.used)}
|
||||
unit="chamadas"
|
||||
suffix={dashboard.dailyCallQuota.max !== null ? `de ${formatInt(dashboard.dailyCallQuota.max)} hoje` : "sem limite diário"}
|
||||
/>
|
||||
<InstrumentTile
|
||||
label="Valor estimado no mês"
|
||||
value={dashboard.monthlyConsumption ? formatCurrency(dashboard.monthlyConsumption.amount, dashboard.monthlyConsumption.currency) : null}
|
||||
pending="Nenhum período de billing foi fechado ainda neste mês — sem número real pra mostrar (nunca um valor inventado)."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,16 @@ export default function LoginPage() {
|
||||
setError(body.message ?? "Não foi possível entrar.");
|
||||
return;
|
||||
}
|
||||
router.push("/platform");
|
||||
|
||||
// Decide platform vs tenant (e qual tenant) server-side — nunca no
|
||||
// client, o token de sessão não é legível por JS (ver post-login/route.ts).
|
||||
const postLogin = await fetch("/api/post-login", { method: "POST" });
|
||||
const postLoginBody = await postLogin.json().catch(() => ({ redirectTo: "/login" }));
|
||||
if (!postLogin.ok) {
|
||||
setError(postLoginBody.message ?? "Não foi possível entrar.");
|
||||
return;
|
||||
}
|
||||
router.push(postLoginBody.redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Não foi possível entrar. Verifique sua conexão.");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { PlatformSidebar } from "@/components/platform-shell/sidebar";
|
||||
import { PlatformTopbar } from "@/components/platform-shell/topbar";
|
||||
import { PlatformSidebar } from "@/components/platform-shell/platform-sidebar";
|
||||
import { PlatformTopbar } from "@/components/platform-shell/platform-topbar";
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
@@ -13,12 +14,13 @@ interface Me {
|
||||
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||
if (!me.isPlatformUser) redirect("/app");
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh overflow-hidden bg-background">
|
||||
<PlatformSidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PlatformTopbar userEmail={me.email} />
|
||||
<PlatformTopbar userLabel={me.email} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
apps/frontend/src/app/select-tenant/actions.ts
Normal file
33
apps/frontend/src/app/select-tenant/actions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { sessionCookieName } from "@/lib/session";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function chooseTenant(tenantId: string): Promise<{ error: string } | void> {
|
||||
const session = await requireSession();
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
const result = await apiFetch<{ accessToken: string }>("/auth/select-tenant", session.accessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tenantId }),
|
||||
});
|
||||
accessToken = result.accessToken;
|
||||
} catch {
|
||||
return { error: "Não foi possível entrar nesse tenant. Tente novamente." };
|
||||
}
|
||||
|
||||
const store = await cookies();
|
||||
store.set(sessionCookieName(), JSON.stringify({ accessToken, refreshToken: session.refreshToken }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
|
||||
redirect("/app");
|
||||
}
|
||||
29
apps/frontend/src/app/select-tenant/page.tsx
Normal file
29
apps/frontend/src/app/select-tenant/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Image from "next/image";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TenantPicker } from "./picker";
|
||||
|
||||
interface TenantOption {
|
||||
tenantId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default async function SelectTenantPage() {
|
||||
const session = await requireSession();
|
||||
const tenants = await apiFetch<TenantOption[]>("/auth/tenants", session.accessToken);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<Image src="/branding/b2blogo.png" alt="B2BCall" width={140} height={34} priority className="mx-auto mb-8" />
|
||||
<h1 className="text-center text-lg font-semibold text-foreground">Qual conta você quer acessar?</h1>
|
||||
<p className="mt-1 text-center text-sm text-muted-foreground">Sua conta tem acesso a mais de um tenant.</p>
|
||||
<div className="mt-6">
|
||||
<TenantPicker tenants={tenants} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/frontend/src/app/select-tenant/picker.tsx
Normal file
65
apps/frontend/src/app/select-tenant/picker.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Building2, ChevronRight } from "lucide-react";
|
||||
import { Panel } from "@/components/ui/panel";
|
||||
import { chooseTenant } from "./actions";
|
||||
|
||||
interface TenantOption {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function TenantPicker({ tenants }: { tenants: TenantOption[] }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function onSelect(tenantId: string) {
|
||||
setError(null);
|
||||
setPendingId(tenantId);
|
||||
startTransition(async () => {
|
||||
const result = await chooseTenant(tenantId);
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
setPendingId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Panel>
|
||||
<ul className="divide-y divide-border">
|
||||
{tenants.map((tenant) => (
|
||||
<li key={tenant.tenantId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(tenant.tenantId)}
|
||||
disabled={pendingId !== null}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Building2 className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{tenant.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{tenant.code}</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user