Self-service password reset
This commit is contained in:
15
README.md
15
README.md
@@ -33,6 +33,11 @@ This application uses environment variables configured directly in `docker-compo
|
|||||||
| `AUTH_SECRET` | Secret key for authentication sessions | `change-this-secret-in-production-please` |
|
| `AUTH_SECRET` | Secret key for authentication sessions | `change-this-secret-in-production-please` |
|
||||||
| `ADMIN_USERNAME` | Initial admin login username | `admin` |
|
| `ADMIN_USERNAME` | Initial admin login username | `admin` |
|
||||||
| `ADMIN_PASSWORD` | Initial admin login password | `Admin123!` |
|
| `ADMIN_PASSWORD` | Initial admin login password | `Admin123!` |
|
||||||
|
| `EMAIL_HOST` | SMTP server hostname | `smtp.example.com` |
|
||||||
|
| `EMAIL_PORT` | SMTP server port | `587` |
|
||||||
|
| `EMAIL_USER` | SMTP authentication username | `noreply@example.com` |
|
||||||
|
| `EMAIL_PASSWORD` | SMTP authentication password | `your-email-password` |
|
||||||
|
| `EMAIL_FROM` | Email sender address and name | `New Life Christian Church <noreply@example.com>` |
|
||||||
|
|
||||||
### Customizing Configuration
|
### Customizing Configuration
|
||||||
|
|
||||||
@@ -47,11 +52,21 @@ services:
|
|||||||
- AUTH_SECRET=your-secure-random-secret-here
|
- AUTH_SECRET=your-secure-random-secret-here
|
||||||
- ADMIN_USERNAME=your-admin-username
|
- ADMIN_USERNAME=your-admin-username
|
||||||
- ADMIN_PASSWORD=your-secure-password
|
- ADMIN_PASSWORD=your-secure-password
|
||||||
|
- EMAIL_HOST=smtp.gmail.com
|
||||||
|
- EMAIL_PORT=587
|
||||||
|
- EMAIL_USER=your-email@gmail.com
|
||||||
|
- EMAIL_PASSWORD=your-app-password
|
||||||
|
- EMAIL_FROM=Your Church Name <your-email@gmail.com>
|
||||||
environment:
|
environment:
|
||||||
- SITE_URL=https://your-church-domain.com
|
- SITE_URL=https://your-church-domain.com
|
||||||
- AUTH_SECRET=your-secure-random-secret-here
|
- AUTH_SECRET=your-secure-random-secret-here
|
||||||
- ADMIN_USERNAME=your-admin-username
|
- ADMIN_USERNAME=your-admin-username
|
||||||
- ADMIN_PASSWORD=your-secure-password
|
- ADMIN_PASSWORD=your-secure-password
|
||||||
|
- EMAIL_HOST=smtp.gmail.com
|
||||||
|
- EMAIL_PORT=587
|
||||||
|
- EMAIL_USER=your-email@gmail.com
|
||||||
|
- EMAIL_PASSWORD=your-app-password
|
||||||
|
- EMAIL_FROM=Your Church Name <your-email@gmail.com>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Generate a secure AUTH_SECRET:**
|
**Generate a secure AUTH_SECRET:**
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ services:
|
|||||||
- AUTH_SECRET=change-this-secret-in-production-please
|
- AUTH_SECRET=change-this-secret-in-production-please
|
||||||
- ADMIN_USERNAME=admin
|
- ADMIN_USERNAME=admin
|
||||||
- ADMIN_PASSWORD=Admin123!
|
- ADMIN_PASSWORD=Admin123!
|
||||||
|
- EMAIL_HOST=smtp.example.com
|
||||||
|
- EMAIL_PORT=587
|
||||||
|
- EMAIL_USER=noreply@example.com
|
||||||
|
- EMAIL_PASSWORD=your-email-password
|
||||||
|
- EMAIL_FROM=New Life Christian Church <noreply@example.com>
|
||||||
container_name: nlcc-itinerary
|
container_name: nlcc-itinerary
|
||||||
ports:
|
ports:
|
||||||
- "3002:3000"
|
- "3002:3000"
|
||||||
@@ -18,4 +23,9 @@ services:
|
|||||||
- SITE_URL=https://nlcc.rydertech.us
|
- SITE_URL=https://nlcc.rydertech.us
|
||||||
- ADMIN_USERNAME=admin
|
- ADMIN_USERNAME=admin
|
||||||
- ADMIN_PASSWORD=Admin123!
|
- ADMIN_PASSWORD=Admin123!
|
||||||
|
- EMAIL_HOST=smtp.example.com
|
||||||
|
- EMAIL_PORT=587
|
||||||
|
- EMAIL_USER=noreply@example.com
|
||||||
|
- EMAIL_PASSWORD=your-email-password
|
||||||
|
- EMAIL_FROM=New Life Christian Church <noreply@example.com>
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export default defineNuxtConfig({
|
|||||||
authSecret: process.env.AUTH_SECRET || 'change-this-secret-in-production',
|
authSecret: process.env.AUTH_SECRET || 'change-this-secret-in-production',
|
||||||
adminUsername: process.env.ADMIN_USERNAME || 'admin',
|
adminUsername: process.env.ADMIN_USERNAME || 'admin',
|
||||||
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
|
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
|
||||||
|
emailHost: process.env.EMAIL_HOST || 'smtp.example.com',
|
||||||
|
emailPort: process.env.EMAIL_PORT || '587',
|
||||||
|
emailUser: process.env.EMAIL_USER || 'noreply@example.com',
|
||||||
|
emailPassword: process.env.EMAIL_PASSWORD || 'your-email-password',
|
||||||
|
emailFrom: process.env.EMAIL_FROM || 'New Life Christian Church <noreply@example.com>',
|
||||||
public: {
|
public: {
|
||||||
siteUrl: process.env.SITE_URL || 'https://newlife-christian.com'
|
siteUrl: process.env.SITE_URL || 'https://newlife-christian.com'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
"nuxt": "^3.13.2",
|
"nuxt": "^3.13.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.5",
|
"vue": "^3.5.5",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@nuxtjs/tailwindcss": "^6.12.1",
|
"@nuxtjs/tailwindcss": "^6.12.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/qrcode": "^1.5.5"
|
"@types/qrcode": "^1.5.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
305
pages/forgot-password.vue
Normal file
305
pages/forgot-password.vue
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
<div class="flex-1 flex items-center justify-center px-4">
|
||||||
|
<div class="max-w-md w-full">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-20 w-auto mx-auto mb-4" />
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">Reset Password</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">{{ stepMessage }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white py-8 px-6 shadow-lg rounded-lg">
|
||||||
|
<!-- Step 1: Enter Email -->
|
||||||
|
<form v-if="step === 1" @submit.prevent="sendResetCode" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="text-green-600 text-sm">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Sending...' : 'Send Reset Code' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: Enter Code -->
|
||||||
|
<form v-if="step === 2" @submit.prevent="verifyCode" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="code" class="block text-sm font-medium text-gray-700">
|
||||||
|
6-Digit Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
v-model="code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
placeholder="000000"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-center text-2xl tracking-widest"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Enter the 6-digit code sent to {{ email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || code.length !== 6"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Verifying...' : 'Verify Code' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 3: Reset Password -->
|
||||||
|
<form v-if="step === 3" @submit.prevent="resetPassword" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="newPassword" class="block text-sm font-medium text-gray-700">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password Requirements -->
|
||||||
|
<div v-if="newPassword.length > 0" class="mt-2 space-y-1 text-xs">
|
||||||
|
<div :class="passwordRequirements.minLength ? 'text-green-600' : 'text-gray-500'">
|
||||||
|
<span v-if="passwordRequirements.minLength">✓</span>
|
||||||
|
<span v-else>○</span>
|
||||||
|
At least 8 characters
|
||||||
|
</div>
|
||||||
|
<div :class="passwordRequirements.hasUppercase ? 'text-green-600' : 'text-gray-500'">
|
||||||
|
<span v-if="passwordRequirements.hasUppercase">✓</span>
|
||||||
|
<span v-else>○</span>
|
||||||
|
One uppercase letter
|
||||||
|
</div>
|
||||||
|
<div :class="passwordRequirements.hasLowercase ? 'text-green-600' : 'text-gray-500'">
|
||||||
|
<span v-if="passwordRequirements.hasLowercase">✓</span>
|
||||||
|
<span v-else>○</span>
|
||||||
|
One lowercase letter
|
||||||
|
</div>
|
||||||
|
<div :class="passwordRequirements.hasNumberOrSymbol ? 'text-green-600' : 'text-gray-500'">
|
||||||
|
<span v-if="passwordRequirements.hasNumberOrSymbol">✓</span>
|
||||||
|
<span v-else>○</span>
|
||||||
|
One number or symbol
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmNewPassword"
|
||||||
|
v-model="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password Match Indicator -->
|
||||||
|
<div v-if="confirmNewPassword.length > 0" class="mt-2 text-xs">
|
||||||
|
<div :class="passwordsMatch ? 'text-green-600' : 'text-red-600'">
|
||||||
|
<span v-if="passwordsMatch">✓ Passwords match</span>
|
||||||
|
<span v-else>✗ Passwords do not match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !isPasswordValid || !passwordsMatch"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Resetting...' : 'Reset Password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center space-y-3">
|
||||||
|
<button
|
||||||
|
v-if="step > 1"
|
||||||
|
@click="goBack"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
← Go Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/login" class="text-sm text-blue-600 hover:text-blue-700">
|
||||||
|
Back to Login
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const email = ref('')
|
||||||
|
const code = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmNewPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const step = ref(1)
|
||||||
|
|
||||||
|
const stepMessage = computed(() => {
|
||||||
|
switch (step.value) {
|
||||||
|
case 1:
|
||||||
|
return 'Enter your email to receive a reset code'
|
||||||
|
case 2:
|
||||||
|
return 'Enter the code sent to your email'
|
||||||
|
case 3:
|
||||||
|
return 'Create a new password'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordRequirements = computed(() => ({
|
||||||
|
minLength: newPassword.value.length >= 8,
|
||||||
|
hasUppercase: /[A-Z]/.test(newPassword.value),
|
||||||
|
hasLowercase: /[a-z]/.test(newPassword.value),
|
||||||
|
hasNumberOrSymbol: /[0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword.value)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isPasswordValid = computed(() => {
|
||||||
|
return passwordRequirements.value.minLength &&
|
||||||
|
passwordRequirements.value.hasUppercase &&
|
||||||
|
passwordRequirements.value.hasLowercase &&
|
||||||
|
passwordRequirements.value.hasNumberOrSymbol
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordsMatch = computed(() => {
|
||||||
|
return newPassword.value === confirmNewPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (step.value > 1) {
|
||||||
|
step.value--
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResetCode() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: email.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
success.value = response.message
|
||||||
|
setTimeout(() => {
|
||||||
|
step.value = 2
|
||||||
|
success.value = ''
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.data?.message || 'Failed to send reset code'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCode() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/verify-reset-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: email.value,
|
||||||
|
code: code.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
step.value = 3
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.data?.message || 'Invalid or expired code'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!isPasswordValid.value) {
|
||||||
|
error.value = 'Please meet all password requirements'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordsMatch.value) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: email.value,
|
||||||
|
code: code.value,
|
||||||
|
newPassword: newPassword.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.data?.message || 'Failed to reset password'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
113
pages/login.vue
113
pages/login.vue
@@ -10,19 +10,64 @@
|
|||||||
|
|
||||||
<div class="bg-white py-8 px-6 shadow-lg rounded-lg">
|
<div class="bg-white py-8 px-6 shadow-lg rounded-lg">
|
||||||
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()" class="space-y-6">
|
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()" class="space-y-6">
|
||||||
|
<!-- First Name (only show when registering) -->
|
||||||
|
<div v-if="isRegistering">
|
||||||
|
<label for="firstName" class="block text-sm font-medium text-gray-700">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
v-model="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="given-name"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name (only show when registering) -->
|
||||||
|
<div v-if="isRegistering">
|
||||||
|
<label for="lastName" class="block text-sm font-medium text-gray-700">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
v-model="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="family-name"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email (only show when registering) -->
|
||||||
|
<div v-if="isRegistering">
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700">
|
<label for="username" class="block text-sm font-medium text-gray-700">
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -62,6 +107,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password (only show when registering) -->
|
||||||
|
<div v-if="isRegistering">
|
||||||
|
<label for="confirmPassword" class="block text-sm font-medium text-gray-700">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password Match Indicator -->
|
||||||
|
<div v-if="confirmPassword.length > 0" class="mt-2 text-xs">
|
||||||
|
<div :class="passwordsMatch ? 'text-green-600' : 'text-red-600'">
|
||||||
|
<span v-if="passwordsMatch">✓ Passwords match</span>
|
||||||
|
<span v-else>✗ Passwords do not match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="text-red-600 text-sm">
|
<div v-if="error" class="text-red-600 text-sm">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,6 +149,11 @@
|
|||||||
>
|
>
|
||||||
{{ isRegistering ? 'Already have an account? Sign in' : "Don't have an account? Create one" }}
|
{{ isRegistering ? 'Already have an account? Sign in' : "Don't have an account? Create one" }}
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="!isRegistering">
|
||||||
|
<NuxtLink to="/forgot-password" class="text-sm text-blue-600 hover:text-blue-700">
|
||||||
|
Forgot Password?
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<NuxtLink to="/" class="text-sm text-gray-600 hover:text-gray-700">
|
<NuxtLink to="/" class="text-sm text-gray-600 hover:text-gray-700">
|
||||||
← Back to Sermons
|
← Back to Sermons
|
||||||
@@ -99,6 +171,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const firstName = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const isRegistering = ref(false)
|
const isRegistering = ref(false)
|
||||||
@@ -117,11 +193,19 @@ const isPasswordValid = computed(() => {
|
|||||||
passwordRequirements.value.hasNumberOrSymbol
|
passwordRequirements.value.hasNumberOrSymbol
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const passwordsMatch = computed(() => {
|
||||||
|
return password.value === confirmPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
function toggleMode() {
|
function toggleMode() {
|
||||||
isRegistering.value = !isRegistering.value
|
isRegistering.value = !isRegistering.value
|
||||||
error.value = ''
|
error.value = ''
|
||||||
username.value = ''
|
username.value = ''
|
||||||
password.value = ''
|
password.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
email.value = ''
|
||||||
|
firstName.value = ''
|
||||||
|
lastName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -156,6 +240,12 @@ async function handleRegister() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (!passwordsMatch.value) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -163,7 +253,10 @@ async function handleRegister() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value
|
password: password.value,
|
||||||
|
email: email.value,
|
||||||
|
firstName: firstName.value,
|
||||||
|
lastName: lastName.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Reset Password Modal -->
|
<!-- Reset Password Modal -->
|
||||||
<div v-if="showPasswordModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" @click.self="closePasswordModal">
|
<div v-if="showPasswordModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
||||||
|
|||||||
44
server/api/auth/forgot-password.post.ts
Normal file
44
server/api/auth/forgot-password.post.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { getUserByEmail, createPasswordResetCode } from '~/server/utils/database'
|
||||||
|
import { sendPasswordResetEmail, generateResetCode } from '~/server/utils/email'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email } = body
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Email is required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists with this email
|
||||||
|
const user = getUserByEmail(email)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal if email exists or not for security
|
||||||
|
return { success: true, message: 'If an account exists with this email, a reset code has been sent.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 6-digit code
|
||||||
|
const code = generateResetCode()
|
||||||
|
|
||||||
|
// Set expiration to 15 minutes from now
|
||||||
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
// Store code in database
|
||||||
|
createPasswordResetCode(email, code, expiresAt)
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
try {
|
||||||
|
await sendPasswordResetEmail(email, code)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send reset email:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Failed to send reset email. Please try again later.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'If an account exists with this email, a reset code has been sent.' }
|
||||||
|
})
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { createUser, getUserByUsername } from '~/server/utils/database'
|
import { createUser, getUserByUsername, getUserByEmail } from '~/server/utils/database'
|
||||||
import { setAuthCookie } from '~/server/utils/auth'
|
import { setAuthCookie } from '~/server/utils/auth'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { username, password } = body
|
const { username, password, email, firstName, lastName } = body
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || !email || !firstName || !lastName) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Username and password are required'
|
message: 'All fields are required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid email format'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +58,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if username already exists
|
||||||
const existingUser = getUserByUsername(username.toLowerCase())
|
const existingUser = getUserByUsername(username.toLowerCase())
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -58,9 +67,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingEmail = getUserByEmail(email.toLowerCase())
|
||||||
|
if (existingEmail) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: 'Email already exists'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the new user
|
// Create the new user with all fields
|
||||||
createUser(username.toLowerCase(), password)
|
createUser(username.toLowerCase(), password, email.toLowerCase(), firstName, lastName)
|
||||||
|
|
||||||
// Log them in automatically
|
// Log them in automatically
|
||||||
setAuthCookie(event, username.toLowerCase())
|
setAuthCookie(event, username.toLowerCase())
|
||||||
|
|||||||
50
server/api/auth/reset-password.post.ts
Normal file
50
server/api/auth/reset-password.post.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { getPasswordResetCode, resetPasswordByEmail, deletePasswordResetCode } from '~/server/utils/database'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, code, newPassword } = body
|
||||||
|
|
||||||
|
if (!email || !code || !newPassword) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Email, code, and new password are required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password requirements
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Password must be at least 8 characters long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUpperCase = /[A-Z]/.test(newPassword)
|
||||||
|
const hasLowerCase = /[a-z]/.test(newPassword)
|
||||||
|
const hasNumberOrSymbol = /[0-9!@#$%^&*(),.?":{}|<>]/.test(newPassword)
|
||||||
|
|
||||||
|
if (!hasUpperCase || !hasLowerCase || !hasNumberOrSymbol) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Password must contain uppercase, lowercase, and number/symbol',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code exists and hasn't expired
|
||||||
|
const resetCode = getPasswordResetCode(email, code)
|
||||||
|
|
||||||
|
if (!resetCode) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid or expired reset code',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password
|
||||||
|
resetPasswordByEmail(email, newPassword)
|
||||||
|
|
||||||
|
// Delete used reset code
|
||||||
|
deletePasswordResetCode(email)
|
||||||
|
|
||||||
|
return { success: true, message: 'Password reset successfully' }
|
||||||
|
})
|
||||||
25
server/api/auth/verify-reset-code.post.ts
Normal file
25
server/api/auth/verify-reset-code.post.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getPasswordResetCode } from '~/server/utils/database'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, code } = body
|
||||||
|
|
||||||
|
if (!email || !code) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Email and code are required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code exists and hasn't expired
|
||||||
|
const resetCode = getPasswordResetCode(email, code)
|
||||||
|
|
||||||
|
if (!resetCode) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid or expired reset code',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Code verified successfully' }
|
||||||
|
})
|
||||||
@@ -21,9 +21,20 @@ export interface User {
|
|||||||
id?: number
|
id?: number
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
email?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
is_admin: number
|
is_admin: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetCode {
|
||||||
|
id?: number
|
||||||
|
email: string
|
||||||
|
code: string
|
||||||
|
expires_at: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SermonNote {
|
export interface SermonNote {
|
||||||
id?: number
|
id?: number
|
||||||
user_id: number
|
user_id: number
|
||||||
@@ -60,10 +71,23 @@ export function getDatabase() {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
is_admin INTEGER DEFAULT 0
|
is_admin INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sermon_notes (
|
CREATE TABLE IF NOT EXISTS sermon_notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -143,16 +167,48 @@ export function getUserByUsername(username: string) {
|
|||||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(username: string, password: string) {
|
export function createUser(username: string, password: string, email?: string, firstName?: string, lastName?: string) {
|
||||||
const db = getDatabase()
|
const db = getDatabase()
|
||||||
const saltRounds = 10
|
const saltRounds = 10
|
||||||
const hashedPassword = bcrypt.hashSync(password, saltRounds)
|
const hashedPassword = bcrypt.hashSync(password, saltRounds)
|
||||||
return db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, 0)').run(username, hashedPassword)
|
return db.prepare('INSERT INTO users (username, password, email, first_name, last_name, is_admin) VALUES (?, ?, ?, ?, ?, 0)')
|
||||||
|
.run(username, hashedPassword, email || null, firstName || null, lastName || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByEmail(email: string) {
|
||||||
|
const db = getDatabase()
|
||||||
|
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPasswordResetCode(email: string, code: string, expiresAt: string) {
|
||||||
|
const db = getDatabase()
|
||||||
|
// Delete any existing codes for this email
|
||||||
|
db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
||||||
|
return db.prepare('INSERT INTO password_reset_codes (email, code, expires_at) VALUES (?, ?, ?)')
|
||||||
|
.run(email, code, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasswordResetCode(email: string, code: string) {
|
||||||
|
const db = getDatabase()
|
||||||
|
return db.prepare('SELECT * FROM password_reset_codes WHERE email = ? AND code = ? AND expires_at > datetime("now")')
|
||||||
|
.get(email, code) as PasswordResetCode | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePasswordResetCode(email: string) {
|
||||||
|
const db = getDatabase()
|
||||||
|
return db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPasswordByEmail(email: string, newPassword: string) {
|
||||||
|
const db = getDatabase()
|
||||||
|
const saltRounds = 10
|
||||||
|
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
|
||||||
|
return db.prepare('UPDATE users SET password = ? WHERE email = ?').run(hashedPassword, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllUsers() {
|
export function getAllUsers() {
|
||||||
const db = getDatabase()
|
const db = getDatabase()
|
||||||
return db.prepare('SELECT id, username, is_admin FROM users ORDER BY username').all() as Omit<User, 'password'>[]
|
return db.prepare('SELECT id, username, email, first_name, last_name, is_admin FROM users ORDER BY username').all() as Omit<User, 'password'>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUser(id: number) {
|
export function deleteUser(id: number) {
|
||||||
|
|||||||
39
server/utils/email.ts
Normal file
39
server/utils/email.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
export async function sendPasswordResetEmail(email: string, code: string) {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.emailHost,
|
||||||
|
port: parseInt(config.emailPort),
|
||||||
|
secure: parseInt(config.emailPort) === 465,
|
||||||
|
auth: {
|
||||||
|
user: config.emailUser,
|
||||||
|
pass: config.emailPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: config.emailFrom,
|
||||||
|
to: email,
|
||||||
|
subject: 'Password Reset Code - New Life Christian Church',
|
||||||
|
text: `Please enter this 6 digit code to reset your password for the New Life Christian Church sermon page: ${code}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Password Reset Request</h2>
|
||||||
|
<p>Please enter this 6 digit code to reset your password for the New Life Christian Church sermon page:</p>
|
||||||
|
<div style="background-color: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 5px; margin: 20px 0;">
|
||||||
|
${code}
|
||||||
|
</div>
|
||||||
|
<p style="color: #666;">This code will expire in 15 minutes.</p>
|
||||||
|
<p style="color: #666;">If you did not request a password reset, please ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
await transporter.sendMail(mailOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateResetCode(): string {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user