Ir al contenido

Sistema que obliga a los usuarios a cambiar su contraseña cuando es establecida por un administrador.

  1. Admin crea usuario nuevo → Usuario debe cambiar contraseña en primer login
  2. Admin cambia contraseña de usuario → Usuario debe cambiar contraseña en próximo login
  3. Usuario cambia su propia contraseña → Flag se limpia automáticamente

Cuando un admin crea un usuario o cambia su contraseña desde el panel de administración:

// En updateUserByAdmin operation
if (newPassword) {
// ... actualiza password ...
// Marca que el usuario debe cambiar su contraseña
passwordWasChanged = true;
}
// Al final de la operación
data: {
...updateData,
...(passwordWasChanged && { mustChangePassword: true }),
}

Al iniciar sesión, si mustChangePassword = true, aparece un modal no-cerrable que:

  • ❌ No se puede cerrar (sin botón X, sin click fuera, sin ESC)
  • 🔒 Bloquea toda la interfaz
  • 📝 Muestra formulario de cambio de contraseña
  • ⚠️ Muestra alerta roja explicando la situación
// En App.tsx
const mustChangePassword = user?.mustChangePassword === true;
return (
<>
<ForcePasswordChangeModal open={mustChangePassword} />
{/* Resto de la app */}
</>
);

El modal contiene un formulario con:

  • Contraseña actual
  • Nueva contraseña
  • Confirmar nueva contraseña
  • Botones show/hide para cada campo

Al enviar:

// En ForcePasswordChangeModal
await changePassword({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
});
// Después de 1.5 segundos, recarga la página
setTimeout(() => {
window.location.reload();
}, 1500);
// En changePassword operation
await updateAuthIdentityProviderData(providerId, providerData, {
hashedPassword: args.newPassword,
});
// Limpia el flag mustChangePassword
await context.entities.User.update({
where: { id: context.user!.id },
data: { mustChangePassword: false },
});
  • La página recarga con el nuevo estado del usuario
  • mustChangePassword = false
  • Modal desaparece
  • Usuario tiene acceso completo a la aplicación
  • Permanece en la misma página donde estaba
model User {
id String @id @default(uuid())
email String? @unique
username String? @unique
isAdmin Boolean @default(false)
mustChangePassword Boolean @default(false) // 👈 Flag nuevo
// ... otros campos
}

Modal React que aparece cuando mustChangePassword = true.

Props:

  • open: boolean - Controla si el modal está visible

Características:

  • No se puede cerrar manualmente
  • Bloquea eventos de teclado (ESC) y mouse (click fuera)
  • Muestra alerta destructiva roja
  • Formulario con validación Zod
  • Botones show/hide para contraseñas
  • Recarga automática después del cambio

Ubicación: src/user/components/ForcePasswordChangeModal.tsx

import { ForcePasswordChangeModal } from "../user/components/ForcePasswordChangeModal";
function AppContent() {
const { data: user } = useAuth();
const mustChangePassword = user?.mustChangePassword === true;
return (
<>
<ForcePasswordChangeModal open={mustChangePassword} />
{/* Resto de la app */}
</>
);
}

Al crear un usuario, establece mustChangePassword: true por defecto:

const userFields: Partial<User> = {
email: args.email,
username: args.username,
isAdmin: args.isAdmin ?? false,
mustChangePassword: true, // 👈 Fuerza cambio en primer login
subscriptionStatus: args.subscriptionStatus ?? null,
subscriptionPlan: args.subscriptionPlan ?? null,
};

Si el admin cambia la contraseña, establece mustChangePassword: true:

if (newPassword) {
// ... actualiza AuthIdentity ...
passwordWasChanged = true;
}
return context.entities.User.update({
where: { id },
data: {
...updateData,
...(passwordWasChanged && { mustChangePassword: true }),
},
});

Cuando el usuario cambia su propia contraseña, limpia el flag:

await updateAuthIdentityProviderData(providerId, providerData, {
hashedPassword: args.newPassword,
});
await context.entities.User.update({
where: { id: context.user!.id },
data: { mustChangePassword: false }, // 👈 Limpia el flag
});
const passwordFormSchema = z
.object({
currentPassword: z.string().min(1, "La contraseña actual es requerida"),
newPassword: z.string().min(8, "La contraseña debe tener al menos 8 caracteres"),
confirmPassword: z.string().min(1, "Por favor confirma tu contraseña"),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
})
.refine(data => data.currentPassword !== data.newPassword, {
message: "La nueva contraseña debe ser diferente de la actual",
path: ["newPassword"],
});
<Input
type={showPassword ? "text" : "password"}
autoComplete="new-password" // 👈 Previene autofill
{...field}
/>

Antes de cambiar, se verifica que la contraseña actual sea correcta:

const isPasswordCorrect = await verify(providerData.hashedPassword, args.currentPassword);
if (!isPasswordCorrect) {
throw new HttpError(400, "Current password is incorrect");
}

Ver Guía de Tests E2E para información sobre cómo probar este feature.

  1. Hacer login como admin
  2. Ir a /admin/users
  3. Cambiar contraseña de un usuario
  4. Logout
  5. Login con ese usuario
  6. ✅ Debería aparecer el modal forzoso
  7. Cambiar contraseña
  8. ✅ Modal desaparece y app funciona normal

Todas las operaciones de cambio de contraseña se registran:

// Cuando admin cambia password
console.log(`Admin ${context.user.id} changed password for user ${id}`);
// Cuando admin crea usuario
console.log(`Admin ${context.user.id} created user ${newUser.id}`, {
email: args.email,
isAdmin: args.isAdmin,
});

Causa: El flag mustChangePassword no está en true

Solución: Verificar en la base de datos:

SELECT email, "mustChangePassword" FROM "User" WHERE email = 'usuario@ejemplo.com';
Sección titulada «Modal no se cierra después de cambiar contraseña»

Causa: La operación changePassword falló o no limpió el flag

Solución:

  • Verificar logs del servidor
  • Verificar que la contraseña actual sea correcta
  • Verificar que la nueva contraseña cumpla requisitos mínimos

Causa: Usuario fue creado de forma incorrecta (sin AuthIdentity)

Solución: El código ya maneja esto automáticamente, creando la AuthIdentity si no existe:

if (!authIdentity) {
// Crea Auth record si no existe
let auth = await prisma.auth.findUnique({ where: { userId: id } });
if (!auth) {
auth = await prisma.auth.create({ data: { userId: id } });
}
// Crea AuthIdentity
await prisma.authIdentity.create({
data: {
providerName: "email",
providerUserId: email,
providerData: JSON.stringify(providerData),
authId: auth.id,
},
});
}

Ideas para mejorar este feature:

  • Email notification cuando admin cambia contraseña
  • Historial de cambios de contraseña
  • Forzar cambio de contraseña periódico (ej: cada 90 días)
  • Política de contraseñas más estricta (mayúsculas, números, símbolos)
  • Prevenir reutilización de contraseñas anteriores
  • Bloqueo temporal después de X intentos fallidos