Files
Spanglish/frontend/src/app/(public)/dashboard/components/AccountTab.tsx
T
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00

536 lines
20 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
dashboardApi,
authApi,
UserProfile,
UserSession,
} from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface AccountTabProps {
onUpdate?: () => void;
}
/**
* Merged Profile + Security tab: account info, edit-profile form, authentication
* methods, change/set password, active sessions and account deletion.
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
setFormData({
name: profileRes.profile.name || '',
phone: profileRes.profile.phone || '',
languagePreference: profileRes.profile.languagePreference || '',
rucNumber: profileRes.profile.rucNumber || '',
});
} catch (error) {
toast.error(locale === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await dashboardApi.updateProfile(formData);
toast.success(locale === 'es' ? 'Perfil actualizado' : 'Profile updated');
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
onUpdate?.();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(locale === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password')
);
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(locale === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData();
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al establecer contraseña' : 'Failed to set password')
);
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (
!confirm(
locale === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)
)
return;
try {
await dashboardApi.unlinkGoogle();
toast.success(locale === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
toast.success(locale === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const formatDateTime = (dateStr: string) =>
parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
);
}
const isActive = profile?.accountStatus === 'active';
return (
<div className="max-w-2xl space-y-6">
{/* Account info */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Información de la cuenta' : 'Account information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Correo' : 'Email'}</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex items-center justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Estado' : 'Status'}</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
isActive ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'
}`}
>
{isActive
? locale === 'es'
? 'Activa'
: 'Active'
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">{locale === 'es' ? 'Miembro desde' : 'Member since'}</span>
<span className="font-medium">
{profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString(
locale === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)
: '-'}
</span>
</div>
</div>
</Card>
{/* Edit profile */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Editar perfil' : 'Edit profile'}
</h3>
<form onSubmit={handleSaveProfile} className="space-y-4">
<Input
id="name"
label={locale === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
type="tel"
label={locale === 'es' ? 'Teléfono' : 'Phone'}
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="mb-1.5 block text-sm font-medium text-primary-dark">
{locale === 'es' ? 'Idioma preferido' : 'Preferred language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full rounded-btn border border-secondary-light-gray px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">{locale === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div>
<Input
id="rucNumber"
label={locale === 'es' ? 'Número de RUC (para facturas)' : 'RUC number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={locale === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
</div>
<div className="pt-2">
<Button type="submit" isLoading={saving}>
{locale === 'es' ? 'Guardar cambios' : 'Save changes'}
</Button>
</div>
</form>
<p className="mt-4 border-t pt-4 text-xs text-gray-500">
{locale === 'es'
? 'Para cambiar tu correo, contacta al soporte.'
: 'To change your email, please contact support.'}
</p>
</Card>
{/* Authentication methods */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">{locale === 'es' ? 'Contraseña' : 'Password'}</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? locale === 'es' ? 'Configurada' : 'Set'
: locale === 'es' ? 'No configurada' : 'Not set'}
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? locale === 'es' ? 'Vinculado' : 'Linked'
: locale === 'es' ? 'No vinculado' : 'Not linked'}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{locale === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password management */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{profile?.hasPassword
? locale === 'es' ? 'Cambiar contraseña' : 'Change password'
: locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={locale === 'es' ? 'Contraseña actual' : 'Current password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<div>
<Input
id="newPassword"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{locale === 'es' ? 'Cambiar contraseña' : 'Change password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="mb-2 text-sm text-gray-600">
{locale === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<div>
<Input
id="password"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmNewPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</Button>
</form>
)}
</Card>
{/* Active sessions */}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">
{locale === 'es' ? 'Sesiones activas' : 'Active sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{locale === 'es' ? 'Cerrar todas' : 'Log out all'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '')
: locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'}
</p>
<p className="text-xs text-gray-500">
{locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '}
{formatDateTime(session.lastActiveAt)}
{session.ipAddress && ` • ${session.ipAddress}`}
</p>
</div>
{session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
) : (
<Button variant="outline" size="sm" onClick={() => handleRevokeSession(session.id)}>
{locale === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger zone */}
<Card className="border border-red-200 bg-red-50 p-6">
<h3 className="mb-2 text-lg font-semibold text-red-800">
{locale === 'es' ? 'Zona de peligro' : 'Danger zone'}
</h3>
<p className="mb-4 text-sm text-red-700">
{locale === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button
variant="outline"
size="sm"
className="border-red-300 text-red-700 hover:bg-red-100"
disabled
>
{locale === 'es' ? 'Eliminar cuenta' : 'Delete account'}
</Button>
</Card>
</div>
);
}