Files
space-booking/frontend/src/views/Users.vue
Claude Agent e21cf03a16 feat: add multi-tenant system with properties, organizations, and public booking
Implement complete multi-property architecture:
- Properties (groups of spaces) with public/private visibility
- Property managers (many-to-many) with role-based permissions
- Organizations with member management
- Anonymous/guest booking support via public API (/api/public/*)
- Property-scoped spaces, bookings, and settings
- Frontend: property selector, organization management, public booking views
- Migration script and updated seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:17:21 +00:00

662 lines
16 KiB
Vue

<template>
<div class="users">
<Breadcrumb :items="breadcrumbItems" />
<div class="page-header">
<h2>User Management</h2>
<button class="btn btn-primary" @click="openCreateModal">
<UserPlus :size="16" />
Create New User
</button>
</div>
<!-- Filters -->
<CollapsibleSection title="Filters" :icon="Filter">
<div class="filters">
<div class="form-group">
<label for="filter-role">Filter by Role</label>
<select id="filter-role" v-model="filterRole" @change="loadUsers">
<option value="">All Roles</option>
<option value="admin">Superadmin</option>
<option value="manager">Manager</option>
<option value="user">User</option>
</select>
</div>
<div class="form-group">
<label for="filter-org">Filter by Organization</label>
<input
id="filter-org"
v-model="filterOrganization"
type="text"
placeholder="Enter organization name"
@input="loadUsers"
/>
</div>
</div>
</CollapsibleSection>
<!-- Users List -->
<CollapsibleSection title="All Users" :icon="UsersIcon">
<div v-if="loadingUsers" class="loading">Loading users...</div>
<div v-else-if="users.length === 0" class="empty">
No users found. {{ filterRole || filterOrganization ? 'Try different filters.' : 'Create one above!' }}
</div>
<div v-else class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Email</th>
<th>Full Name</th>
<th>Role</th>
<th>Organization</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.full_name }}</td>
<td>
<span :class="['badge', user.role === 'admin' || user.role === 'superadmin' ? 'badge-admin' : user.role === 'manager' ? 'badge-manager' : 'badge-user']">
{{ user.role === 'admin' ? 'superadmin' : user.role }}
</span>
</td>
<td>{{ user.organization || '-' }}</td>
<td>
<span :class="['badge', user.is_active ? 'badge-active' : 'badge-inactive']">
{{ user.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
<button
class="btn btn-sm btn-secondary"
@click="startEdit(user)"
:disabled="loading"
>
Edit
</button>
<button
:class="['btn', 'btn-sm', user.is_active ? 'btn-warning' : 'btn-success']"
@click="toggleStatus(user)"
:disabled="loading"
>
{{ user.is_active ? 'Deactivate' : 'Activate' }}
</button>
<button
class="btn btn-sm btn-danger"
@click="showResetPassword(user)"
:disabled="loading"
>
Reset Password
</button>
</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleSection>
<!-- Create/Edit User Modal -->
<div v-if="showFormModal" class="modal" @click.self="closeFormModal">
<div class="modal-content">
<h3>{{ editingUser ? 'Edit User' : 'Create New User' }}</h3>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label for="email">Email *</label>
<input
id="email"
v-model="formData.email"
type="email"
required
placeholder="user@example.com"
:disabled="!!editingUser"
/>
</div>
<div class="form-group">
<label for="full_name">Full Name *</label>
<input
id="full_name"
v-model="formData.full_name"
type="text"
required
placeholder="John Doe"
/>
</div>
<div class="form-group" v-if="!editingUser">
<label for="password">Password *</label>
<input
id="password"
v-model="formData.password"
type="password"
:required="!editingUser"
placeholder="Minimum 8 characters"
minlength="8"
/>
</div>
<div class="form-group">
<label for="role">Role *</label>
<select id="role" v-model="formData.role" required>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Superadmin</option>
</select>
</div>
<div class="form-group">
<label for="organization">Organization</label>
<input
id="organization"
v-model="formData.organization"
type="text"
placeholder="Optional organization"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ editingUser ? 'Update' : 'Create' }}
</button>
<button type="button" class="btn btn-secondary" @click="closeFormModal">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Reset Password Modal -->
<div v-if="resetPasswordUser" class="modal" @click.self="closeResetPassword">
<div class="modal-content">
<h3>Reset Password for {{ resetPasswordUser.full_name }}</h3>
<form @submit.prevent="handleResetPassword">
<div class="form-group">
<label for="new_password">New Password *</label>
<input
id="new_password"
v-model="newPassword"
type="password"
required
placeholder="Minimum 8 characters"
minlength="8"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading">
Reset Password
</button>
<button type="button" class="btn btn-secondary" @click="closeResetPassword">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
import type { User } from '@/types'
const breadcrumbItems = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Admin', to: '/admin' },
{ label: 'Users' }
]
const users = ref<User[]>([])
const loadingUsers = ref(false)
const loading = ref(false)
const error = ref('')
const success = ref('')
const editingUser = ref<User | null>(null)
const showFormModal = ref(false)
const resetPasswordUser = ref<User | null>(null)
const newPassword = ref('')
const filterRole = ref('')
const filterOrganization = ref('')
const formData = ref({
email: '',
full_name: '',
password: '',
role: 'user',
organization: ''
})
const loadUsers = async () => {
loadingUsers.value = true
error.value = ''
try {
const params: { role?: string; organization?: string } = {}
if (filterRole.value) params.role = filterRole.value
if (filterOrganization.value) params.organization = filterOrganization.value
users.value = await usersApi.list(params)
} catch (err) {
error.value = handleApiError(err)
} finally {
loadingUsers.value = false
}
}
const handleSubmit = async () => {
loading.value = true
error.value = ''
success.value = ''
try {
if (editingUser.value) {
await usersApi.update(editingUser.value.id, {
full_name: formData.value.full_name,
role: formData.value.role,
organization: formData.value.organization || undefined
})
success.value = 'User updated successfully!'
} else {
await usersApi.create({
email: formData.value.email,
full_name: formData.value.full_name,
password: formData.value.password,
role: formData.value.role,
organization: formData.value.organization || undefined,
timezone: 'UTC'
})
success.value = 'User created successfully!'
}
await loadUsers()
closeFormModal()
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const openCreateModal = () => {
resetForm()
showFormModal.value = true
}
const startEdit = (user: User) => {
editingUser.value = user
formData.value = {
email: user.email,
full_name: user.full_name,
password: '',
role: user.role,
organization: user.organization || ''
}
showFormModal.value = true
}
const closeFormModal = () => {
showFormModal.value = false
resetForm()
}
const resetForm = () => {
editingUser.value = null
formData.value = {
email: '',
full_name: '',
password: '',
role: 'user',
organization: ''
}
}
const toggleStatus = async (user: User) => {
loading.value = true
error.value = ''
success.value = ''
try {
await usersApi.updateStatus(user.id, !user.is_active)
success.value = `User ${user.is_active ? 'deactivated' : 'activated'} successfully!`
await loadUsers()
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const showResetPassword = (user: User) => {
resetPasswordUser.value = user
newPassword.value = ''
}
const closeResetPassword = () => {
resetPasswordUser.value = null
newPassword.value = ''
}
const handleResetPassword = async () => {
if (!resetPasswordUser.value) return
loading.value = true
error.value = ''
success.value = ''
try {
await usersApi.resetPassword(resetPasswordUser.value.id, newPassword.value)
success.value = 'Password reset successfully!'
closeResetPassword()
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.user-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-group input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-warning:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-warning) 85%, black);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
margin-top: 12px;
}
.success {
padding: 12px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
border-radius: var(--radius-sm);
margin-top: 12px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.badge-inactive {
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.badge-admin {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.badge-manager {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.badge-user {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 28px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 20px;
color: var(--color-text-primary);
}
.collapsible-section + .collapsible-section {
margin-top: 16px;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
}
}
</style>