diff --git a/TODO.md b/TODO.md index 210e1b7..0f1424d 100644 --- a/TODO.md +++ b/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) --- diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index 7abbe64..45e8c0e 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -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) diff --git a/apps/frontend/.impeccable/review/ramais-after-delete-desktop.png b/apps/frontend/.impeccable/review/ramais-after-delete-desktop.png new file mode 100644 index 0000000..4341380 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramais-after-delete-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramais-empty-desktop.png b/apps/frontend/.impeccable/review/ramais-empty-desktop.png new file mode 100644 index 0000000..c595898 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramais-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramais-with-data-desktop.png b/apps/frontend/.impeccable/review/ramais-with-data-desktop.png new file mode 100644 index 0000000..fafcd3d Binary files /dev/null and b/apps/frontend/.impeccable/review/ramais-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramais-with-data-mobile.png b/apps/frontend/.impeccable/review/ramais-with-data-mobile.png new file mode 100644 index 0000000..2e39701 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramais-with-data-mobile.png differ diff --git a/apps/frontend/.impeccable/review/ramal-created-desktop.png b/apps/frontend/.impeccable/review/ramal-created-desktop.png new file mode 100644 index 0000000..0f95d39 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramal-created-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramal-detail-desktop.png b/apps/frontend/.impeccable/review/ramal-detail-desktop.png new file mode 100644 index 0000000..0f37173 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramal-detail-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramal-new-dark-desktop.png b/apps/frontend/.impeccable/review/ramal-new-dark-desktop.png new file mode 100644 index 0000000..73462d8 Binary files /dev/null and b/apps/frontend/.impeccable/review/ramal-new-dark-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ramal-reset-password-desktop.png b/apps/frontend/.impeccable/review/ramal-reset-password-desktop.png new file mode 100644 index 0000000..4f68d9e Binary files /dev/null and b/apps/frontend/.impeccable/review/ramal-reset-password-desktop.png differ diff --git a/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx b/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx new file mode 100644 index 0000000..f32b085 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx @@ -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(null); + const [newPassword, setNewPassword] = useState(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 ; + } + + return ( +
+ + {confirming && !pending && ( +

O ramal para de registrar com a senha antiga assim que confirmar.

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export function DeleteExtensionAction({ extensionId, extensionNumber }: { extensionId: string; extensionNumber: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(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 ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx b/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx new file mode 100644 index 0000000..539a356 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx @@ -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(`/extensions/${id}`, session.accessToken); + } catch (err) { + if (err instanceof ApiError && err.status === 404) notFound(); + throw err; + } + const me = await apiFetch("/auth/me", session.accessToken); + + return ( +
+ + + Ramais + + +
+

{extension.number}

+ {extension.name} + {extension.enabled ? "Ativo" : "Desabilitado"} +
+

+ Tenant {me.tenant?.name ?? extension.tenantId} · domínio SIP{" "} + {extension.domain} +

+ + + +
+
+
Caller ID
+
+ {extension.callerIdName || extension.callerIdNumber + ? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim() + : "—"} +
+
+
+
Contexto
+
{extension.context}
+
+
+
Perfil Sofia
+
{extension.sofiaProfile}
+
+
+
Codecs
+
{extension.codecs}
+
+
+
Registros simultâneos
+
{extension.maxRegistrations}
+
+
+
Criado em
+
{formatDate(extension.createdAt)}
+
+
+
+ + + +
+ +
+
+ + + +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/actions.ts b/apps/frontend/src/app/app/telefonia/ramais/actions.ts new file mode 100644 index 0000000..a2ccdd2 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/actions.ts @@ -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("/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(`/extensions/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/telefonia/ramais"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx b/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx new file mode 100644 index 0000000..908d3ac --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx @@ -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(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 ( +
+ + + + +
+

+ Ramal {created.extension.number} criado +

+

{created.extension.name}

+
+
+ + + +
+ + +
+
+ ); + } + + return ( +
+ +
+
+ Ramal +
+ + setNumber(e.target.value.replace(/[^0-9]/g, ""))} + placeholder="1500" + className="pl-8 font-mono" + disabled={pending} + /> +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Recepção" disabled={pending} /> +
+
+ +
+
+ Caller ID — nome (opcional) + setCallerIdName(e.target.value)} disabled={pending} /> +
+
+ Caller ID — número (opcional) + setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))} + className="font-mono" + disabled={pending} + /> +
+
+
+ + {error && ( +

+ {error} +

+ )} + +
+ +
+
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/new/page.tsx b/apps/frontend/src/app/app/telefonia/ramais/new/page.tsx new file mode 100644 index 0000000..0082349 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/new/page.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { ChevronLeft } from "lucide-react"; +import { NewExtensionForm } from "./form"; + +export default function NewExtensionPage() { + return ( +
+ + + Ramais + +
+

Novo ramal

+

+ A senha SIP é gerada automaticamente e mostrada uma única vez depois de criar. +

+
+ +
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/page.tsx b/apps/frontend/src/app/app/telefonia/ramais/page.tsx new file mode 100644 index 0000000..a471787 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/page.tsx @@ -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("/extensions", session.accessToken), + apiFetch("/auth/me", session.accessToken), + ]); + + return ; +} diff --git a/apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx b/apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx new file mode 100644 index 0000000..e25b2a5 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx @@ -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 ( +
+
+
+

Ramais

+

+ Ramais SIP de {tenantName || "este tenant"} — 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 Redefinir senha no ramal pra gerar uma nova. +

+
+ +
+ + + + +
+
+ + setQuery(e.target.value)} + placeholder="Buscar por ramal ou nome…" + className="pl-8" + aria-label="Buscar ramal" + /> +
+ +
+ + {rows.length === 0 ? ( + 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 && ( + + ) + } + /> + ) : ( + + + + + + + + + + + + + {rows.map((ext) => ( + + + + + + + + + ))} + +
setSortDesc((d) => !d)} aria-sort={sortDesc ? "descending" : "ascending"}> + Ramal + NomeCaller IDRegistrosStatusCriado
+ + + {ext.number} + + {ext.name} + {ext.callerIdName || ext.callerIdNumber ? `${ext.callerIdName ?? ""} ${ext.callerIdNumber ? `<${ext.callerIdNumber}>` : ""}`.trim() : "—"} + {ext.maxRegistrations} + {ext.enabled ? "Ativo" : "Desabilitado"} + {formatDate(ext.createdAt)}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 658db24..01dc90f 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -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", diff --git a/apps/frontend/src/components/ui/secret-reveal.tsx b/apps/frontend/src/components/ui/secret-reveal.tsx new file mode 100644 index 0000000..46fd316 --- /dev/null +++ b/apps/frontend/src/components/ui/secret-reveal.tsx @@ -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 ( +
+
+ + {label} — anote agora, ela não será mostrada de novo. +
+
+ + {value} + + +
+
+ ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index f16075c..ddd44a8 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -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(path: string, accessToken: string, init?: RequestInit): Promise { 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", }); diff --git a/apps/frontend/src/lib/extension-types.ts b/apps/frontend/src/lib/extension-types.ts new file mode 100644 index 0000000..c61e8c9 --- /dev/null +++ b/apps/frontend/src/lib/extension-types.ts @@ -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; +}