nftables bloqueava incondicionalmente SIP (5060/udp) e RTP (10000-20000) vindos da interface LAN — impedia qualquer softphone real de registrar, já que o OpenSIPS que ficaria na frente (docs/OPENSIPS.md) não foi implantado. Liberado para toda a faixa RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), mantendo AMI/ARI bloqueados incondicionalmente. Aplicado ao vivo no servidor. Ramais: agora dá para ver e trocar a senha SIP diretamente no formulário de edição (GET /extensions/:id/password, decifra sob demanda — diferente da senha de login, a senha SIP é cifrada, não hasheada, porque o próprio Asterisk precisa dela em texto puro para autenticar; expor sob permissão extensions.update e auditado é razoável). Evita ter que redefinir toda vez que o operador esquece a senha configurada num softphone.
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
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';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { useToast } from '@/components/ui/toast';
|
|
import { useAuth } from '@/hooks/use-auth';
|
|
import { useDebounce } from '@/hooks/use-debounce';
|
|
import { extensionsService, type ExtensionInput } from '@/services/extensions';
|
|
import type { Extension } from '@/types';
|
|
import { errorMessage } from '@/lib/error-message';
|
|
import { formatDateTime } from '@/lib/utils';
|
|
|
|
function ExtensionFormDialog({
|
|
open,
|
|
onOpenChange,
|
|
extension,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (v: boolean) => void;
|
|
extension: Extension | null;
|
|
}) {
|
|
const isEdit = Boolean(extension);
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
const [form, setForm] = React.useState<ExtensionInput>({
|
|
number: '',
|
|
name: '',
|
|
callerId: '',
|
|
enabled: true,
|
|
});
|
|
const [createdPassword, setCreatedPassword] = React.useState<string | null>(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<string | undefined>(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
|
|
? {
|
|
name: extension.name,
|
|
callerId: extension.callerId ?? '',
|
|
enabled: extension.enabled,
|
|
}
|
|
: { number: '', name: '', callerId: '', enabled: true },
|
|
);
|
|
}
|
|
}, [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: () => {
|
|
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' });
|
|
} else {
|
|
toast({ title: 'Ramal salvo', variant: 'success' });
|
|
onOpenChange(false);
|
|
}
|
|
},
|
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? 'Editar ramal' : 'Novo ramal'}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{createdPassword ? (
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Senha SIP gerada (exibida apenas uma vez, copie agora):
|
|
</p>
|
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-2 font-mono text-sm">
|
|
<span className="flex-1 break-all">{createdPassword}</span>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => navigator.clipboard.writeText(createdPassword)}
|
|
>
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => onOpenChange(false)}>Fechar</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
) : (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
mutation.mutate();
|
|
}}
|
|
className="flex flex-col gap-4"
|
|
>
|
|
{!isEdit && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="number">Número</Label>
|
|
<Input
|
|
id="number"
|
|
required
|
|
value={form.number ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, number: e.target.value }))}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="name">Nome</Label>
|
|
<Input
|
|
id="name"
|
|
required
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="callerId">Caller ID</Label>
|
|
<Input
|
|
id="callerId"
|
|
value={form.callerId ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
|
/>
|
|
</div>
|
|
{isEdit && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="sipPassword">Senha SIP</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="sipPassword"
|
|
type={showPassword ? 'text' : 'password'}
|
|
className="font-mono"
|
|
value={form.sipPassword ?? ''}
|
|
placeholder={passwordQuery.isLoading ? 'Carregando...' : undefined}
|
|
onChange={(e) => setForm((f) => ({ ...f, sipPassword: e.target.value }))}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
>
|
|
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => form.sipPassword && navigator.clipboard.writeText(form.sipPassword)}
|
|
>
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Use isso para configurar o softphone. Editar aqui troca a senha de
|
|
verdade (reaplica no Asterisk ao salvar).
|
|
</p>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button type="submit" loading={mutation.isPending}>
|
|
Salvar
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ResetPasswordDialog({
|
|
result,
|
|
onOpenChange,
|
|
}: {
|
|
result: { number: string; password: string } | null;
|
|
onOpenChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<Dialog open={result !== null} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Senha SIP redefinida — ramal {result?.number}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Exibida apenas uma vez, copie agora:
|
|
</p>
|
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-2 font-mono text-sm">
|
|
<span className="flex-1 break-all">{result?.password}</span>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => result && navigator.clipboard.writeText(result.password)}
|
|
>
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => onOpenChange(false)}>Fechar</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ExtensionsContent() {
|
|
const { can } = useAuth();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [search, setSearch] = React.useState('');
|
|
const debouncedSearch = useDebounce(search);
|
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
|
const [editing, setEditing] = React.useState<Extension | null>(null);
|
|
const [resetResult, setResetResult] = React.useState<{ number: string; password: string } | null>(
|
|
null,
|
|
);
|
|
|
|
const { data, isLoading, isError, refetch } = useQuery({
|
|
queryKey: ['extensions'],
|
|
queryFn: extensionsService.list,
|
|
});
|
|
|
|
const resetPassword = useMutation({
|
|
mutationFn: (id: string) => extensionsService.resetPassword(id),
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
|
setResetResult({ number: result.number, password: result.sipPassword });
|
|
},
|
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: string) => extensionsService.remove(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
|
toast({ title: 'Ramal removido', variant: 'success' });
|
|
},
|
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
|
});
|
|
|
|
const filtered = (data ?? []).filter(
|
|
(e) =>
|
|
e.number.includes(debouncedSearch) ||
|
|
e.name.toLowerCase().includes(debouncedSearch.toLowerCase()),
|
|
);
|
|
|
|
const columns: DataTableColumn<Extension>[] = [
|
|
{ key: 'number', header: 'Ramal', render: (r) => r.number },
|
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
|
{ key: 'context', header: 'Contexto', render: (r) => r.context },
|
|
{
|
|
key: 'enabled',
|
|
header: 'Status',
|
|
render: (r) => (
|
|
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
|
{r.enabled ? 'Ativo' : 'Inativo'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{ key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) },
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
className: 'text-right',
|
|
render: (r) => (
|
|
<div className="flex justify-end gap-1">
|
|
{can('extensions.update') && (
|
|
<>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditing(r);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (confirm(`Redefinir a senha SIP do ramal ${r.number}?`)) {
|
|
resetPassword.mutate(r.id);
|
|
}
|
|
}}
|
|
>
|
|
<KeyRound className="size-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{can('extensions.delete') && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
onClick={() => {
|
|
if (confirm(`Remover o ramal ${r.number}?`)) remove.mutate(r.id);
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Ramais"
|
|
description="Ramais SIP dos agentes (PJSIP)"
|
|
actions={
|
|
can('extensions.create') && (
|
|
<Button
|
|
onClick={() => {
|
|
setEditing(null);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
<Plus /> Novo ramal
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
<DataTable
|
|
columns={columns}
|
|
data={filtered}
|
|
isLoading={isLoading}
|
|
isError={isError}
|
|
onRetry={() => refetch()}
|
|
rowKey={(r) => r.id}
|
|
searchValue={search}
|
|
onSearchChange={setSearch}
|
|
searchPlaceholder="Buscar por número ou nome..."
|
|
emptyMessage="Nenhum ramal cadastrado."
|
|
/>
|
|
<ExtensionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} extension={editing} />
|
|
<ResetPasswordDialog
|
|
result={resetResult}
|
|
onOpenChange={(v) => !v && setResetResult(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ExtensionsPage() {
|
|
return (
|
|
<RequirePermission permission="extensions.view">
|
|
<ExtensionsContent />
|
|
</RequirePermission>
|
|
);
|
|
}
|