Add password setup flow with server-side validation
Build And Push Image / docker (push) Successful in 3m2s Details

- Replace external password setup link with internal navigation
- Add comprehensive password validation utility with strength requirements
- Create dedicated password setup page and API endpoint
- Streamline user flow from email verification to password creation
This commit is contained in:
Matt 2025-08-09 19:11:54 +02:00
parent 30b7e23319
commit d14008efd4
4 changed files with 635 additions and 15 deletions

View File

@ -0,0 +1,410 @@
<template>
<div :class="containerClasses">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<div class="mb-6">
<v-icon
color="primary"
size="80"
class="mb-4"
>
mdi-lock-plus
</v-icon>
<h1 class="text-h4 font-weight-bold text-primary mb-3">
Set Your Password
</h1>
<p class="text-body-1 text-medium-emphasis mb-2" v-if="email">
Complete your registration by setting a secure password for <strong>{{ email }}</strong>
</p>
<p class="text-body-1 text-medium-emphasis">
Choose a strong password to secure your MonacoUSA Portal account.
</p>
</div>
<!-- Password Setup Form -->
<v-form ref="formRef" v-model="formValid" @submit.prevent="setupPassword">
<v-text-field
v-model="password"
:type="showPassword ? 'text' : 'password'"
label="New Password"
variant="outlined"
density="comfortable"
:rules="passwordRules"
:error="!!errorMessage"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showPassword = !showPassword"
class="mb-3"
/>
<v-text-field
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
label="Confirm Password"
variant="outlined"
density="comfortable"
:rules="confirmPasswordRules"
:error="!!errorMessage"
:append-inner-icon="showConfirmPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showConfirmPassword = !showConfirmPassword"
class="mb-4"
/>
<!-- Password Strength Indicator -->
<v-progress-linear
:model-value="passwordStrength"
:color="passwordStrengthColor"
height="6"
class="mb-2"
/>
<p class="text-caption text-medium-emphasis mb-4">
Password strength: {{ passwordStrengthLabel }}
</p>
<!-- Error Alert -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4 text-start"
icon="mdi-alert"
>
{{ errorMessage }}
</v-alert>
<!-- Success Alert -->
<v-alert
v-if="successMessage"
type="success"
variant="tonal"
class="mb-4 text-start"
icon="mdi-check-circle"
>
{{ successMessage }}
</v-alert>
<div class="d-flex flex-column gap-3">
<v-btn
type="submit"
color="primary"
size="large"
variant="elevated"
block
:loading="loading"
:disabled="!formValid || loading"
class="text-none"
>
<v-icon start>mdi-check</v-icon>
Set Password & Continue
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
:to="{ path: '/login' }"
:disabled="loading"
class="text-none"
>
<v-icon start>mdi-login</v-icon>
I Already Have a Password
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
:disabled="loading"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
</v-form>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Need help? Contact support at:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
import {
getOptimizedClasses,
applyMobileSafariFixes
} from '~/utils/mobile-safari-utils';
definePageMeta({
layout: false,
middleware: 'guest'
});
// Mobile Safari optimization classes
const containerClasses = computed(() => [
'password-setup-page',
...getOptimizedClasses()
].join(' '));
// Reactive state
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const formValid = ref(false);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
// Form data
const password = ref('');
const confirmPassword = ref('');
// Get query parameters
const route = useRoute();
const email = computed(() => route.query.email as string || '');
const token = computed(() => route.query.token as string || '');
// Form ref
const formRef = ref();
// Password strength calculation
const passwordStrength = computed(() => {
if (!password.value) return 0;
let score = 0;
// Length
if (password.value.length >= 8) score += 20;
if (password.value.length >= 12) score += 10;
// Character types
if (/[a-z]/.test(password.value)) score += 15;
if (/[A-Z]/.test(password.value)) score += 15;
if (/[0-9]/.test(password.value)) score += 15;
if (/[^A-Za-z0-9]/.test(password.value)) score += 25;
return Math.min(score, 100);
});
const passwordStrengthColor = computed(() => {
if (passwordStrength.value < 40) return 'error';
if (passwordStrength.value < 70) return 'warning';
return 'success';
});
const passwordStrengthLabel = computed(() => {
if (passwordStrength.value < 40) return 'Weak';
if (passwordStrength.value < 70) return 'Good';
return 'Strong';
});
// Validation rules
const passwordRules = [
(v: string) => !!v || 'Password is required',
(v: string) => v.length >= 8 || 'Password must be at least 8 characters',
(v: string) => /[A-Z]/.test(v) || 'Password must contain at least one uppercase letter',
(v: string) => /[a-z]/.test(v) || 'Password must contain at least one lowercase letter',
(v: string) => /[0-9]/.test(v) || 'Password must contain at least one number',
];
const confirmPasswordRules = [
(v: string) => !!v || 'Please confirm your password',
(v: string) => v === password.value || 'Passwords do not match',
];
// Set page title with mobile viewport optimization
useHead({
title: 'Set Your Password - MonacoUSA Portal',
meta: [
{
name: 'description',
content: 'Set your password to complete your MonacoUSA Portal registration.'
},
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }
]
});
// Setup password function
const setupPassword = async () => {
if (!formValid.value) return;
if (!email.value) {
errorMessage.value = 'No email address provided. Please check the link from your email.';
return;
}
try {
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
// Call our password setup API
const response = await $fetch('/api/auth/setup-password', {
method: 'POST',
body: {
email: email.value,
password: password.value,
token: token.value
}
});
console.log('[setup-password] Password setup successful:', response);
successMessage.value = 'Password set successfully! Redirecting to login...';
// Wait a moment to show success message, then redirect
setTimeout(() => {
navigateTo({
path: '/login',
query: { email: email.value, passwordSet: 'true' }
});
}, 2000);
} catch (err: any) {
console.error('[setup-password] Password setup failed:', err);
if (err.statusCode === 400) {
errorMessage.value = 'Invalid request. Please check your information and try again.';
} else if (err.statusCode === 404) {
errorMessage.value = 'User not found. The link may be invalid or expired.';
} else if (err.statusCode === 409) {
errorMessage.value = 'Password has already been set. You can log in with your existing password.';
} else if (err.statusCode === 422) {
errorMessage.value = 'Password does not meet security requirements. Please choose a stronger password.';
} else {
errorMessage.value = err.message || 'Failed to set password. Please try again or contact support.';
}
loading.value = false;
}
};
// Apply mobile Safari fixes
onMounted(() => {
// Apply mobile Safari fixes
if (typeof window !== 'undefined') {
applyMobileSafariFixes();
}
console.log('[setup-password] Password setup page loaded for:', email.value);
// Check if we have required parameters
if (!email.value) {
errorMessage.value = 'No email address provided. Please use the link from your verification email.';
}
});
</script>
<style scoped>
.password-setup-page {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow-x: hidden; /* Prevent horizontal scroll on mobile */
}
/* Mobile Safari optimizations */
.password-setup-page.is-mobile-safari {
min-height: 100vh;
min-height: -webkit-fill-available;
}
.password-setup-page.performance-mode {
will-change: auto;
transform: translateZ(0); /* Lighter hardware acceleration */
}
.fill-height {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
}
/* Mobile Safari fill-height optimization */
.is-mobile-safari .fill-height {
min-height: -webkit-fill-available;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(163, 21, 21, 0.5);
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.password-setup-page {
padding: 16px;
}
.v-card {
margin: 0;
}
/* Optimize button spacing on mobile */
.gap-3 {
gap: 8px;
}
}
/* Improve touch targets on mobile */
@media (hover: none) and (pointer: coarse) {
.v-btn {
min-height: 48px; /* Ensure touch-friendly button size */
}
}
/* Performance mode optimizations */
.performance-mode .v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; /* Lighter shadow */
}
.performance-mode .v-btn {
transition: none; /* Remove button transitions for better performance */
}
/* Form styling improvements */
.v-text-field {
margin-bottom: 8px;
}
.v-progress-linear {
border-radius: 3px;
}
</style>

View File

@ -49,26 +49,13 @@
size="large"
variant="elevated"
block
:href="setupPasswordUrl"
target="_blank"
@click="goToPasswordSetup"
class="text-none"
>
<v-icon start>mdi-lock</v-icon>
<v-icon start>mdi-lock-plus</v-icon>
Set Your Password
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
:to="{ path: '/login', query: { verified: 'true' } }"
class="text-none"
>
<v-icon start>mdi-login</v-icon>
Log In to Portal
</v-btn>
<v-btn
color="outline"
size="small"
@ -147,6 +134,16 @@ useHead({
]
});
// Go to password setup page
const goToPasswordSetup = () => {
navigateTo({
path: '/auth/setup-password',
query: {
email: email.value
}
});
};
// Apply mobile Safari fixes and track verification
onMounted(() => {
// Apply mobile Safari fixes

View File

@ -0,0 +1,160 @@
/**
* Password Setup API Endpoint
* Handles setting passwords for newly registered users
*/
import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin';
import { validatePassword } from '~/server/utils/security';
interface SetupPasswordRequest {
email: string;
password: string;
token?: string;
}
export default defineEventHandler(async (event) => {
console.log('[api/auth/setup-password] =========================');
console.log('[api/auth/setup-password] POST /api/auth/setup-password - Password setup');
try {
const body = await readBody(event) as SetupPasswordRequest;
console.log('[api/auth/setup-password] Setup password attempt for:', body.email);
// 1. Validate request data
if (!body.email?.trim()) {
throw createError({
statusCode: 400,
statusMessage: 'Email address is required'
});
}
if (!body.password?.trim()) {
throw createError({
statusCode: 400,
statusMessage: 'Password is required'
});
}
// 2. Validate password strength
const passwordValidation = validatePassword(body.password);
if (!passwordValidation.isValid) {
throw createError({
statusCode: 422,
statusMessage: `Password validation failed: ${passwordValidation.errors.join(', ')}`
});
}
// 3. Find user in Keycloak
const keycloakAdmin = createKeycloakAdminClient();
const existingUsers = await keycloakAdmin.findUserByEmail(body.email);
if (existingUsers.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'User not found. Please register first or contact support.'
});
}
const user = existingUsers[0];
// 4. Check if user already has a password set by checking if they have any required actions
console.log('[api/auth/setup-password] User found:', user.id, 'Required actions:', user.requiredActions);
if (user.requiredActions && !user.requiredActions.includes('UPDATE_PASSWORD')) {
console.log('[api/auth/setup-password] User already has password set, allowing password update');
// Allow password updates - this could be a password reset scenario
}
// 5. Set the user's password in Keycloak using direct REST API
console.log('[api/auth/setup-password] Setting password for user:', user.id);
const adminToken = await keycloakAdmin.getAdminToken();
const config = useRuntimeConfig();
const adminBaseUrl = config.keycloak.issuer.replace('/realms/', '/admin/realms/');
// Set password using Keycloak Admin REST API
const setPasswordResponse = await fetch(`${adminBaseUrl}/users/${user.id}/reset-password`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify({
type: 'password',
value: body.password,
temporary: false
})
});
if (!setPasswordResponse.ok) {
const errorText = await setPasswordResponse.text().catch(() => 'Unknown error');
throw createError({
statusCode: setPasswordResponse.status,
statusMessage: `Failed to set password: ${errorText}`
});
}
// 6. Update user to ensure they're enabled, email is verified, and remove required actions
const updateUserResponse = await fetch(`${adminBaseUrl}/users/${user.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify({
...user,
enabled: true,
emailVerified: true,
requiredActions: [], // Remove all required actions including UPDATE_PASSWORD
attributes: {
...user.attributes,
needsPasswordSetup: ['false'],
passwordSetAt: [new Date().toISOString()]
}
})
});
if (!updateUserResponse.ok) {
const errorText = await updateUserResponse.text().catch(() => 'Unknown error');
console.warn('[api/auth/setup-password] Failed to update user profile:', errorText);
// Don't fail the entire operation if this update fails
}
console.log(`[api/auth/setup-password] ✅ Password setup successful for user: ${body.email}`);
return {
success: true,
message: 'Password set successfully! You can now log in to your account.',
data: {
email: body.email,
passwordSet: true,
canLogin: true
}
};
} catch (error: any) {
console.error('[api/auth/setup-password] ❌ Password setup failed:', error);
// Handle Keycloak specific errors
if (error.response?.status === 404) {
throw createError({
statusCode: 404,
statusMessage: 'User not found. Please register first or contact support.'
});
} else if (error.response?.status === 409) {
throw createError({
statusCode: 409,
statusMessage: 'Password has already been set. You can log in with your existing password.'
});
} else if (error.response?.status === 400) {
throw createError({
statusCode: 422,
statusMessage: 'Password does not meet Keycloak security requirements. Please choose a stronger password.'
});
}
throw error;
}
});

View File

@ -128,6 +128,59 @@ export const cleanupOldEntries = (): void => {
console.log('🧹 Cleaned up old security entries');
};
// Password validation function
export const validatePassword = (password: string): { isValid: boolean; errors: string[] } => {
const errors: string[] = [];
if (!password || typeof password !== 'string') {
errors.push('Password is required');
return { isValid: false, errors };
}
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (password.length > 128) {
errors.push('Password must not exceed 128 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
// Optional: require special characters
// if (!/[^A-Za-z0-9]/.test(password)) {
// errors.push('Password must contain at least one special character');
// }
// Check for common weak patterns
const commonPatterns = [
/(.)\1{2,}/i, // Three or more consecutive identical characters
/123456|654321|abcdef|qwerty|password|admin|login/i, // Common weak passwords
];
for (const pattern of commonPatterns) {
if (pattern.test(password)) {
errors.push('Password contains common patterns that make it weak');
break;
}
}
return {
isValid: errors.length === 0,
errors
};
};
// Initialize cleanup interval (runs every 5 minutes)
if (typeof setInterval !== 'undefined') {
setInterval(cleanupOldEntries, 5 * 60 * 1000);