Profile enhancements & greeting

This commit is contained in:
2025-10-06 18:54:58 -04:00
parent 49a88f6634
commit 21b480021e
5 changed files with 499 additions and 1 deletions

View File

@@ -26,19 +26,27 @@
</ClientOnly>
</div>
<ClientOnly fallback-tag="div">
<div v-if="isAuthenticated && isAdmin" class="flex flex-wrap items-center justify-center gap-2">
<div v-if="isAuthenticated" class="flex flex-wrap items-center justify-center gap-2">
<NuxtLink
v-if="isAdmin"
to="/admin"
class="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium text-xs whitespace-nowrap"
>
Manage Sermons
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/users"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-medium text-xs whitespace-nowrap"
>
Manage Users
</NuxtLink>
<NuxtLink
to="/profile"
class="px-3 py-2 text-xs font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100 whitespace-nowrap"
>
Edit Profile
</NuxtLink>
</div>
</ClientOnly>
</div>
@@ -65,6 +73,12 @@
>
Manage Users
</NuxtLink>
<NuxtLink
to="/profile"
class="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
>
Edit Profile
</NuxtLink>
<button
@click="handleLogout"
class="px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100"
@@ -87,6 +101,11 @@
</div>
<div class="text-center mt-6">
<ClientOnly>
<h2 v-if="isAuthenticated && firstName" class="text-xl text-gray-700 mb-2">
{{ greeting }}
</h2>
</ClientOnly>
<h1 class="text-3xl font-bold text-gray-900">
Welcome to New Life!
</h1>
@@ -159,6 +178,27 @@
const { data: authData } = await useFetch('/api/auth/verify')
const isAuthenticated = computed(() => authData.value?.authenticated || false)
const isAdmin = computed(() => authData.value?.isAdmin || false)
const firstName = computed(() => authData.value?.firstName || '')
// Time-based greeting
const greeting = computed(() => {
if (!firstName.value) return ''
const hour = new Date().getHours()
let timeGreeting = ''
if (hour >= 5 && hour < 12) {
timeGreeting = 'Good morning'
} else if (hour >= 12 && hour < 18) {
timeGreeting = 'Good afternoon'
} else if (hour >= 18 && hour < 21) {
timeGreeting = 'Good evening'
} else {
timeGreeting = 'Greetings'
}
return `${timeGreeting}, ${firstName.value}!`
})
// Fetch non-archived sermons for the most recent sermon
const { data: activeSermons } = await useFetch('/api/sermons?includeArchived=false')

316
pages/profile.vue Normal file
View File

@@ -0,0 +1,316 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between mb-4">
<NuxtLink to="/">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-16 w-auto cursor-pointer hover:opacity-80" />
</NuxtLink>
<div class="flex items-center gap-4">
<NuxtLink
to="/"
class="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
>
Home
</NuxtLink>
<button
@click="handleLogout"
class="px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100"
>
Log Out
</button>
</div>
</div>
<h1 class="text-2xl font-bold text-gray-900">Edit Profile</h1>
</div>
</header>
<!-- Main Content -->
<main class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">Profile Information</h2>
</div>
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
<!-- Username (read-only) -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
v-model="profile.username"
type="text"
disabled
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-500 cursor-not-allowed"
/>
<p class="mt-1 text-xs text-gray-500">Username cannot be changed</p>
</div>
<!-- First Name -->
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700">
First Name
</label>
<input
id="firstName"
v-model="profile.firstName"
type="text"
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"
/>
</div>
<!-- Last Name -->
<div>
<label for="lastName" class="block text-sm font-medium text-gray-700">
Last Name
</label>
<input
id="lastName"
v-model="profile.lastName"
type="text"
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"
/>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
v-model="profile.email"
type="email"
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"
/>
</div>
<!-- Change Password Section -->
<div class="pt-6 border-t border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4">Change Password (Optional)</h3>
<!-- Current Password -->
<div class="mb-4">
<label for="currentPassword" class="block text-sm font-medium text-gray-700">
Current Password
</label>
<input
id="currentPassword"
v-model="passwords.current"
type="password"
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>
<!-- New Password -->
<div class="mb-4">
<label for="newPassword" class="block text-sm font-medium text-gray-700">
New Password
</label>
<input
id="newPassword"
v-model="passwords.new"
type="password"
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="passwords.new.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>
<!-- Confirm New Password -->
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700">
Confirm New Password
</label>
<input
id="confirmPassword"
v-model="passwords.confirm"
type="password"
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="passwords.confirm.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>
<div v-if="error" class="text-red-600 text-sm">
{{ error }}
</div>
<div v-if="success" class="text-green-600 text-sm">
{{ success }}
</div>
<div class="flex gap-4">
<button
type="submit"
:disabled="loading"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{{ loading ? 'Saving...' : 'Save Changes' }}
</button>
<NuxtLink
to="/"
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 text-center"
>
Cancel
</NuxtLink>
</div>
</form>
</div>
</main>
<Footer />
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const profile = ref({
username: '',
firstName: '',
lastName: '',
email: ''
})
const passwords = ref({
current: '',
new: '',
confirm: ''
})
const error = ref('')
const success = ref('')
const loading = ref(false)
const passwordRequirements = computed(() => ({
minLength: passwords.value.new.length >= 8,
hasUppercase: /[A-Z]/.test(passwords.value.new),
hasLowercase: /[a-z]/.test(passwords.value.new),
hasNumberOrSymbol: /[0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(passwords.value.new)
}))
const isPasswordValid = computed(() => {
if (passwords.value.new.length === 0) return true // Password change is optional
return passwordRequirements.value.minLength &&
passwordRequirements.value.hasUppercase &&
passwordRequirements.value.hasLowercase &&
passwordRequirements.value.hasNumberOrSymbol
})
const passwordsMatch = computed(() => {
return passwords.value.new === passwords.value.confirm
})
async function loadProfile() {
try {
const data = await $fetch('/api/profile')
profile.value = data
} catch (e: any) {
error.value = e.data?.message || 'Failed to load profile'
}
}
async function handleSubmit() {
error.value = ''
success.value = ''
// Validate password change if attempted
if (passwords.value.new || passwords.value.current || passwords.value.confirm) {
if (!passwords.value.current) {
error.value = 'Current password is required to change password'
return
}
if (!passwords.value.new) {
error.value = 'New password is required'
return
}
if (!isPasswordValid.value) {
error.value = 'Please meet all password requirements'
return
}
if (!passwordsMatch.value) {
error.value = 'New passwords do not match'
return
}
}
loading.value = true
try {
await $fetch('/api/profile/update', {
method: 'PUT',
body: {
firstName: profile.value.firstName,
lastName: profile.value.lastName,
email: profile.value.email,
currentPassword: passwords.value.current || undefined,
newPassword: passwords.value.new || undefined,
confirmPassword: passwords.value.confirm || undefined
}
})
success.value = 'Profile updated successfully!'
// Clear password fields
passwords.value = {
current: '',
new: '',
confirm: ''
}
// Reload profile data
await loadProfile()
} catch (e: any) {
error.value = e.data?.message || 'Failed to update profile'
} finally {
loading.value = false
}
}
async function handleLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/login')
}
onMounted(() => {
loadProfile()
})
</script>

View File

@@ -25,6 +25,7 @@ export default defineEventHandler(async (event) => {
return {
authenticated: true,
username: user.username,
firstName: user.first_name || '',
isAdmin: user.is_admin === 1
}
})

View File

@@ -0,0 +1,29 @@
import { getUserByUsername } from '~/server/utils/database'
import { getAuthUser } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const authUser = await getAuthUser(event)
if (!authUser) {
throw createError({
statusCode: 401,
message: 'Unauthorized',
})
}
const user = getUserByUsername(authUser.username)
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
return {
username: user.username,
email: user.email || '',
firstName: user.first_name || '',
lastName: user.last_name || '',
}
})

View File

@@ -0,0 +1,112 @@
import { getUserByUsername, getUserByEmail } from '~/server/utils/database'
import { getAuthUser } from '~/server/utils/auth'
import bcrypt from 'bcrypt'
export default defineEventHandler(async (event) => {
const authUser = await getAuthUser(event)
if (!authUser) {
throw createError({
statusCode: 401,
message: 'Unauthorized',
})
}
const body = await readBody(event)
const { firstName, lastName, email, currentPassword, newPassword, confirmPassword } = body
if (!firstName || !lastName || !email) {
throw createError({
statusCode: 400,
message: 'First name, last name, and email are required',
})
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
throw createError({
statusCode: 400,
message: 'Invalid email format',
})
}
const db = (await import('~/server/utils/database')).getDatabase()
// Get current user data
const currentUser = getUserByUsername(authUser.username)
if (!currentUser) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
// Check if email is already taken by another user
if (email.toLowerCase() !== currentUser.email?.toLowerCase()) {
const existingEmail = getUserByEmail(email)
if (existingEmail && existingEmail.id !== currentUser.id) {
throw createError({
statusCode: 409,
message: 'Email already exists',
})
}
}
// If changing password, validate it
if (newPassword) {
if (!currentPassword) {
throw createError({
statusCode: 400,
message: 'Current password is required to change password',
})
}
// Verify current password
const isValidPassword = bcrypt.compareSync(currentPassword, currentUser.password)
if (!isValidPassword) {
throw createError({
statusCode: 400,
message: 'Current password is incorrect',
})
}
if (newPassword !== confirmPassword) {
throw createError({
statusCode: 400,
message: 'New passwords do not match',
})
}
// Validate new 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',
})
}
// Update with new password
const saltRounds = 10
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
db.prepare('UPDATE users SET first_name = ?, last_name = ?, email = ?, password = ? WHERE id = ?')
.run(firstName, lastName, email.toLowerCase(), hashedPassword, currentUser.id)
} else {
// Update without changing password
db.prepare('UPDATE users SET first_name = ?, last_name = ?, email = ? WHERE id = ?')
.run(firstName, lastName, email.toLowerCase(), currentUser.id)
}
return { success: true, message: 'Profile updated successfully' }
})