Files
nlcc-itinerary/pages/users.vue
Joshua Ryder 3f1c573a67 feat: Make menu context-aware and hide current page links
Implement intelligent menu filtering that hides self-referencing links and shows all applicable pages based on user role and authentication state.

Changes:
- Menu component now uses currentPath to filter out the current page link
- Added computed properties to detect which page user is on
- Fixed profile, admin, and users pages to dynamically detect admin status from API
- Added menu to forgot-password page for consistent navigation
- All pages now pass correct authentication state to Menu component

This ensures menus always show relevant navigation options while avoiding redundant links to the current page. Admin users now see all admin options (Manage Sermons, Manage Users) regardless of which page they're on, except the current one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 09:40:42 -05:00

378 lines
13 KiB
Vue

<template>
<div class="min-h-screen bg-gray-50 flex flex-col">
<!-- 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>
<ClientOnly>
<Menu
:is-authenticated="isAuthenticated"
:is-admin="isAdmin"
:show-home="true"
:current-path="route.fullPath"
/>
</ClientOnly>
</div>
<h1 class="text-2xl font-bold text-gray-900">Manage Users</h1>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 w-full">
<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">All Users</h2>
</div>
<div v-if="loading" class="p-6 text-center">
<p class="text-gray-500">Loading users...</p>
</div>
<div v-else-if="error" class="p-6">
<p class="text-red-600">{{ error }}</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
First Name
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Name
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="user in users" :key="user.id">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ user.username }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ user.first_name || '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ user.last_name || '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
:class="user.is_admin ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
>
{{ user.is_admin ? 'Admin' : 'User' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col gap-1 items-start">
<span
v-if="isAccountLocked(user)"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"
>
🔒 Locked
</span>
<span
v-else-if="(user.failed_login_attempts || 0) > 0"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"
>
{{ user.failed_login_attempts }} failed attempt{{ user.failed_login_attempts !== 1 ? 's' : '' }}
</span>
<span
v-else
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
Active
</span>
<span v-if="isAccountLocked(user)" class="text-xs text-gray-500">
Until {{ formatLockTime(user.locked_until!) }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
v-if="isAccountLocked(user)"
@click="unlockAccount(user)"
class="text-green-600 hover:text-green-900 font-semibold"
>
Unlock Account
</button>
<button
v-if="!isCurrentUser(user)"
@click="toggleRole(user)"
class="text-blue-600 hover:text-blue-900"
>
{{ user.is_admin ? 'Make User' : 'Make Admin' }}
</button>
<button
@click="openResetPasswordModal(user)"
class="text-yellow-600 hover:text-yellow-900"
>
Reset Password
</button>
<button
v-if="!isCurrentUser(user)"
@click="confirmDelete(user)"
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- 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">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
Reset Password for {{ selectedUser?.username }}
</h3>
<div class="mt-2">
<label for="newPassword" class="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
id="newPassword"
v-model="newPassword"
type="password"
class="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 v-if="passwordError" class="mt-2 text-red-600 text-sm">
{{ passwordError }}
</div>
</div>
<div class="flex gap-3 mt-4">
<button
@click="resetPassword"
:disabled="!isPasswordValid"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset Password
</button>
<button
@click="closePasswordModal"
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<Footer />
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const route = useRoute()
interface User {
id: number
username: string
first_name?: string
last_name?: string
is_admin: number
failed_login_attempts?: number
locked_until?: string | null
}
const { data: authData } = await useFetch('/api/auth/verify')
const currentUsername = computed(() => authData.value?.username)
const isAuthenticated = computed(() => authData.value?.authenticated || false)
const isAdmin = computed(() => authData.value?.isAdmin || false)
const users = ref<User[]>([])
const loading = ref(true)
const error = ref('')
const showPasswordModal = ref(false)
const selectedUser = ref<User | null>(null)
const newPassword = ref('')
const passwordError = ref('')
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
})
function isCurrentUser(user: User) {
return user.username === currentUsername.value
}
function isAccountLocked(user: User): boolean {
if (!user.locked_until) return false
return new Date(user.locked_until) > new Date()
}
function formatLockTime(lockUntil: string): string {
const lockTime = new Date(lockUntil)
const now = new Date()
const diffMs = lockTime.getTime() - now.getTime()
const diffMins = Math.ceil(diffMs / 60000)
if (diffMins <= 0) return 'Expired'
if (diffMins === 1) return '1 minute'
return `${diffMins} minutes`
}
async function loadUsers() {
loading.value = true
error.value = ''
try {
const data = await $fetch<User[]>('/api/users')
users.value = data
} catch (e: any) {
error.value = e.data?.message || 'Failed to load users'
} finally {
loading.value = false
}
}
async function toggleRole(user: User) {
const newRole = user.is_admin ? 'User' : 'Admin'
if (!confirm(`Are you sure you want to make ${user.username} a ${newRole}?`)) {
return
}
try {
await $fetch(`/api/users/role/${user.id}`, {
method: 'PUT',
body: { isAdmin: !user.is_admin }
})
await loadUsers()
} catch (e: any) {
alert(e.data?.message || 'Failed to update user role')
}
}
function openResetPasswordModal(user: User) {
selectedUser.value = user
newPassword.value = ''
passwordError.value = ''
showPasswordModal.value = true
}
function closePasswordModal() {
showPasswordModal.value = false
selectedUser.value = null
newPassword.value = ''
passwordError.value = ''
}
async function resetPassword() {
if (!selectedUser.value || !isPasswordValid.value) {
return
}
passwordError.value = ''
try {
await $fetch(`/api/users/password/${selectedUser.value.id}`, {
method: 'PUT',
body: { newPassword: newPassword.value }
})
alert(`Password reset successfully for ${selectedUser.value.username}`)
closePasswordModal()
} catch (e: any) {
passwordError.value = e.data?.message || 'Failed to reset password'
}
}
async function unlockAccount(user: User) {
if (!confirm(`Are you sure you want to unlock ${user.username}? This will reset their failed login attempts.`)) {
return
}
try {
const result = await $fetch(`/api/users/unlock/${user.id}`, {
method: 'POST'
})
alert(`Account unlocked successfully!\n\nPrevious failed attempts: ${result.user.previousAttempts}`)
await loadUsers()
} catch (e: any) {
alert(e.data?.message || 'Failed to unlock account')
}
}
async function confirmDelete(user: User) {
if (!confirm(`Are you sure you want to delete ${user.username}? This action cannot be undone.`)) {
return
}
try {
await $fetch(`/api/users/delete/${user.id}`, {
method: 'DELETE'
})
await loadUsers()
} catch (e: any) {
alert(e.data?.message || 'Failed to delete user')
}
}
async function handleLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/login')
}
onMounted(() => {
loadUsers()
})
</script>