- Updated profile.vue to use MobileMenu on mobile, desktop buttons on desktop - Updated admin.vue to use MobileMenu on mobile, desktop buttons on desktop - Updated users.vue to use MobileMenu on mobile, desktop buttons on desktop - All pages with header navigation now have consistent mobile UX - Mobile menu provides clean, organized navigation with all options - Desktop retains traditional button layout for familiarity This ensures consistent navigation experience across the entire application, with the hamburger menu appearing on all pages with headers when viewed on mobile devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
396 lines
14 KiB
Vue
396 lines
14 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>
|
|
|
|
<!-- Mobile Menu -->
|
|
<div class="md:hidden">
|
|
<ClientOnly>
|
|
<MobileMenu
|
|
:is-authenticated="true"
|
|
:is-admin="true"
|
|
:show-home="true"
|
|
:current-path="route.fullPath"
|
|
/>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Desktop Navigation -->
|
|
<div class="hidden md: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">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 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>
|