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:
49
TODO.md
49
TODO.md
@@ -1828,6 +1828,55 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
|
|||||||
mas o error boundary do client detecta, desloga e redireciona pro
|
mas o error boundary do client detecta, desloga e redireciona pro
|
||||||
login sozinho — sem crash na tela pro usuário
|
login sozinho — sem crash na tela pro usuário
|
||||||
|
|
||||||
|
## PHASE 52 — Domínio SIP por tenant + grupo de captura + revelar senha
|
||||||
|
(achado real detalhado pelo usuário, testando o PABX de verdade)
|
||||||
|
- [x] achado real (o mais sério dos três): `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: domain })`),
|
||||||
|
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: o sofia profile `internal`
|
||||||
|
(vanilla, nunca sobrescrito) já vem com
|
||||||
|
`<domain name="all" alias="true" parse="false"/>` — o FreeSWITCH
|
||||||
|
SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a
|
||||||
|
aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de
|
||||||
|
infra (Dockerfile/sofia profile) foi necessária
|
||||||
|
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
|
||||||
|
`20260830140000_tenant_domain_and_call_group`, com backfill:
|
||||||
|
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
|
||||||
|
e os `Extension.domain` já criados foram atualizados junto — sem
|
||||||
|
isso ficariam com um `<domain>` desatualizado no XML). Tela de
|
||||||
|
criação de tenant sugere `{code}.b2bcall.net` ao digitar o código,
|
||||||
|
editável antes de criar. Duplicado vira 403 claro
|
||||||
|
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
|
||||||
|
178): ramais no mesmo grupo podem atender a chamada um do outro
|
||||||
|
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
|
||||||
|
`call-group` no directory XML (`packages/telephony`); a extensão de
|
||||||
|
dialplan do `*8` em si é uma regra a configurar em Telefonia >
|
||||||
|
Dialplan (já existe o editor), não precisava de código novo — só do
|
||||||
|
dado. Editável na criação e depois (`PATCH /extensions/:id`, novo)
|
||||||
|
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
|
||||||
|
once" puro não funciona no dia a dia de um PABX (reconfigurar um
|
||||||
|
telefone físico ou softphone precisa da senha de novo; forçar reset
|
||||||
|
toda vez derruba o registro de qualquer aparelho já configurado
|
||||||
|
com a senha antiga). `sipPasswordEnc` sempre foi criptografia
|
||||||
|
reversível (AES-256-GCM), nunca hash — só não estava exposto.
|
||||||
|
Diferente de `reset-password`: nunca troca nada, só decifra a
|
||||||
|
atual. Auditado (`EXTENSION_PASSWORD_REVEALED`) por ser sensível
|
||||||
|
mesmo sem escrita
|
||||||
|
- [x] Tela de criação de ramal ganhou um bloco "Configuração do telefone/
|
||||||
|
softphone" (domínio, ramal, proxy=servidor, senha) — os 4 campos
|
||||||
|
exatos que o usuário descreveu precisar pra configurar um aparelho
|
||||||
|
- [x] `apps/freeswitch-config` reconstruído e reiniciado (mudança de
|
||||||
|
schema + lógica) — testado ponta a ponta contra o container REAL:
|
||||||
|
criado tenant com domínio próprio + ramal com callGroup, `docker
|
||||||
|
exec` no FreeSWITCH chamando `fs-config` direto (mesma rede
|
||||||
|
Docker) confirmou (1) senha revelada bate exatamente com a gerada
|
||||||
|
na criação, (2) `call-group` aparece no XML, (3) o mesmo número de
|
||||||
|
ramal em domínios diferentes NUNCA se confunde (isolamento
|
||||||
|
cross-tenant confirmado de verdade, não só por leitura de código)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Riscos conhecidos
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -33,4 +33,37 @@ export class CreateExtensionDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(10)
|
@Max(10)
|
||||||
maxRegistrations?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
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 { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
import { assertQuota } from "@b2bcall/entitlements";
|
import { assertQuota } from "@b2bcall/entitlements";
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.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: {
|
function toPublicExtension(ext: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +33,7 @@ function toPublicExtension(ext: {
|
|||||||
context: string;
|
context: string;
|
||||||
sofiaProfile: string;
|
sofiaProfile: string;
|
||||||
codecs: string;
|
codecs: string;
|
||||||
|
callGroup: string | null;
|
||||||
maxRegistrations: number;
|
maxRegistrations: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -81,6 +83,7 @@ export class ExtensionsController {
|
|||||||
context: dto.context ?? "default",
|
context: dto.context ?? "default",
|
||||||
sofiaProfile: dto.sofiaProfile ?? "internal",
|
sofiaProfile: dto.sofiaProfile ?? "internal",
|
||||||
maxRegistrations: dto.maxRegistrations ?? 1,
|
maxRegistrations: dto.maxRegistrations ?? 1,
|
||||||
|
callGroup: dto.callGroup,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -162,6 +165,72 @@ export class ExtensionsController {
|
|||||||
return { sipPassword: plainPassword };
|
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")
|
@RequirePermission("extensions.manage")
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ export class CreateTenantDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
planId!: string;
|
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
|
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
|
||||||
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
|
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
|
||||||
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
|
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
|
||||||
|
|||||||
@@ -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),
|
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
|
||||||
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
|
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
|
||||||
*
|
*
|
||||||
* `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão
|
* `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora,
|
||||||
* já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda).
|
* ú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)
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
@Controller("tenants")
|
@Controller("tenants")
|
||||||
@@ -28,12 +34,14 @@ export class TenantsController {
|
|||||||
}
|
}
|
||||||
const prisma = getPrismaClient();
|
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: { code: dto.code } }),
|
||||||
|
prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }),
|
||||||
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
|
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
|
||||||
prisma.plan.findUnique({ where: { id: dto.planId } }),
|
prisma.plan.findUnique({ where: { id: dto.planId } }),
|
||||||
]);
|
]);
|
||||||
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
|
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 (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
|
||||||
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
|
||||||
@@ -49,7 +57,7 @@ export class TenantsController {
|
|||||||
tradeName: dto.tradeName,
|
tradeName: dto.tradeName,
|
||||||
taxId: dto.taxId,
|
taxId: dto.taxId,
|
||||||
planId: dto.planId,
|
planId: dto.planId,
|
||||||
telephonyDomain: "b2bcall.local",
|
telephonyDomain: dto.telephonyDomain,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
|
||||||
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
|
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
|
||||||
// tenant especifico depois de achar o dono do dominio.
|
// tenant especifico depois de achar o dono do dominio. `telephonyDomain`
|
||||||
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
|
// e' @unique desde a PHASE 50 (achado real: antes disso todo tenant
|
||||||
if (!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;
|
return NOT_FOUND_XML;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +77,17 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
|||||||
callerIdNumber: extension.callerIdNumber ?? undefined,
|
callerIdNumber: extension.callerIdNumber ?? undefined,
|
||||||
tenantId: extension.tenantId,
|
tenantId: extension.tenantId,
|
||||||
extensionId: extension.id,
|
extensionId: extension.id,
|
||||||
|
callGroup: extension.callGroup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
|
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
|
||||||
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
|
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
|
||||||
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra
|
* Ao contrário do directory (resolvido por domain — cada tenant tem o seu
|
||||||
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o
|
* agora, único no banco, PHASE 50/docs/EXTENSIONS.md), o dialplan já tem
|
||||||
* dialplan já tem essa variável disponível na própria chamada, então nem
|
* essa variável disponível na própria chamada, então nem depende de
|
||||||
* sofre da mesma ambiguidade.
|
* domain nenhum.
|
||||||
*
|
*
|
||||||
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
|
* 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
|
* — 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 |
@@ -2,10 +2,96 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
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 }) {
|
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
|
|||||||
import { Pill } from "@/components/ui/pill";
|
import { Pill } from "@/components/ui/pill";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { ResetPasswordAction, DeleteExtensionAction } from "./actions-panel";
|
import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel";
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
tenant: { name: string; code: string } | null;
|
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>
|
<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>
|
<dd className="mt-1 font-mono text-sm tabular-nums text-foreground">{extension.maxRegistrations}</dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Criado em</dt>
|
<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>
|
<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>
|
<Panel>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Senha SIP"
|
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} />
|
<ResetPasswordAction extensionId={extension.id} />
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface CreateExtensionInput {
|
|||||||
callerIdNumber?: string;
|
callerIdNumber?: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
maxRegistrations?: number;
|
maxRegistrations?: number;
|
||||||
|
callGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createExtension(
|
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 }> {
|
export async function deleteExtension(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function NewExtensionForm() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [callerIdName, setCallerIdName] = useState("");
|
const [callerIdName, setCallerIdName] = useState("");
|
||||||
const [callerIdNumber, setCallerIdNumber] = useState("");
|
const [callerIdNumber, setCallerIdNumber] = useState("");
|
||||||
|
const [callGroup, setCallGroup] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null);
|
const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null);
|
||||||
@@ -38,6 +39,7 @@ export function NewExtensionForm() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
callerIdName: callerIdName.trim() || undefined,
|
callerIdName: callerIdName.trim() || undefined,
|
||||||
callerIdNumber: callerIdNumber.trim() || undefined,
|
callerIdNumber: callerIdNumber.trim() || undefined,
|
||||||
|
callGroup: callGroup.trim() || undefined,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
@@ -64,6 +66,28 @@ export function NewExtensionForm() {
|
|||||||
|
|
||||||
<SecretReveal label="Senha SIP" value={created.sipPassword} />
|
<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">
|
<div className="flex justify-end gap-3">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/app/telefonia/ramais">Voltar pra lista</Link>
|
<Link href="/app/telefonia/ramais">Voltar pra lista</Link>
|
||||||
@@ -116,6 +140,22 @@ export function NewExtensionForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Panel>
|
||||||
|
|
||||||
{error && (
|
{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)},{" "}
|
Código <span className="font-mono">{tenant.code}</span> — criado em {formatDate(tenant.createdAt)},{" "}
|
||||||
{tenant.memberCount} usuário(s)
|
{tenant.memberCount} usuário(s)
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface CreateTenantInput {
|
|||||||
tradeName?: string;
|
tradeName?: string;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
telephonyDomain: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminName: string;
|
adminName: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,20 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
|||||||
const [tradeName, setTradeName] = useState("");
|
const [tradeName, setTradeName] = useState("");
|
||||||
const [taxId, setTaxId] = useState("");
|
const [taxId, setTaxId] = useState("");
|
||||||
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
|
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
|
||||||
|
const [telephonyDomain, setTelephonyDomain] = useState("");
|
||||||
|
const [domainTouched, setDomainTouched] = useState(false);
|
||||||
const [adminEmail, setAdminEmail] = useState("");
|
const [adminEmail, setAdminEmail] = useState("");
|
||||||
const [adminName, setAdminName] = useState("");
|
const [adminName, setAdminName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [created, setCreated] = useState<CreateTenantResult | null>(null);
|
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) {
|
function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
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.");
|
setError("Razão social, plano, e-mail e nome do admin são obrigatórios.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!telephonyDomain.trim()) {
|
||||||
|
setError("Domínio SIP é obrigatório — isola o PABX deste tenant dos outros.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createTenant({
|
const result = await createTenant({
|
||||||
@@ -42,6 +54,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
|||||||
tradeName: tradeName.trim() || undefined,
|
tradeName: tradeName.trim() || undefined,
|
||||||
taxId: taxId.trim() || undefined,
|
taxId: taxId.trim() || undefined,
|
||||||
planId,
|
planId,
|
||||||
|
telephonyDomain: telephonyDomain.trim(),
|
||||||
adminEmail: adminEmail.trim(),
|
adminEmail: adminEmail.trim(),
|
||||||
adminName: adminName.trim(),
|
adminName: adminName.trim(),
|
||||||
});
|
});
|
||||||
@@ -102,7 +115,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
|||||||
<Input
|
<Input
|
||||||
id="t-code"
|
id="t-code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.toLowerCase())}
|
onChange={(e) => onCodeChange(e.target.value)}
|
||||||
placeholder="ex.: acme"
|
placeholder="ex.: acme"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
disabled={pending}
|
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} />
|
<Input id="t-trade" value={tradeName} onChange={(e) => setTradeName(e.target.value)} placeholder="Ex.: Acme Call Center" disabled={pending} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:w-64">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FieldLabel htmlFor="t-tax">CNPJ/CPF (opcional)</FieldLabel>
|
<div>
|
||||||
<Input id="t-tax" value={taxId} onChange={(e) => setTaxId(e.target.value)} className="font-mono" disabled={pending} />
|
<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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Extension {
|
|||||||
context: string;
|
context: string;
|
||||||
sofiaProfile: string;
|
sofiaProfile: string;
|
||||||
codecs: string;
|
codecs: string;
|
||||||
|
callGroup: string | null;
|
||||||
maxRegistrations: number;
|
maxRegistrations: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Tenant {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
billingCurrency: string;
|
billingCurrency: string;
|
||||||
|
telephonyDomain: string | null;
|
||||||
planId: string;
|
planId: string;
|
||||||
plan: { id: string; key: string; name: string };
|
plan: { id: string; key: string; name: string };
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
|
|||||||
@@ -71,18 +71,29 @@ user/1500 &park()` falhava com `MANDATORY_IE_MISSING` em vez do
|
|||||||
pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da
|
pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da
|
||||||
config vanilla.
|
config vanilla.
|
||||||
|
|
||||||
## `$${domain}` fixo
|
## `$${domain}` fixo (só o valor default do container, não uma restrição)
|
||||||
|
|
||||||
`Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como
|
`Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como
|
||||||
`domain` no POST do xml_curl. Por padrão, a config vanilla usa
|
`domain` no POST do xml_curl. Por padrão, a config vanilla usa
|
||||||
`domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e
|
`domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e
|
||||||
nunca seria estável o suficiente pra configurar em um tenant. Corrigido no
|
nunca seria estável o suficiente pra configurar em um tenant. Corrigido no
|
||||||
`Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra
|
`Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra
|
||||||
`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`). Multi-domínio
|
`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`) — isso é só o
|
||||||
real por tenant (múltiplos domínios simultâneos, um por tenant) ainda não
|
valor usado como REFERÊNCIA/fallback em `vars.xml`, não uma restrição de
|
||||||
está resolvido — hoje só suporta um domínio fixo pra todos; isso é uma
|
quais domínios o profile aceita.
|
||||||
limitação genuína a resolver quando existir gestão de domínio por tenant de
|
|
||||||
verdade (fora do escopo desta fase).
|
**Multi-domínio real por tenant — resolvido na PHASE 50** (achado real
|
||||||
|
reportado pelo usuário testando): o profile `internal` (vanilla, não
|
||||||
|
sobrescrito) já vem com `<domain name="all" alias="true" parse="false"/>`
|
||||||
|
— aceita REGISTER de qualquer domínio dinamicamente, sempre dependeu só do
|
||||||
|
`section=directory` do `mod_xml_curl` resolver o domínio certo (que já
|
||||||
|
funciona por request, olhando o `domain` que o UA manda). O bug de verdade
|
||||||
|
era só a camada de aplicação: `TenantsController.create()` gravava o mesmo
|
||||||
|
`telephonyDomain` fixo ("b2bcall.local") pra todo tenant novo. Corrigido:
|
||||||
|
`telephonyDomain` agora é obrigatório e único na criação do tenant
|
||||||
|
(constraint no banco), sugerido como `{code}.b2bcall.net` na tela mas
|
||||||
|
editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi
|
||||||
|
necessária — só era preciso um domínio de verdade por tenant.
|
||||||
|
|
||||||
## Verificado ponta a ponta
|
## Verificado ponta a ponta
|
||||||
|
|
||||||
@@ -113,9 +124,34 @@ reais, deixou de ser opcional): HTTP Basic, credenciais em `FS_CONFIG_USER`/
|
|||||||
sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials`
|
sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials`
|
||||||
configurado) continua funcionando normalmente.
|
configurado) continua funcionando normalmente.
|
||||||
|
|
||||||
|
## Senha SIP: "ver de novo" além do "show once" (PHASE 51)
|
||||||
|
|
||||||
|
Achado real reportado pelo usuário: "show once" puro não funciona no dia a
|
||||||
|
dia — reconfigurar um telefone físico ou softphone precisa da senha de
|
||||||
|
novo, e forçar reset toda vez (`POST /:id/reset-password`) derruba o
|
||||||
|
registro de qualquer aparelho já configurado com a senha antiga.
|
||||||
|
`sipPasswordEnc` sempre foi criptografia reversível (AES-256-GCM), nunca
|
||||||
|
hash — só não estava exposto. `POST /extensions/:id/reveal-password` (novo)
|
||||||
|
decifra e devolve a senha ATUAL sem trocar nada, auditado
|
||||||
|
(`EXTENSION_PASSWORD_REVEALED`) por ser uma ação sensível mesmo sem
|
||||||
|
escrita nenhuma.
|
||||||
|
|
||||||
|
## Grupo de captura (call group, PHASE 51)
|
||||||
|
|
||||||
|
Achado real: sem isso, qualquer ramal conseguia capturar a chamada de
|
||||||
|
qualquer outro (o PBX não tinha noção de "grupo"). `Extension.callGroup`
|
||||||
|
(nullable) vira a variable `call-group` no directory XML — o FreeSWITCH já
|
||||||
|
resolve `*8` (group pickup) comparando essa variable entre canais. A
|
||||||
|
extensão de dialplan pro `*8` em si fica pra configurar em Telefonia >
|
||||||
|
Dialplan (já é um editor de regras por tenant), não precisa de código
|
||||||
|
novo — só precisava existir o dado.
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
|
- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
|
||||||
docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
|
docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
|
||||||
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
|
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
|
||||||
- Multi-domínio real por tenant (ver acima).
|
- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50 (ver acima).
|
||||||
|
- Extensão de dialplan padrão pro `*8` de group pickup — o dado
|
||||||
|
(`callGroup`) já existe, falta só alguém configurar a regra em
|
||||||
|
Telefonia > Dialplan (ou decidir seedar uma default por tenant).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "extensions" ADD COLUMN "call_group" TEXT;
|
||||||
|
|
||||||
|
-- Backfill: todo tenant existente nascia com o mesmo telephony_domain fixo
|
||||||
|
-- ("b2bcall.local") — achado real reportado pelo usuário (isolamento de
|
||||||
|
-- PABX por tenant nao funciona sem dominio unico por tenant, secao 178).
|
||||||
|
-- Da' um valor distinto e' pra cada um antes de criar a constraint UNIQUE
|
||||||
|
-- abaixo, seguindo a mesma convencao que passa a ser sugerida na criacao
|
||||||
|
-- de tenant novo (`{code}.b2bcall.net`).
|
||||||
|
UPDATE "tenants" SET "telephony_domain" = "code" || '.b2bcall.net' WHERE "telephony_domain" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Ramais já existentes guardaram o domain antigo no momento da criação
|
||||||
|
-- (Extension.domain é uma cópia, não uma FK) — sem isto ficariam com um
|
||||||
|
-- <domain> desatualizado no XML do directory depois do backfill acima.
|
||||||
|
UPDATE "extensions" e SET "domain" = t."telephony_domain"
|
||||||
|
FROM "tenants" t WHERE e."tenant_id" = t."id";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tenants_telephony_domain_key" ON "tenants"("telephony_domain");
|
||||||
@@ -39,7 +39,14 @@ model Tenant {
|
|||||||
timezone String @default("America/Sao_Paulo")
|
timezone String @default("America/Sao_Paulo")
|
||||||
locale String @default("pt-BR")
|
locale String @default("pt-BR")
|
||||||
billingCurrency String @default("BRL") @map("billing_currency")
|
billingCurrency String @default("BRL") @map("billing_currency")
|
||||||
telephonyDomain String? @map("telephony_domain")
|
// Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — precisa ser
|
||||||
|
// único: é a chave que `b2bcall-fs-config` usa pra achar QUAL tenant é
|
||||||
|
// dono de um REGISTER/directory lookup (`Tenant.findFirst({
|
||||||
|
// telephonyDomain: domain })`). Antes desta constraint, todo tenant
|
||||||
|
// nascia com o mesmo valor fixo ("b2bcall.local") — achado real
|
||||||
|
// reportado pelo usuário: isolamento de PABX por tenant (call groups,
|
||||||
|
// filas, IVR) não funciona sem um domínio de verdade por tenant.
|
||||||
|
telephonyDomain String? @unique @map("telephony_domain")
|
||||||
planId String @map("plan_id") @db.Uuid
|
planId String @map("plan_id") @db.Uuid
|
||||||
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
|
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
|
||||||
// null = usa o PriceBook/RateDeck com isDefault=true (agente.md secao
|
// null = usa o PriceBook/RateDeck com isDefault=true (agente.md secao
|
||||||
@@ -303,6 +310,15 @@ model Extension {
|
|||||||
sofiaProfile String @default("internal") @map("sofia_profile")
|
sofiaProfile String @default("internal") @map("sofia_profile")
|
||||||
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
|
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
|
||||||
|
|
||||||
|
// Grupo de captura (agente.md secao 178, achado real reportado pelo
|
||||||
|
// usuário: sem isto, qualquer ramal consegue capturar a chamada de
|
||||||
|
// qualquer outro — precisa isolar por grupo pra virar um PABX de
|
||||||
|
// verdade). Vira a variável `call-group` no directory XML
|
||||||
|
// (packages/telephony); o *8 de group pickup em si é uma extensão de
|
||||||
|
// dialplan (Telefonia > Dialplan, já configurável por tenant), não
|
||||||
|
// precisa de coluna própria pra isso.
|
||||||
|
callGroup String? @map("call_group")
|
||||||
|
|
||||||
maxRegistrations Int @default(1) @map("max_registrations")
|
maxRegistrations Int @default(1) @map("max_registrations")
|
||||||
|
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface DirectoryUserParams {
|
|||||||
callerIdNumber?: string;
|
callerIdNumber?: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
extensionId: string;
|
extensionId: string;
|
||||||
|
/** Grupo de captura (secao 178) — vira a variable `call-group` no
|
||||||
|
* directory; o *8 de group pickup no dialplan usa essa mesma variable
|
||||||
|
* pra achar um canal tocando no mesmo grupo (`pickup` da FreeSWITCH
|
||||||
|
* exige `call-group` batendo, senão qualquer ramal capturaria a
|
||||||
|
* chamada de qualquer outro — achado real reportado pelo usuário). */
|
||||||
|
callGroup?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +60,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
|
|||||||
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
|
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
|
||||||
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
||||||
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||||
|
${params.callGroup ? `<variable name="call-group" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||||
</variables>
|
</variables>
|
||||||
</user>
|
</user>
|
||||||
</users>
|
</users>
|
||||||
|
|||||||
Reference in New Issue
Block a user