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:
2026-08-30 12:05:25 -03:00
parent 7bc4b2e819
commit 56f73bdb8f
24 changed files with 498 additions and 30 deletions

View File

@@ -33,4 +33,37 @@ export class CreateExtensionDto {
@Min(1)
@Max(10)
maxRegistrations?: number;
/** Grupo de captura (secao 178) — ramais no mesmo grupo podem capturar
* a chamada um do outro (*8 no dialplan); ramais fora do grupo, não.
* Sem grupo (null) = ninguém mais captura essa chamada. */
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
}
export class UpdateExtensionDto {
@IsOptional()
@IsString()
@MaxLength(80)
callerIdName?: string;
@IsOptional()
@IsString()
@Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
maxRegistrations?: number;
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
}

View File

@@ -8,18 +8,19 @@ import {
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { assertQuota } from "@b2bcall/entitlements";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateExtensionDto } from "./dto/create-extension.dto";
import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto";
function toPublicExtension(ext: {
id: string;
@@ -32,6 +33,7 @@ function toPublicExtension(ext: {
context: string;
sofiaProfile: string;
codecs: string;
callGroup: string | null;
maxRegistrations: number;
enabled: boolean;
createdAt: Date;
@@ -81,6 +83,7 @@ export class ExtensionsController {
context: dto.context ?? "default",
sofiaProfile: dto.sofiaProfile ?? "internal",
maxRegistrations: dto.maxRegistrations ?? 1,
callGroup: dto.callGroup,
},
}),
);
@@ -162,6 +165,72 @@ export class ExtensionsController {
return { sipPassword: plainPassword };
}
/**
* Revela a senha SIP atual (achado real reportado pelo usuário: "show
* once" puro não funciona no dia a dia — reconfigurar um telefone físico
* ou um softphone precisa da senha de novo, e forçar reset toda vez
* derruba o registro de qualquer aparelho já configurado com a senha
* antiga). Diferente de `resetPassword`: não gera senha nova, só
* decifra a que já existe (`sipPasswordEnc` é criptografia reversível
* AES-256-GCM, não hash — sempre foi possível decifrar, só não estava
* exposto). Cada chamada fica no audit log — ver a senha de novo é uma
* ação sensível, mesmo sem trocar nada.
*/
@RequirePermission("extensions.manage")
@Post(":id/reveal-password")
async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }),
);
if (!extension) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "EXTENSION_PASSWORD_REVEALED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: decryptSecret(extension.sipPasswordEnc) };
}
@RequirePermission("extensions.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "EXTENSION_UPDATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
after: { ...dto },
});
return toPublicExtension(updated);
}
@RequirePermission("extensions.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -25,6 +25,22 @@ export class CreateTenantDto {
@IsUUID()
planId!: string;
/** Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — achado
* real reportado pelo usuário: antes disto todo tenant nascia com o
* mesmo domínio fixo ("b2bcall.local"), quebrando isolamento de PABX
* (call groups, filas, IVR não conseguem distinguir um tenant do
* outro). A tela sugere `{code}.b2bcall.net`, mas quem cria pode
* digitar outro — só precisa ser único (constraint no banco) e ter
* cara de domínio (não precisa resolver de verdade por DNS: o
* telefone/softphone aponta pro IP do servidor via `proxy=`, este
* campo só serve pra identificar o tenant no REGISTER). */
@IsString()
@MaxLength(120)
@Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/, {
message: "telephonyDomain precisa ter formato de domínio (ex.: minhaempresa.b2bcall.net)",
})
telephonyDomain!: string;
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo

View File

@@ -14,8 +14,14 @@ import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
*
* `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão
* já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda).
* `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora,
* único por tenant (constraint no banco) — achado real reportado pelo
* usuário testando: até a PHASE 50 todo tenant novo nascia com o mesmo
* domínio fixo ("b2bcall.local"), quebrando isolamento de PABX de verdade
* (call groups, filas, IVR não tinham como distinguir um tenant do outro
* no REGISTER). O FreeSWITCH em si já aceita qualquer domínio dinamicamente
* (`<domain name="all" alias="true".../>` no sofia profile vanilla,
* confirmado lendo o container) — o bug era só este valor fixo aqui.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("tenants")
@@ -28,12 +34,14 @@ export class TenantsController {
}
const prisma = getPrismaClient();
const [existingCode, existingEmail, plan] = await Promise.all([
const [existingCode, existingDomain, existingEmail, plan] = await Promise.all([
prisma.tenant.findFirst({ where: { code: dto.code } }),
prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }),
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
prisma.plan.findUnique({ where: { id: dto.planId } }),
]);
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
if (existingDomain) throw new ForbiddenException(`Ja existe um tenant usando o dominio "${dto.telephonyDomain}"`);
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
if (!plan) throw new ForbiddenException("Plano nao encontrado");
@@ -49,7 +57,7 @@ export class TenantsController {
tradeName: dto.tradeName,
taxId: dto.taxId,
planId: dto.planId,
telephonyDomain: "b2bcall.local",
telephonyDomain: dto.telephonyDomain,
},
});

View File

@@ -52,9 +52,11 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
// tenant especifico depois de achar o dono do dominio.
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
if (!tenant) {
// tenant especifico depois de achar o dono do dominio. `telephonyDomain`
// e' @unique desde a PHASE 50 (achado real: antes disso todo tenant
// tinha o mesmo dominio fixo) — `findUnique` reflete essa garantia.
const tenant = await prisma.tenant.findUnique({ where: { telephonyDomain: domain } });
if (!tenant || tenant.status !== "ACTIVE") {
return NOT_FOUND_XML;
}
@@ -75,16 +77,17 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
callerIdNumber: extension.callerIdNumber ?? undefined,
tenantId: extension.tenantId,
extensionId: extension.id,
callGroup: extension.callGroup,
});
}
/**
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o
* dialplan já tem essa variável disponível na própria chamada, então nem
* sofre da mesma ambiguidade.
* Ao contrário do directory (resolvido por domain — cada tenant tem o seu
* agora, único no banco, PHASE 50/docs/EXTENSIONS.md), o dialplan já tem
* essa variável disponível na própria chamada, então nem depende de
* domain nenhum.
*
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -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();

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -25,6 +25,7 @@ export interface CreateTenantInput {
tradeName?: string;
taxId?: string;
planId: string;
telephonyDomain: string;
adminEmail: string;
adminName: string;
}

View File

@@ -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
identifica o tenant.
</p>
</div>
</div>
</Panel>

View File

@@ -9,6 +9,7 @@ export interface Extension {
context: string;
sofiaProfile: string;
codecs: string;
callGroup: string | null;
maxRegistrations: number;
enabled: boolean;
createdAt: string;

View File

@@ -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;