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
48
TODO.md
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
apps/frontend/.impeccable/review/ramais-after-delete-desktop.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/frontend/.impeccable/review/ramais-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/frontend/.impeccable/review/ramais-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/frontend/.impeccable/review/ramais-with-data-mobile.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/frontend/.impeccable/review/ramal-created-desktop.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/frontend/.impeccable/review/ramal-detail-desktop.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
apps/frontend/.impeccable/review/ramal-new-dark-desktop.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
101
apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/frontend/src/app/app/telefonia/ramais/actions.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
134
apps/frontend/src/app/app/telefonia/ramais/new/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/frontend/src/app/app/telefonia/ramais/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/src/app/app/telefonia/ramais/page.tsx
Normal 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 ?? ""} />;
|
||||
}
|
||||
124
apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
43
apps/frontend/src/components/ui/secret-reveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
16
apps/frontend/src/lib/extension-types.ts
Normal 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;
|
||||
}
|
||||