feat: SMS OTP auth, phone verification gate, admin comments/edit/status pages

Backend:
- Add SmsService + SmsModule: send OTP via u-site.app provider, 5-min TTL
- Auth endpoints: POST /auth/send-otp, POST /auth/phone (login by phone+code),
  POST /auth/verify-phone (link), PATCH /auth/change-password
- is_phone_verified included in JWT token response
- GET /comments (admin, JWT-protected) with author/destination names

Admin:
- Users list: link to detail page per row
- User detail: inline edit form (name, city, phone) with PATCH /users/:id
- Services list: link to detail page per row
- Service detail: status change dropdown (PATCH /services/:id/status)
- New Comments page: summary stats + full table with star ratings
- New SMS settings page: configure API key + send test SMS
- Sidebar: added Comments and SMS entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 15:53:32 -05:00
co-authored by Claude Sonnet 4.6
parent 1c2f0ca71a
commit 7783cac3fe
15 changed files with 675 additions and 234 deletions
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { Save, Send, Eye, EyeOff, CheckCircle2, XCircle } from 'lucide-react';
interface SmsConfig {
configured: boolean;
api_key_preview: string;
}
export default function SmsPage() {
const [config, setConfig] = useState<SmsConfig | null>(null);
const [newKey, setNewKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [savingKey, setSavingKey] = useState(false);
const [testPhone, setTestPhone] = useState('');
const [testMessage, setTestMessage] = useState('Hola, este es un mensaje de prueba desde ProsApp.');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; id?: string; error?: string } | null>(null);
const loadConfig = useCallback(() => {
api.get<SmsConfig>('/sms/config')
.then(setConfig)
.catch(() => toast.error('Error al cargar configuración SMS'));
}, []);
useEffect(() => { loadConfig(); }, [loadConfig]);
const saveKey = async () => {
if (!newKey.trim()) return toast.error('Ingresa una API Key');
setSavingKey(true);
try {
await api.patch('/sms/config', { api_key: newKey.trim() });
toast.success('API Key guardada');
setNewKey('');
loadConfig();
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSavingKey(false);
}
};
const sendTest = async () => {
if (!testPhone.trim()) return toast.error('Ingresa un número de teléfono');
if (!testMessage.trim()) return toast.error('Ingresa un mensaje');
setTesting(true);
setTestResult(null);
try {
const result = await api.post<{ ok: boolean; id: string }>('/sms/test', {
numero: testPhone.trim(),
mensaje: testMessage.trim(),
});
setTestResult(result);
toast.success('SMS enviado correctamente');
} catch (e: any) {
setTestResult({ ok: false, error: e?.message || 'Error desconocido' });
toast.error(e?.message || 'Error al enviar SMS');
} finally {
setTesting(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Proveedor SMS</h1>
<p className="text-muted-foreground text-sm mt-1">
Configuración del servicio de envío de mensajes de texto (OTP y notificaciones).
</p>
</div>
{/* Estado */}
<Card>
<CardHeader>
<CardTitle className="text-base">Estado del proveedor</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3">
{config === null ? (
<span className="text-muted-foreground text-sm">Cargando...</span>
) : config.configured ? (
<>
<CheckCircle2 className="text-green-500 h-5 w-5" />
<span className="text-sm font-medium">Configurado</span>
<Badge variant="secondary" className="font-mono text-xs">
{config.api_key_preview}
</Badge>
</>
) : (
<>
<XCircle className="text-destructive h-5 w-5" />
<span className="text-sm font-medium text-destructive">No configurado</span>
</>
)}
</CardContent>
</Card>
{/* Configurar API Key */}
<Card>
<CardHeader>
<CardTitle className="text-base">API Key</CardTitle>
<CardDescription>
Encuentra tu API Key en la sección <strong>API Key (envío externo)</strong> del panel de U-Site.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder="sms_xxxxxxxxxxxxxxxxxxxx"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 font-mono"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowKey(!showKey)}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button onClick={saveKey} disabled={savingKey || !newKey.trim()}>
<Save className="mr-1 h-4 w-4" />
{savingKey ? 'Guardando...' : 'Guardar'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
La clave se almacena cifrada. Si ya tienes una configurada, ingresa una nueva para reemplazarla.
</p>
</CardContent>
</Card>
{/* Prueba de envío */}
<Card>
<CardHeader>
<CardTitle className="text-base">Probar envío</CardTitle>
<CardDescription>
Envía un SMS de prueba para verificar que la configuración funciona correctamente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Número de teléfono</label>
<Input
placeholder="573001234567"
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Formato internacional sin + (ej: 573001234567 para Colombia)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Mensaje</label>
<Input
placeholder="Hola, este es un mensaje de prueba"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value.slice(0, 160))}
/>
<p className="text-xs text-muted-foreground">
{testMessage.length}/160 caracteres
</p>
</div>
<Button onClick={sendTest} disabled={testing || !config?.configured}>
<Send className="mr-1 h-4 w-4" />
{testing ? 'Enviando...' : 'Enviar SMS de prueba'}
</Button>
{!config?.configured && (
<p className="text-xs text-destructive">Debes configurar la API Key antes de probar.</p>
)}
{testResult && (
<div
className={`rounded-md border p-3 text-sm font-mono ${
testResult.ok
? 'border-green-200 bg-green-50 text-green-800'
: 'border-red-200 bg-red-50 text-red-800'
}`}
>
{testResult.ok ? (
<span> Enviado ID: {testResult.id}</span>
) : (
<span> Error: {testResult.error}</span>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}