Profile enhancements & greeting
This commit is contained in:
@@ -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
316
pages/profile.vue
Normal 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>
|
||||
@@ -25,6 +25,7 @@ export default defineEventHandler(async (event) => {
|
||||
return {
|
||||
authenticated: true,
|
||||
username: user.username,
|
||||
firstName: user.first_name || '',
|
||||
isAdmin: user.is_admin === 1
|
||||
}
|
||||
})
|
||||
|
||||
29
server/api/profile/index.get.ts
Normal file
29
server/api/profile/index.get.ts
Normal 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 || '',
|
||||
}
|
||||
})
|
||||
112
server/api/profile/update.put.ts
Normal file
112
server/api/profile/update.put.ts
Normal 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' }
|
||||
})
|
||||
Reference in New Issue
Block a user