diff --git a/apps/api/src/extensions/dto/update-extension.dto.ts b/apps/api/src/extensions/dto/update-extension.dto.ts index c512007..72d7d72 100644 --- a/apps/api/src/extensions/dto/update-extension.dto.ts +++ b/apps/api/src/extensions/dto/update-extension.dto.ts @@ -1,8 +1,18 @@ import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { IsOptional, IsString, MinLength } from 'class-validator'; import { CreateExtensionDto } from './create-extension.dto'; // "number" não é editável — trocar o número de ramal é, na prática, criar // outro ramal (o identificador do objeto PJSIP é o próprio número). export class UpdateExtensionDto extends PartialType( OmitType(CreateExtensionDto, ['number'] as const), -) {} +) { + // Só na edição (não no create, que sempre gera automaticamente) — permite + // o operador definir/trocar a senha SIP diretamente, sem precisar de um + // reset (agente.md seção 55: cifrada em repouso, nunca em texto puro fora + // deste fluxo). + @IsOptional() + @IsString() + @MinLength(12, { message: 'Senha SIP deve ter pelo menos 12 caracteres.' }) + sipPassword?: string; +} diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index a3929fb..5cabc86 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -60,6 +60,19 @@ export class ExtensionsController { }); } + @Get(':id/password') + @RequirePermissions('extensions.update') + getPassword( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.extensionsService.getPassword(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + @Post(':id/reset-password') @RequirePermissions('extensions.update') resetPassword( diff --git a/apps/api/src/extensions/extensions.service.ts b/apps/api/src/extensions/extensions.service.ts index a88e81c..539aad6 100644 --- a/apps/api/src/extensions/extensions.service.ts +++ b/apps/api/src/extensions/extensions.service.ts @@ -70,6 +70,33 @@ export class ExtensionsService { return { ...toSafeExtension(extension), state }; } + // Diferente da senha de login (hash argon2id, irreversível por design), a + // senha SIP é cifrada (AES-256-GCM) porque o próprio Asterisk precisa da + // senha em texto puro para autenticar o REGISTER — o sistema já tem essa + // capacidade de decifrar, então expor sob demanda (permissão + // extensions.update, auditado) é razoável e evita forçar reset toda vez + // que o operador esquece a senha de um softphone. + async getPassword(id: string, actor: { id: string }, ctx: RequestContext) { + const extension = await this.prisma.extension.findUnique({ where: { id } }); + if (!extension) throw new NotFoundException('Ramal não encontrado.'); + + const sipPassword = decryptSecret( + extension.sipPasswordEncrypted, + this.masterKey, + ); + + await this.audit.log({ + userId: actor.id, + action: 'extension_password_viewed', + entityType: 'extension', + entityId: id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return { sipPassword }; + } + private async provisionPjsip(extension: Extension, plainPassword: string) { const id = extension.number; await this.pjsip.upsertAuth({ id, username: id, password: plainPassword }); @@ -145,6 +172,10 @@ export class ExtensionsService { const before = await this.prisma.extension.findUnique({ where: { id } }); if (!before) throw new NotFoundException('Ramal não encontrado.'); + const sipPasswordEncrypted = dto.sipPassword + ? encryptSecret(dto.sipPassword, this.masterKey) + : undefined; + const extension = await this.prisma.extension.update({ where: { id }, data: { @@ -156,19 +187,23 @@ export class ExtensionsService { maxContacts: dto.maxContacts, qualifyFrequency: dto.qualifyFrequency, enabled: dto.enabled, + ...(sipPasswordEncrypted ? { sipPasswordEncrypted } : {}), }, }); - // Não há troca de senha aqui — reaplica só endpoint/aor com a senha atual. - const currentPassword = decryptSecret( - extension.sipPasswordEncrypted, - this.masterKey, - ); + // Reaplica o objeto PJSIP com a senha (nova, se veio no dto; a mesma de + // antes, caso contrário) — endpoint/aor podem ter mudado mesmo sem + // trocar senha (contexto, codecs, etc.). + const currentPassword = + dto.sipPassword ?? + decryptSecret(extension.sipPasswordEncrypted, this.masterKey); await this.provisionPjsip(extension, currentPassword); await this.audit.log({ userId: actor.id, - action: 'extension_updated', + action: dto.sipPassword + ? 'extension_updated_password_changed' + : 'extension_updated', entityType: 'extension', entityId: id, before: { name: before.name, enabled: before.enabled }, diff --git a/apps/api/src/outbound-routes/outbound-route-generator.spec.ts b/apps/api/src/outbound-routes/outbound-route-generator.spec.ts index 1e8d754..0ba0fdd 100644 --- a/apps/api/src/outbound-routes/outbound-route-generator.spec.ts +++ b/apps/api/src/outbound-routes/outbound-route-generator.spec.ts @@ -46,8 +46,22 @@ describe('generateOutboundRoutesConfig', () => { it('ordena rotas pelo campo order', () => { const config = generateOutboundRoutesConfig([ - { name: 'Segunda', prefix: '', matchPattern: 'X.', prepend: null, order: 5, trunk: { name: 't2' } }, - { name: 'Primeira', prefix: '', matchPattern: 'X.', prepend: null, order: 1, trunk: { name: 't1' } }, + { + name: 'Segunda', + prefix: '', + matchPattern: 'X.', + prepend: null, + order: 5, + trunk: { name: 't2' }, + }, + { + name: 'Primeira', + prefix: '', + matchPattern: 'X.', + prepend: null, + order: 1, + trunk: { name: 't1' }, + }, ]); expect(config.indexOf('Primeira')).toBeLessThan(config.indexOf('Segunda')); }); diff --git a/apps/frontend/src/app/(app)/ramais/page.tsx b/apps/frontend/src/app/(app)/ramais/page.tsx index d8d4291..82dbe62 100644 --- a/apps/frontend/src/app/(app)/ramais/page.tsx +++ b/apps/frontend/src/app/(app)/ramais/page.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, KeyRound, Trash2, Pencil, Copy } from 'lucide-react'; +import { Plus, KeyRound, Trash2, Pencil, Copy, Eye, EyeOff } from 'lucide-react'; import { PageHeader } from '@/components/layout/page-header'; import { RequirePermission } from '@/components/require-permission'; import { DataTable, type DataTableColumn } from '@/components/data-table/data-table'; @@ -44,10 +44,23 @@ function ExtensionFormDialog({ enabled: true, }); const [createdPassword, setCreatedPassword] = React.useState(null); + const [showPassword, setShowPassword] = React.useState(false); + // Compara com o valor buscado do servidor para só mandar sipPassword no + // update quando o operador realmente mudou o campo (evita re-provisionar + // o PJSIP e gerar log de "senha alterada" à toa a cada salvar). + const originalPasswordRef = React.useRef(undefined); + + const passwordQuery = useQuery({ + queryKey: ['extension-password', extension?.id], + queryFn: () => extensionsService.getPassword(extension!.id), + enabled: open && isEdit && Boolean(extension), + }); React.useEffect(() => { if (open) { setCreatedPassword(null); + setShowPassword(false); + originalPasswordRef.current = undefined; setForm( extension ? { @@ -60,13 +73,27 @@ function ExtensionFormDialog({ } }, [open, extension]); + React.useEffect(() => { + if (passwordQuery.data) { + originalPasswordRef.current = passwordQuery.data.sipPassword; + setForm((f) => ({ ...f, sipPassword: passwordQuery.data.sipPassword })); + } + }, [passwordQuery.data]); + const mutation = useMutation({ - mutationFn: () => - isEdit && extension - ? extensionsService.update(extension.id, form) - : extensionsService.create(form), + mutationFn: () => { + if (isEdit && extension) { + const changedPassword = + form.sipPassword && form.sipPassword !== originalPasswordRef.current + ? form.sipPassword + : undefined; + return extensionsService.update(extension.id, { ...form, sipPassword: changedPassword }); + } + return extensionsService.create(form); + }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['extensions'] }); + queryClient.invalidateQueries({ queryKey: ['extension-password', extension?.id] }); if (!isEdit && 'sipPassword' in result && result.sipPassword) { setCreatedPassword(result.sipPassword); toast({ title: 'Ramal criado', variant: 'success' }); @@ -140,6 +167,41 @@ function ExtensionFormDialog({ onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))} /> + {isEdit && ( +
+ +
+ setForm((f) => ({ ...f, sipPassword: e.target.value }))} + /> + + +
+

+ Use isso para configurar o softphone. Editar aqui troca a senha de + verdade (reaplica no Asterisk ao salvar). +

+
+ )}