feat(frontend): Telefonia > Ramais — CRUD, reset de senha, isolamento por tenant

Tela /app/telefonia/ramais completa: listagem com busca/ordenacao,
criacao com reveal de senha SIP (gerada pela API, mostrada uma unica vez),
detalhe com redefinir senha (novo POST /extensions/:id/reset-password —
unica forma de "editar" a senha, nunca reexpoe a existente) e remover
(soft delete), confirmacao inline de 2 cliques em vez de modal. Cada tela
deixa explicito de qual tenant os ramais sao, alem do isolamento por RLS
que ja existia.

Corrige de quebra um bug real que afeta qualquer acao futura sem payload:
apiFetch sempre mandava Content-Type: application/json mesmo em requests
sem body, e o parser do Fastify rejeita body vazio com esse header.

Testado ponta a ponta com um tenant real (Acme Call Center): criar ramal,
revelar senha, redefinir (senha nova confirmada diferente da original),
remover, lista voltando vazia. Dark mode e mobile conferidos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 10:17:06 -03:00
parent 565110eac3
commit 34ef408f00
21 changed files with 726 additions and 15 deletions

View File

@@ -126,6 +126,42 @@ export class ExtensionsController {
return toPublicExtension(extension);
}
/**
* Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha
* existente — a única forma de "editar" é gerar uma nova e mostrar
* ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve
* o directory ao vivo por request (sem arquivo/sync intermediário,
* diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente,
* o próximo REGISTER do ramal usa a senha nova automaticamente.
*/
@RequirePermission("extensions.manage")
@Post(":id/reset-password")
async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const plainPassword = generateStrongPassword();
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: { sipPasswordEnc: encryptSecret(plainPassword) },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_RESET_PASSWORD",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: plainPassword };
}
@RequirePermission("extensions.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)