fix: domínio SIP único por tenant + grupo de captura + revelar senha do ramal
Achado real, detalhado pelo usuário testando o PABX de verdade: TenantsController
.create() gravava telephonyDomain="b2bcall.local" fixo pra TODO tenant novo —
b2bcall-fs-config decide qual tenant é dono de um REGISTER só pelo domínio
(Tenant.findFirst({telephonyDomain})), então com todo tenant no mesmo domínio o
isolamento de PABX (ramais, call groups, filas, IVR) não tinha como funcionar de
verdade. Investigado direto no container antes de mudar qualquer coisa: o sofia
profile "internal" (vanilla) já vem com <domain name="all" alias="true".../> — o
FreeSWITCH sempre aceitou domínio dinâmico por REGISTER, o bug era só a aplicação.
Nenhuma mudança de infra foi necessária.
Tenant.telephonyDomain agora é obrigatório e @unique (migration com backfill:
tenants existentes ganharam {code}.b2bcall.net, e os Extension.domain já criados
foram atualizados junto). Tela de criação de tenant sugere {code}.b2bcall.net ao
digitar o código, editável.
Extension.callGroup (novo) — ramais no mesmo grupo podem capturar a chamada um do
outro (*8), fora do grupo não. Vira a variable call-group no directory XML; a regra
de dialplan do *8 em si fica pra configurar em Telefonia > Dialplan (editor já
existe). Editável na criação e depois (PATCH /extensions/:id, novo).
POST /extensions/:id/reveal-password (novo) — achado real: "show once" puro não
funciona no dia a dia (reconfigurar um telefone/softphone precisa da senha de novo;
forçar reset toda vez derruba outro aparelho já configurado). sipPasswordEnc sempre
foi criptografia reversível, nunca hash — só não estava exposto. Auditado
(EXTENSION_PASSWORD_REVEALED) por ser sensível mesmo sem escrita.
apps/freeswitch-config reconstruído e reiniciado. Testado ponta a ponta contra o
container REAL via docker exec: senha revelada bate com a gerada na criação,
call-group aparece no XML, e o mesmo número de ramal em domínios diferentes nunca
se confunde (isolamento cross-tenant confirmado de verdade).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -2,10 +2,96 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, Trash2 } from "lucide-react";
|
||||
import { Eye, KeyRound, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { resetExtensionPassword, deleteExtension } from "../actions";
|
||||
import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions";
|
||||
|
||||
/** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a
|
||||
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar
|
||||
* outro aparelho já usando a mesma credencial (achado real reportado pelo
|
||||
* usuário: "show once" puro não funciona no dia a dia de um PABX). */
|
||||
export function RevealPasswordAction({ extensionId }: { extensionId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
function onClick() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await revealExtensionPassword(extensionId);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setPassword(result.sipPassword);
|
||||
});
|
||||
}
|
||||
|
||||
if (password) {
|
||||
return <SecretReveal label="Senha SIP atual" value={password} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button type="button" variant="outline" onClick={onClick} disabled={pending}>
|
||||
<Eye className="h-4 w-4" aria-hidden />
|
||||
{pending ? "Buscando…" : "Ver senha atual"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallGroupEditAction({ extensionId, callGroup }: { extensionId: string; callGroup: string | null }) {
|
||||
const router = useRouter();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(callGroup ?? "");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function onSave() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateExtension(extensionId, { callGroup: value.trim() || null });
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm text-foreground">{callGroup ?? "— (nenhum)"}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Ex.: recepcao" className="h-8 w-48 font-mono" disabled={pending} />
|
||||
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
||||
{pending ? "…" : "Salvar"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -7,7 +7,7 @@ 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";
|
||||
import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel";
|
||||
|
||||
interface Me {
|
||||
tenant: { name: string; code: string } | null;
|
||||
@@ -73,6 +73,12 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
||||
<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">Grupo de captura</dt>
|
||||
<dd className="mt-1 text-sm text-foreground">
|
||||
<CallGroupEditAction extensionId={extension.id} callGroup={extension.callGroup} />
|
||||
</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>
|
||||
@@ -83,9 +89,10 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
title="Senha SIP"
|
||||
description="Cifrada em repouso, nunca reexibida — a única forma de trocá-la é gerar uma nova."
|
||||
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova."
|
||||
/>
|
||||
<div className="p-5">
|
||||
<div className="space-y-4 p-5">
|
||||
<RevealPasswordAction extensionId={extension.id} />
|
||||
<ResetPasswordAction extensionId={extension.id} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CreateExtensionInput {
|
||||
callerIdNumber?: string;
|
||||
context?: string;
|
||||
maxRegistrations?: number;
|
||||
callGroup?: string;
|
||||
}
|
||||
|
||||
export async function createExtension(
|
||||
@@ -57,6 +58,43 @@ export async function resetExtensionPassword(id: string): Promise<{ ok: true; si
|
||||
}
|
||||
}
|
||||
|
||||
/** Diferente de `resetExtensionPassword`: não troca nada, só mostra a
|
||||
* senha atual de novo — pra reconfigurar um telefone/softphone sem
|
||||
* derrubar o registro de outro aparelho já usando essa senha. */
|
||||
export async function revealExtensionPassword(id: string): Promise<{ ok: true; sipPassword: string } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const { sipPassword } = await apiFetch<{ sipPassword: string }>(`/extensions/${id}/reveal-password`, session.accessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
return { ok: true, sipPassword };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateExtensionInput {
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
maxRegistrations?: number;
|
||||
callGroup?: string | null;
|
||||
}
|
||||
|
||||
export async function updateExtension(id: string, input: UpdateExtensionInput): Promise<{ ok: true; extension: Extension } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const extension = await apiFetch<Extension>(`/extensions/${id}`, session.accessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath(`/app/telefonia/ramais/${id}`);
|
||||
revalidatePath("/app/telefonia/ramais");
|
||||
return { ok: true, extension };
|
||||
} 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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function NewExtensionForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [callerIdName, setCallerIdName] = useState("");
|
||||
const [callerIdNumber, setCallerIdNumber] = useState("");
|
||||
const [callGroup, setCallGroup] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null);
|
||||
@@ -38,6 +39,7 @@ export function NewExtensionForm() {
|
||||
name: name.trim(),
|
||||
callerIdName: callerIdName.trim() || undefined,
|
||||
callerIdNumber: callerIdNumber.trim() || undefined,
|
||||
callGroup: callGroup.trim() || undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
@@ -64,6 +66,28 @@ export function NewExtensionForm() {
|
||||
|
||||
<SecretReveal label="Senha SIP" value={created.sipPassword} />
|
||||
|
||||
<Panel className="space-y-2 p-5">
|
||||
<p className="text-sm font-medium text-foreground">Configuração do telefone/softphone</p>
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Domínio</dt>
|
||||
<dd className="font-mono text-foreground">{created.extension.domain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Ramal (usuário)</dt>
|
||||
<dd className="font-mono text-foreground">{created.extension.number}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Proxy/servidor</dt>
|
||||
<dd className="font-mono text-foreground">IP ou host do FreeSWITCH desta implantação</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Senha</dt>
|
||||
<dd className="font-mono text-foreground">a de cima ↑</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/app/telefonia/ramais">Voltar pra lista</Link>
|
||||
@@ -116,6 +140,22 @@ export function NewExtensionForm() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:w-72">
|
||||
<FieldLabel htmlFor="ext-callgroup">Grupo de captura (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="ext-callgroup"
|
||||
value={callGroup}
|
||||
onChange={(e) => setCallGroup(e.target.value)}
|
||||
placeholder="Ex.: recepcao, vendas"
|
||||
className="font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Ramais no mesmo grupo podem capturar a chamada um do outro (*8). Sem grupo, nenhum outro ramal captura
|
||||
esta chamada.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -50,6 +50,10 @@ export function TenantDetailView({ tenant, plans }: { tenant: Tenant & { plan: P
|
||||
Código <span className="font-mono">{tenant.code}</span> — criado em {formatDate(tenant.createdAt)},{" "}
|
||||
{tenant.memberCount} usuário(s)
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Domínio SIP <span className="font-mono text-foreground">{tenant.telephonyDomain ?? "—"}</span> — usado pra
|
||||
isolar ramais/filas/IVR deste tenant no PABX, fixo desde a criação
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel className="space-y-4 p-5">
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface CreateTenantInput {
|
||||
tradeName?: string;
|
||||
taxId?: string;
|
||||
planId: string;
|
||||
telephonyDomain: string;
|
||||
adminEmail: string;
|
||||
adminName: string;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,20 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||
const [tradeName, setTradeName] = useState("");
|
||||
const [taxId, setTaxId] = useState("");
|
||||
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
|
||||
const [telephonyDomain, setTelephonyDomain] = useState("");
|
||||
const [domainTouched, setDomainTouched] = useState(false);
|
||||
const [adminEmail, setAdminEmail] = useState("");
|
||||
const [adminName, setAdminName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [created, setCreated] = useState<CreateTenantResult | null>(null);
|
||||
|
||||
function onCodeChange(value: string) {
|
||||
const next = value.toLowerCase();
|
||||
setCode(next);
|
||||
if (!domainTouched) setTelephonyDomain(next ? `${next}.b2bcall.net` : "");
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -34,6 +42,10 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||
setError("Razão social, plano, e-mail e nome do admin são obrigatórios.");
|
||||
return;
|
||||
}
|
||||
if (!telephonyDomain.trim()) {
|
||||
setError("Domínio SIP é obrigatório — isola o PABX deste tenant dos outros.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createTenant({
|
||||
@@ -42,6 +54,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||
tradeName: tradeName.trim() || undefined,
|
||||
taxId: taxId.trim() || undefined,
|
||||
planId,
|
||||
telephonyDomain: telephonyDomain.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
adminName: adminName.trim(),
|
||||
});
|
||||
@@ -102,7 +115,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||
<Input
|
||||
id="t-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toLowerCase())}
|
||||
onChange={(e) => onCodeChange(e.target.value)}
|
||||
placeholder="ex.: acme"
|
||||
className="font-mono"
|
||||
disabled={pending}
|
||||
@@ -129,9 +142,30 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||
<Input id="t-trade" value={tradeName} onChange={(e) => setTradeName(e.target.value)} placeholder="Ex.: Acme Call Center" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:w-64">
|
||||
<FieldLabel htmlFor="t-tax">CNPJ/CPF (opcional)</FieldLabel>
|
||||
<Input id="t-tax" value={taxId} onChange={(e) => setTaxId(e.target.value)} className="font-mono" disabled={pending} />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-tax">CNPJ/CPF (opcional)</FieldLabel>
|
||||
<Input id="t-tax" value={taxId} onChange={(e) => setTaxId(e.target.value)} className="font-mono" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-domain">Domínio SIP (telefonia)</FieldLabel>
|
||||
<Input
|
||||
id="t-domain"
|
||||
value={telephonyDomain}
|
||||
onChange={(e) => {
|
||||
setDomainTouched(true);
|
||||
setTelephonyDomain(e.target.value.toLowerCase());
|
||||
}}
|
||||
placeholder="empresa.b2bcall.net"
|
||||
className="font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Isola o PABX deste tenant dos outros (ramais, filas, IVR) — sugerido a partir do código, mas pode
|
||||
trocar. Não precisa resolver por DNS de verdade: o telefone aponta pro IP do servidor, este campo só
|
||||
identifica o tenant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Extension {
|
||||
context: string;
|
||||
sofiaProfile: string;
|
||||
codecs: string;
|
||||
callGroup: string | null;
|
||||
maxRegistrations: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Tenant {
|
||||
timezone: string;
|
||||
locale: string;
|
||||
billingCurrency: string;
|
||||
telephonyDomain: string | null;
|
||||
planId: string;
|
||||
plan: { id: string; key: string; name: string };
|
||||
memberCount: number;
|
||||
|
||||
Reference in New Issue
Block a user