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:
co-authored by
Claude Sonnet 4.6
parent
1c2f0ca71a
commit
7783cac3fe
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user