feat(frontend): Telefonia > Ramais — CRUD, reset de senha, isolamento por tenant

Tela /app/telefonia/ramais completa: listagem com busca/ordenacao,
criacao com reveal de senha SIP (gerada pela API, mostrada uma unica vez),
detalhe com redefinir senha (novo POST /extensions/:id/reset-password —
unica forma de "editar" a senha, nunca reexpoe a existente) e remover
(soft delete), confirmacao inline de 2 cliques em vez de modal. Cada tela
deixa explicito de qual tenant os ramais sao, alem do isolamento por RLS
que ja existia.

Corrige de quebra um bug real que afeta qualquer acao futura sem payload:
apiFetch sempre mandava Content-Type: application/json mesmo em requests
sem body, e o parser do Fastify rejeita body vazio com esse header.

Testado ponta a ponta com um tenant real (Acme Call Center): criar ramal,
revelar senha, redefinir (senha nova confirmada diferente da original),
remover, lista voltando vazia. Dark mode e mobile conferidos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 10:17:06 -03:00
parent 565110eac3
commit 34ef408f00
21 changed files with 726 additions and 15 deletions

48
TODO.md
View File

@@ -766,15 +766,53 @@ app do Tenant + Dashboard (agente.md secao 162, 169)
batendo com `maxDailyCalls` do plano trial; drawer mobile abre e
fecha corretamente; dark mode conferido. Screenshots em
`apps/frontend/.impeccable/review/`.
- [ ] Menu Tenant inteiro (Discador/Call Center/Telefonia/Monitoramento/
Gravações/IA/Relatórios/Administração) ainda "em breve" — próxima
fase natural é Telefonia > Ramais (CRUD mais simples já testado
ponta a ponta desde a PHASE 08, mesmo padrão de tabela da Tarifas)
- [ ] Menu Tenant fora Dashboard/Telefonia > Ramais (Discador/Call
Center/Troncos/Dialplan/Monitoramento/Gravações/IA/Relatórios/
Administração) ainda "em breve"
- [ ] "Itens sem permissão não aparecem" (secao 169) não é aplicado
ainda — a IA do tenant aparece inteira pra qualquer usuário
autenticado do tenant, independente das permissions reais dele
## PHASE 25+ver `agente.md` seções 140 em diante (resto do Frontend,
## PHASE 25 — Frontend: Telefonia > Ramais (agente.md secao 39-40, 169,
178)
- [x] `POST /extensions/:id/reset-password` (novo endpoint) — só forma
de "editar" a senha SIP é gerar uma nova e mostrar ela UMA vez
(mesmo caminho da criação, secao 39: nunca reexpor a existente).
`b2bcall-fs-config` resolve o directory ao vivo por request, sem
arquivo/sync intermediário — um `UPDATE` já é suficiente, sem
precisar notificar nada.
- [x] Tela `/app/telefonia/ramais`: listagem com busca/ordenação (mesmo
padrão client-side da Tarifas), criação (número, nome, caller ID)
com reveal da senha SIP gerada (`SecretReveal`, novo componente:
aviso + valor monoespaçado + copiar), detalhe com "Redefinir
senha" (confirmação inline de 2 cliques, sem modal) e "Remover
ramal" (zona de risco, mesma confirmação inline, soft delete).
Cada tela deixa explícito de qual tenant os ramais são (nome do
tenant na descrição da lista e no cabeçalho do detalhe) — pedido
explícito do usuário, além do isolamento por RLS que já existia.
- [x] **Bug real, achado nesta fase**: `apiFetch` (frontend) sempre
mandava `Content-Type: application/json` mesmo em requests sem
body (`POST .../reset-password`, `DELETE`) — o parser de body do
Fastify rejeita body vazio com esse header ("Body cannot be empty
when content-type is set to 'application/json'"), então toda
ação sem payload quebrava com 400. Nunca tinha aparecido antes
porque Tarifas só fazia POST com body. Corrigido: o header só vai
quando `init.body` existe de verdade — provavelmente afeta outras
ações futuras sem payload, não só Ramais.
- [x] Testado ponta a ponta com o tenant real (Acme Call Center):
criar ramal 1500 → senha revelada uma vez → ver detalhe → redefinir
senha → nova senha diferente da original confirmada → voltar pra
lista (1 ramal, badge "Ativo") → remover → lista vazia de novo.
Dark mode e mobile conferidos.
- [ ] Editar outros campos (nome, caller ID, contexto, registros) ainda
não existe — nem no backend (só create/get/list/delete/reset-
password) nem no frontend; só a senha tem "edição" por enquanto,
conforme pedido
- [ ] Quota de ramais (`maxExtensions`) já é aplicada pelo backend desde
a PHASE 14, mas a UI não mostra consumo/limite antes de tentar
criar — o erro 403 apareceria só depois do submit
## PHASE 26+ — ver `agente.md` seções 140 em diante (resto do Frontend,
Security, Tests)
---

View File

@@ -126,6 +126,42 @@ export class ExtensionsController {
return toPublicExtension(extension);
}
/**
* Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha
* existente — a única forma de "editar" é gerar uma nova e mostrar
* ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve
* o directory ao vivo por request (sem arquivo/sync intermediário,
* diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente,
* o próximo REGISTER do ramal usa a senha nova automaticamente.
*/
@RequirePermission("extensions.manage")
@Post(":id/reset-password")
async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const plainPassword = generateStrongPassword();
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: { sipPasswordEnc: encryptSecret(plainPassword) },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_RESET_PASSWORD",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: plainPassword };
}
@RequirePermission("extensions.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { KeyRound, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SecretReveal } from "@/components/ui/secret-reveal";
import { resetExtensionPassword, deleteExtension } from "../actions";
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
function onClick() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await resetExtensionPassword(extensionId);
setConfirming(false);
if (!result.ok) {
setError(result.error);
return;
}
setNewPassword(result.sipPassword);
});
}
if (newPassword) {
return <SecretReveal label="Nova senha SIP" value={newPassword} />;
}
return (
<div className="space-y-3">
<Button type="button" variant="outline" onClick={onClick} disabled={pending}>
<KeyRound className="h-4 w-4" aria-hidden />
{pending ? "Gerando…" : confirming ? "Confirmar — gerar nova senha agora" : "Redefinir senha"}
</Button>
{confirming && !pending && (
<p className="text-xs text-muted-foreground">O ramal para de registrar com a senha antiga assim que confirmar.</p>
)}
{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>
);
}
export function DeleteExtensionAction({ extensionId, extensionNumber }: { extensionId: string; extensionNumber: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
function onClick() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await deleteExtension(extensionId);
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.push("/app/telefonia/ramais");
router.refresh();
});
}
return (
<div className="space-y-3">
<Button type="button" variant="destructive" onClick={onClick} disabled={pending}>
<Trash2 className="h-4 w-4" aria-hidden />
{pending ? "Removendo…" : confirming ? `Confirmar remoção do ramal ${extensionNumber}` : "Remover ramal"}
</Button>
{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>
);
}

View File

@@ -0,0 +1,101 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import { formatDate } from "@/lib/format";
import type { Extension } from "@/lib/extension-types";
import { ResetPasswordAction, DeleteExtensionAction } from "./actions-panel";
interface Me {
tenant: { name: string; code: string } | null;
}
export default async function ExtensionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireSession();
let extension: Extension;
try {
extension = await apiFetch<Extension>(`/extensions/${id}`, session.accessToken);
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound();
throw err;
}
const me = await apiFetch<Me>("/auth/me", session.accessToken);
return (
<div className="mx-auto max-w-2xl space-y-5">
<Link
href="/app/telefonia/ramais"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Ramais
</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-mono text-lg font-semibold text-foreground">{extension.number}</h1>
<span className="text-lg text-muted-foreground">{extension.name}</span>
<Pill tone={extension.enabled ? "accent" : "neutral"}>{extension.enabled ? "Ativo" : "Desabilitado"}</Pill>
</div>
<p className="-mt-3 text-sm text-muted-foreground">
Tenant <strong>{me.tenant?.name ?? extension.tenantId}</strong> · domínio SIP{" "}
<span className="font-mono">{extension.domain}</span>
</p>
<Panel>
<PanelHeader title="Configuração" />
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 p-5 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
<dd className="mt-1 text-sm text-foreground">
{extension.callerIdName || extension.callerIdNumber
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
: "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Registros simultâneos</dt>
<dd className="mt-1 font-mono text-sm tabular-nums text-foreground">{extension.maxRegistrations}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Criado em</dt>
<dd className="mt-1 text-sm text-foreground">{formatDate(extension.createdAt)}</dd>
</div>
</dl>
</Panel>
<Panel>
<PanelHeader
title="Senha SIP"
description="Cifrada em repouso, nunca reexibida — a única forma de trocá-la é gerar uma nova."
/>
<div className="p-5">
<ResetPasswordAction extensionId={extension.id} />
</div>
</Panel>
<Panel className="border-destructive/30">
<PanelHeader title="Zona de risco" description="Remover um ramal desliga o registro SIP e não pode ser desfeito por aqui." />
<div className="p-5">
<DeleteExtensionAction extensionId={extension.id} extensionNumber={extension.number} />
</div>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { Extension } from "@/lib/extension-types";
function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
try {
const parsed = JSON.parse(err.message);
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
if (typeof parsed.message === "string") return parsed.message;
} catch {
// corpo não era JSON — usa a mensagem crua
}
return err.message || "Falha inesperada na API.";
}
return "Falha inesperada. Tente novamente.";
}
export interface CreateExtensionInput {
number: string;
name: string;
callerIdName?: string;
callerIdNumber?: string;
context?: string;
maxRegistrations?: number;
}
export async function createExtension(
input: CreateExtensionInput,
): Promise<{ ok: true; extension: Extension; sipPassword: string } | { ok: false; error: string }> {
const session = await requireSession();
try {
const result = await apiFetch<Extension & { sipPassword: string }>("/extensions", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
const { sipPassword, ...extension } = result;
revalidatePath("/app/telefonia/ramais");
return { ok: true, extension, sipPassword };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function resetExtensionPassword(id: string): Promise<{ ok: true; sipPassword: string } | { ok: false; error: string }> {
const session = await requireSession();
try {
const { sipPassword } = await apiFetch<{ sipPassword: string }>(`/extensions/${id}/reset-password`, session.accessToken, {
method: "POST",
});
return { ok: true, sipPassword };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteExtension(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/extensions/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/telefonia/ramais");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Check, Phone } from "lucide-react";
import { Panel } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, FieldLabel } from "@/components/ui/input";
import { SecretReveal } from "@/components/ui/secret-reveal";
import { createExtension } from "../actions";
import type { Extension } from "@/lib/extension-types";
export function NewExtensionForm() {
const [number, setNumber] = useState("");
const [name, setName] = useState("");
const [callerIdName, setCallerIdName] = useState("");
const [callerIdNumber, setCallerIdNumber] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!/^[0-9]{2,10}$/.test(number)) {
setError("Ramal deve ter só dígitos (2 a 10).");
return;
}
if (!name.trim()) {
setError("Dê um nome ao ramal.");
return;
}
startTransition(async () => {
const result = await createExtension({
number,
name: name.trim(),
callerIdName: callerIdName.trim() || undefined,
callerIdNumber: callerIdNumber.trim() || undefined,
});
if (!result.ok) {
setError(result.error);
return;
}
setCreated(result);
});
}
if (created) {
return (
<div className="space-y-5">
<Panel className="flex items-center gap-3 p-5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-status-green/15 text-status-green">
<Check className="h-5 w-5" aria-hidden />
</span>
<div>
<p className="text-sm font-semibold text-foreground">
Ramal <span className="font-mono">{created.extension.number}</span> criado
</p>
<p className="text-sm text-muted-foreground">{created.extension.name}</p>
</div>
</Panel>
<SecretReveal label="Senha SIP" value={created.sipPassword} />
<div className="flex justify-end gap-3">
<Button asChild variant="outline">
<Link href="/app/telefonia/ramais">Voltar pra lista</Link>
</Button>
<Button asChild>
<Link href={`/app/telefonia/ramais/${created.extension.id}`}>Ver ramal</Link>
</Button>
</div>
</div>
);
}
return (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<Panel className="space-y-4 p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[140px_1fr]">
<div>
<FieldLabel htmlFor="ext-number">Ramal</FieldLabel>
<div className="relative">
<Phone className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
id="ext-number"
value={number}
onChange={(e) => setNumber(e.target.value.replace(/[^0-9]/g, ""))}
placeholder="1500"
className="pl-8 font-mono"
disabled={pending}
/>
</div>
</div>
<div>
<FieldLabel htmlFor="ext-name">Nome</FieldLabel>
<Input id="ext-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Recepção" disabled={pending} />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ext-cid-name">Caller ID nome (opcional)</FieldLabel>
<Input id="ext-cid-name" value={callerIdName} onChange={(e) => setCallerIdName(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-cid-number">Caller ID número (opcional)</FieldLabel>
<Input
id="ext-cid-number"
value={callerIdNumber}
onChange={(e) => setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="font-mono"
disabled={pending}
/>
</div>
</div>
</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 className="flex justify-end gap-3">
<Button type="submit" disabled={pending}>
{pending ? "Criando…" : "Criar ramal"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { NewExtensionForm } from "./form";
export default function NewExtensionPage() {
return (
<div className="mx-auto max-w-2xl space-y-5">
<Link
href="/app/telefonia/ramais"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Ramais
</Link>
<div>
<h1 className="text-lg font-semibold text-foreground">Novo ramal</h1>
<p className="mt-1 text-sm text-muted-foreground">
A senha SIP é gerada automaticamente e mostrada uma única vez depois de criar.
</p>
</div>
<NewExtensionForm />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { Extension } from "@/lib/extension-types";
import { RamaisView } from "./ramais-view";
interface Me {
tenant: { name: string; code: string } | null;
}
export default async function RamaisPage() {
const session = await requireSession();
const [extensions, me] = await Promise.all([
apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<Me>("/auth/me", session.accessToken),
]);
return <RamaisView extensions={extensions} tenantName={me.tenant?.name ?? ""} />;
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { ArrowUpDown, Phone, Plus, Search } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatDate } from "@/lib/format";
import type { Extension } from "@/lib/extension-types";
export function RamaisView({ extensions, tenantName }: { extensions: Extension[]; tenantName: string }) {
const [query, setQuery] = useState("");
const [sortDesc, setSortDesc] = useState(false);
const rows = useMemo(() => {
const q = query.toLowerCase();
const filtered = extensions.filter((ext) => ext.number.includes(q) || ext.name.toLowerCase().includes(q));
return filtered.sort((a, b) => (sortDesc ? b.number.localeCompare(a.number) : a.number.localeCompare(b.number)));
}, [extensions, query, sortDesc]);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Ramais</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Ramais SIP de <strong>{tenantName || "este tenant"}</strong> nunca visíveis nem alcançáveis por
nenhum outro tenant (isolamento por RLS, agente.md secao 30). A senha SIP nunca é reexibida depois de
criada; use <strong>Redefinir senha</strong> no ramal pra gerar uma nova.
</p>
</div>
<Button asChild>
<Link href="/app/telefonia/ramais/new">
<Plus className="h-4 w-4" aria-hidden />
Novo ramal
</Link>
</Button>
</div>
<Panel>
<PanelHeader title="Ramais cadastrados" description={`${extensions.length} ramal(is) neste tenant`} />
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="relative w-full max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por ramal ou nome…"
className="pl-8"
aria-label="Buscar ramal"
/>
</div>
<button
type="button"
onClick={() => setSortDesc((d) => !d)}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
{sortDesc ? "Maior ramal primeiro" : "Menor ramal primeiro"}
</button>
</div>
{rows.length === 0 ? (
<EmptyState
title={extensions.length > 0 ? "Nenhum ramal bate com essa busca" : "Nenhum ramal cadastrado ainda"}
description={extensions.length > 0 ? "Tente outro termo de busca." : "Crie o primeiro ramal SIP deste tenant."}
action={
extensions.length === 0 && (
<Button asChild>
<Link href="/app/telefonia/ramais/new">
<Plus className="h-4 w-4" aria-hidden /> Criar o primeiro ramal
</Link>
</Button>
)
}
/>
) : (
<Table>
<THead>
<TR>
<TH onClick={() => setSortDesc((d) => !d)} aria-sort={sortDesc ? "descending" : "ascending"}>
Ramal
</TH>
<TH>Nome</TH>
<TH>Caller ID</TH>
<TH>Registros</TH>
<TH>Status</TH>
<TH>Criado</TH>
</TR>
</THead>
<TBody>
{rows.map((ext) => (
<TR key={ext.id}>
<TD>
<Link
href={`/app/telefonia/ramais/${ext.id}`}
className="flex items-center gap-2 font-mono font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
>
<Phone className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{ext.number}
</Link>
</TD>
<TD>{ext.name}</TD>
<TD className="text-muted-foreground">
{ext.callerIdName || ext.callerIdNumber ? `${ext.callerIdName ?? ""} ${ext.callerIdNumber ? `<${ext.callerIdNumber}>` : ""}`.trim() : "—"}
</TD>
<TD className="font-mono tabular-nums">{ext.maxRegistrations}</TD>
<TD>
<Pill tone={ext.enabled ? "accent" : "neutral"}>{ext.enabled ? "Ativo" : "Desabilitado"}</Pill>
</TD>
<TD className="text-muted-foreground">{formatDate(ext.createdAt)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -11,13 +11,14 @@ import {
} from "lucide-react";
import type { NavSection } from "../shell/nav-types";
/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" tem página
* construída até agora (PHASE 23), o resto existe pra provar a
* arquitetura completa (Product Principle #5 em PRODUCT.md), renderizado
* como indisponível em vez de virar link morto. "Itens sem permissão não
* aparecem" (secao 169) ainda não é aplicado aqui — este primeiro corte
* mostra a IA inteira pra qualquer usuário tenant autenticado; filtrar por
* permission real fica pra quando mais telas existirem pra testar contra. */
/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23) e
* "Telefonia > Ramais" (PHASE 25) têm página construída até agora, o
* resto existe pra provar a arquitetura completa (Product Principle #5
* em PRODUCT.md), renderizado como indisponível em vez de virar link
* morto. "Itens sem permissão não aparecem" (secao 169) ainda não é
* aplicado aqui — este primeiro corte mostra a IA inteira pra qualquer
* usuário tenant autenticado; filtrar por permission real fica pra
* quando mais telas existirem pra testar contra. */
export const TENANT_NAV: NavSection[] = [
{
label: "Dashboard",
@@ -44,7 +45,15 @@ export const TENANT_NAV: NavSection[] = [
{
label: "Telefonia",
icon: Router,
children: [{ label: "Ramais" }, { label: "Troncos" }, { label: "Dialplan" }],
children: [
{
label: "Ramais",
href: "/app/telefonia/ramais",
description: "Ramais SIP deste tenant — números, senha, caller ID",
},
{ label: "Troncos" },
{ label: "Dialplan" },
],
},
{
label: "Monitoramento",

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Copy, Check, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
* desta tela). Sempre acompanhado do aviso — nunca só o valor sozinho,
* pra ninguém copiar sem entender que é a única chance.
*/
export function SecretReveal({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
async function onCopy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="rounded-lg border border-status-yellow/40 bg-status-yellow/10 p-4">
<div className="flex items-start gap-2 text-sm font-medium text-foreground">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-status-yellow" aria-hidden />
<span>{label} anote agora, ela não será mostrada de novo.</span>
</div>
<div className="mt-3 flex items-center gap-2">
<code className={cn("flex-1 select-all rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm text-foreground")}>
{value}
</code>
<button
type="button"
onClick={onCopy}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Copiar senha"
>
{copied ? <Check className="h-4 w-4 text-status-green" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}
</button>
</div>
</div>
);
}

View File

@@ -12,14 +12,21 @@ export class ApiError extends Error {
/** Chama a API real (NestJS/Fastify) do servidor (Server Component/Route
* Handler) — nunca do client, o access token nunca sai como JS legível
* (fica só no cookie httpOnly, ver session.ts). */
* (fica só no cookie httpOnly, ver session.ts).
*
* **Bug real, achado nesta fase**: `Content-Type: application/json`
* sempre presente, mesmo em requests sem body (ex.: `POST .../reset-
* password`, `DELETE`) — o parser de body do Fastify rejeita um body
* vazio quando o Content-Type diz JSON ("Body cannot be empty..."),
* então toda ação sem payload quebrava com 400. Corrigido: só manda o
* header quando `init.body` existe de verdade. */
export async function apiFetch<T>(path: string, accessToken: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
cache: "no-store",
});

View File

@@ -0,0 +1,16 @@
export interface Extension {
id: string;
tenantId: string;
number: string;
name: string;
domain: string;
callerIdName: string | null;
callerIdNumber: string | null;
context: string;
sofiaProfile: string;
codecs: string;
maxRegistrations: number;
enabled: boolean;
createdAt: string;
updatedAt: string;
}