UsersView.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. <template>
  2. <div class="page">
  3. <div class="page-header">
  4. <div>
  5. <h1>{{ $t('users.title') }}</h1>
  6. <p>{{ $t('users.manage') }}</p>
  7. </div>
  8. <button @click="showCreateModal" class="btn-primary">{{ $t('users.add') }}</button>
  9. </div>
  10. <div class="content">
  11. <!-- Search & Filters -->
  12. <div class="filters-bar">
  13. <input
  14. v-model="searchQuery"
  15. type="text"
  16. :placeholder="$t('common.search') + '...'"
  17. class="search-input"
  18. />
  19. <select v-model="filterRole" class="filter-select">
  20. <option value="">All Roles</option>
  21. <option value="superadmin">{{ $t('users.roles.superadmin') }}</option>
  22. <option value="admin">{{ $t('users.roles.admin') }}</option>
  23. <option value="owner">{{ $t('users.roles.owner') }}</option>
  24. <option value="user">{{ $t('users.roles.user') }}</option>
  25. </select>
  26. <select v-model="filterOrg" class="filter-select">
  27. <option value="">All Organizations</option>
  28. <option value="null">No Organization (Cloud)</option>
  29. <option v-for="org in organizations" :key="org.id" :value="org.id">
  30. {{ org.name }}
  31. </option>
  32. </select>
  33. <select v-model="filterStatus" class="filter-select">
  34. <option value="">All Statuses</option>
  35. <option value="pending">Pending</option>
  36. <option value="active">Active</option>
  37. <option value="suspended">Suspended</option>
  38. </select>
  39. </div>
  40. <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
  41. <div v-else-if="error" class="error">{{ error }}</div>
  42. <table v-else-if="filteredUsers.length > 0" class="data-table">
  43. <thead>
  44. <tr>
  45. <th>ID</th>
  46. <th>Email</th>
  47. <th>{{ $t('users.fullName') }}</th>
  48. <th>{{ $t('users.role') }}</th>
  49. <th>{{ $t('devices.organization') }}</th>
  50. <th>Email ✓</th>
  51. <th>Last Login</th>
  52. <th>{{ $t('common.status') }}</th>
  53. <th>{{ $t('common.actions') }}</th>
  54. </tr>
  55. </thead>
  56. <tbody>
  57. <tr v-for="user in filteredUsers" :key="user.id">
  58. <td>{{ user.id }}</td>
  59. <td><strong>{{ user.email }}</strong></td>
  60. <td>{{ user.full_name || '-' }}</td>
  61. <td><span class="badge role">{{ $t(`users.roles.${user.role}`) }}</span></td>
  62. <td>{{ getOrganizationName(user.organization_id) }}</td>
  63. <td>
  64. <span class="badge" :class="user.email_verified ? 'badge-verified' : 'badge-unverified'">
  65. {{ user.email_verified ? '✓' : '✗' }}
  66. </span>
  67. </td>
  68. <td>{{ formatLastLogin(user.last_login_at) }}</td>
  69. <td><span class="badge" :class="`status-${user.status}`">{{ user.status }}</span></td>
  70. <td>
  71. <div class="actions">
  72. <button @click="showEditModal(user)" class="btn-icon" title="Edit">✏️</button>
  73. <button @click="showPasswordModal(user)" class="btn-icon" title="Change Password">🔑</button>
  74. <button @click="confirmDelete(user)" class="btn-icon" title="Delete">🗑️</button>
  75. </div>
  76. </td>
  77. </tr>
  78. </tbody>
  79. </table>
  80. <div v-else class="empty">No users yet</div>
  81. </div>
  82. <!-- Create/Edit Modal -->
  83. <div v-if="modalVisible" class="modal-overlay" @click="closeModal">
  84. <div class="modal" @click.stop>
  85. <div class="modal-header">
  86. <h2>{{ editingUser ? $t('common.edit') : $t('users.add') }}</h2>
  87. <button @click="closeModal" class="btn-close">×</button>
  88. </div>
  89. <form @submit.prevent="saveUser" class="modal-body">
  90. <div class="form-group">
  91. <label>Email *</label>
  92. <input v-model="form.email" type="email" required :disabled="!!editingUser" />
  93. </div>
  94. <div class="form-group" v-if="!editingUser">
  95. <label>{{ $t('auth.password') }} *</label>
  96. <input v-model="form.password" type="password" minlength="8" required />
  97. </div>
  98. <div class="form-group">
  99. <label>{{ $t('users.fullName') }}</label>
  100. <input v-model="form.full_name" type="text" />
  101. </div>
  102. <div class="form-group">
  103. <label>Phone</label>
  104. <input v-model="form.phone" type="tel" />
  105. </div>
  106. <div class="form-group">
  107. <label>{{ $t('users.role') }} *</label>
  108. <select v-model="form.role" required>
  109. <option value="superadmin">{{ $t('users.roles.superadmin') }}</option>
  110. <option value="admin">{{ $t('users.roles.admin') }}</option>
  111. <option value="owner">{{ $t('users.roles.owner') }}</option>
  112. <option value="user">{{ $t('users.roles.user') }}</option>
  113. </select>
  114. </div>
  115. <div class="form-group" v-if="form.role !== 'superadmin' && form.role !== 'admin'">
  116. <label>{{ $t('devices.organization') }} *</label>
  117. <select v-model="form.organization_id" required>
  118. <option :value="null">Select organization...</option>
  119. <option v-for="org in organizations" :key="org.id" :value="org.id">
  120. {{ org.name }}
  121. </option>
  122. </select>
  123. </div>
  124. <div class="form-group">
  125. <label>{{ $t('common.status') }}</label>
  126. <select v-model="form.status">
  127. <option value="pending">Pending</option>
  128. <option value="active">Active</option>
  129. <option value="suspended">Suspended</option>
  130. </select>
  131. </div>
  132. <div class="form-group">
  133. <label>{{ $t('users.notes') }}</label>
  134. <textarea v-model="form.notes" rows="3" :placeholder="$t('users.notesHint')"></textarea>
  135. </div>
  136. <div class="modal-footer">
  137. <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
  138. <button type="submit" :disabled="saving" class="btn-primary">
  139. {{ saving ? $t('common.loading') : $t('common.save') }}
  140. </button>
  141. </div>
  142. </form>
  143. </div>
  144. </div>
  145. <!-- Change Password Modal -->
  146. <div v-if="passwordModalVisible" class="modal-overlay" @click="passwordModalVisible = false">
  147. <div class="modal modal-sm" @click.stop>
  148. <div class="modal-header">
  149. <h2>Change Password</h2>
  150. <button @click="passwordModalVisible = false" class="btn-close">×</button>
  151. </div>
  152. <form @submit.prevent="changePassword" class="modal-body">
  153. <div class="form-group">
  154. <label>New Password *</label>
  155. <input v-model="passwordForm.new_password" type="password" minlength="8" required />
  156. </div>
  157. <div class="modal-footer">
  158. <button type="button" @click="passwordModalVisible = false" class="btn-secondary">{{ $t('common.cancel') }}</button>
  159. <button type="submit" :disabled="changingPassword" class="btn-primary">
  160. {{ changingPassword ? $t('common.loading') : $t('common.save') }}
  161. </button>
  162. </div>
  163. </form>
  164. </div>
  165. </div>
  166. <!-- Delete Confirmation Modal -->
  167. <div v-if="deleteConfirmVisible" class="modal-overlay" @click="deleteConfirmVisible = false">
  168. <div class="modal modal-sm" @click.stop>
  169. <div class="modal-header">
  170. <h2>{{ $t('common.confirm') }}</h2>
  171. </div>
  172. <div class="modal-body">
  173. <p>Delete user <strong>{{ userToDelete?.email }}</strong>?</p>
  174. </div>
  175. <div class="modal-footer">
  176. <button @click="deleteConfirmVisible = false" class="btn-secondary">{{ $t('common.cancel') }}</button>
  177. <button @click="deleteUser" :disabled="deleting" class="btn-danger">
  178. {{ deleting ? $t('common.loading') : $t('common.delete') }}
  179. </button>
  180. </div>
  181. </div>
  182. </div>
  183. </div>
  184. </template>
  185. <script setup>
  186. import { ref, computed, onMounted, watch } from 'vue'
  187. import { useRoute, useRouter } from 'vue-router'
  188. import usersApi from '@/api/users'
  189. import organizationsApi from '@/api/organizations'
  190. const route = useRoute()
  191. const router = useRouter()
  192. const users = ref([])
  193. const organizations = ref([])
  194. const loading = ref(false)
  195. const error = ref(null)
  196. const modalVisible = ref(false)
  197. const passwordModalVisible = ref(false)
  198. const deleteConfirmVisible = ref(false)
  199. const editingUser = ref(null)
  200. const userForPassword = ref(null)
  201. const userToDelete = ref(null)
  202. const saving = ref(false)
  203. const changingPassword = ref(false)
  204. const deleting = ref(false)
  205. // Filters
  206. const searchQuery = ref('')
  207. const filterRole = ref('')
  208. const filterOrg = ref('')
  209. const filterStatus = ref('')
  210. const form = ref({
  211. email: '',
  212. password: '',
  213. full_name: '',
  214. phone: '',
  215. role: 'user',
  216. organization_id: null,
  217. status: 'pending',
  218. notes: ''
  219. })
  220. const passwordForm = ref({
  221. new_password: ''
  222. })
  223. // Filtered users
  224. const filteredUsers = computed(() => {
  225. let result = users.value
  226. // Search filter
  227. if (searchQuery.value) {
  228. const query = searchQuery.value.toLowerCase()
  229. result = result.filter(user =>
  230. user.email.toLowerCase().includes(query) ||
  231. (user.full_name && user.full_name.toLowerCase().includes(query))
  232. )
  233. }
  234. // Role filter
  235. if (filterRole.value) {
  236. result = result.filter(user => user.role === filterRole.value)
  237. }
  238. // Organization filter
  239. if (filterOrg.value) {
  240. if (filterOrg.value === 'null') {
  241. result = result.filter(user => user.organization_id === null)
  242. } else {
  243. result = result.filter(user => user.organization_id === parseInt(filterOrg.value))
  244. }
  245. }
  246. // Status filter
  247. if (filterStatus.value) {
  248. result = result.filter(user => user.status === filterStatus.value)
  249. }
  250. return result
  251. })
  252. async function loadUsers() {
  253. loading.value = true
  254. error.value = null
  255. try {
  256. users.value = await usersApi.getAllSuperadmin()
  257. } catch (err) {
  258. error.value = err.response?.data?.detail || 'Failed to load users'
  259. } finally {
  260. loading.value = false
  261. }
  262. }
  263. async function loadOrganizations() {
  264. try {
  265. organizations.value = await organizationsApi.getAll()
  266. } catch (err) {
  267. console.error('Failed to load organizations:', err)
  268. }
  269. }
  270. function getOrganizationName(orgId) {
  271. if (!orgId) return 'None'
  272. const org = organizations.value.find(o => o.id === orgId)
  273. return org ? org.name : `Org #${orgId}`
  274. }
  275. function formatLastLogin(lastLoginAt) {
  276. if (!lastLoginAt) return 'Never'
  277. const date = new Date(lastLoginAt)
  278. const now = new Date()
  279. const diffMs = now - date
  280. const diffMins = Math.floor(diffMs / 60000)
  281. const diffHours = Math.floor(diffMs / 3600000)
  282. const diffDays = Math.floor(diffMs / 86400000)
  283. if (diffMins < 60) return `${diffMins}m ago`
  284. if (diffHours < 24) return `${diffHours}h ago`
  285. if (diffDays < 7) return `${diffDays}d ago`
  286. return date.toLocaleDateString()
  287. }
  288. function showCreateModal() {
  289. editingUser.value = null
  290. form.value = {
  291. email: '',
  292. password: '',
  293. full_name: '',
  294. phone: '',
  295. role: 'user',
  296. organization_id: null,
  297. status: 'pending',
  298. notes: ''
  299. }
  300. modalVisible.value = true
  301. }
  302. function showEditModal(user) {
  303. editingUser.value = user
  304. form.value = {
  305. email: user.email,
  306. full_name: user.full_name || '',
  307. phone: user.phone || '',
  308. role: user.role,
  309. organization_id: user.organization_id,
  310. status: user.status,
  311. notes: user.notes || ''
  312. }
  313. modalVisible.value = true
  314. }
  315. function closeModal() {
  316. modalVisible.value = false
  317. editingUser.value = null
  318. }
  319. function showPasswordModal(user) {
  320. userForPassword.value = user
  321. passwordForm.value.new_password = ''
  322. passwordModalVisible.value = true
  323. }
  324. async function saveUser() {
  325. saving.value = true
  326. try {
  327. if (editingUser.value) {
  328. await usersApi.updateSuperadmin(editingUser.value.id, form.value)
  329. } else {
  330. await usersApi.createSuperadmin(form.value)
  331. }
  332. await loadUsers()
  333. closeModal()
  334. } catch (err) {
  335. alert(err.response?.data?.detail || 'Failed to save user')
  336. } finally {
  337. saving.value = false
  338. }
  339. }
  340. async function changePassword() {
  341. changingPassword.value = true
  342. try {
  343. await usersApi.changePasswordSuperadmin(userForPassword.value.id, passwordForm.value)
  344. passwordModalVisible.value = false
  345. alert('Password changed successfully')
  346. } catch (err) {
  347. alert(err.response?.data?.detail || 'Failed to change password')
  348. } finally {
  349. changingPassword.value = false
  350. }
  351. }
  352. function confirmDelete(user) {
  353. userToDelete.value = user
  354. deleteConfirmVisible.value = true
  355. }
  356. async function deleteUser() {
  357. deleting.value = true
  358. try {
  359. await usersApi.deleteSuperadmin(userToDelete.value.id)
  360. await loadUsers()
  361. deleteConfirmVisible.value = false
  362. } catch (err) {
  363. alert(err.response?.data?.detail || 'Failed to delete user')
  364. } finally {
  365. deleting.value = false
  366. }
  367. }
  368. // Auto-clear organization if role is cloud-side (superadmin or admin)
  369. watch(() => form.value.role, (newRole) => {
  370. if (newRole === 'superadmin' || newRole === 'admin') {
  371. form.value.organization_id = null
  372. }
  373. })
  374. // Watch for query parameter to auto-open edit modal
  375. watch(() => route.query.edit, async (userId) => {
  376. if (userId) {
  377. const user = users.value.find(u => u.id === parseInt(userId))
  378. if (user) {
  379. showEditModal(user)
  380. } else {
  381. // User not loaded yet, wait for load
  382. await loadUsers()
  383. const loadedUser = users.value.find(u => u.id === parseInt(userId))
  384. if (loadedUser) {
  385. showEditModal(loadedUser)
  386. }
  387. }
  388. // Clear query param
  389. router.replace({ query: {} })
  390. }
  391. }, { immediate: true })
  392. onMounted(() => {
  393. loadUsers()
  394. loadOrganizations()
  395. })
  396. </script>
  397. <style scoped>
  398. .page { padding: 32px; }
  399. .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
  400. .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
  401. .page-header p { color: #718096; font-size: 16px; }
  402. .content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
  403. .filters-bar {
  404. display: flex;
  405. gap: 12px;
  406. margin-bottom: 20px;
  407. flex-wrap: wrap;
  408. }
  409. .search-input {
  410. flex: 1;
  411. min-width: 200px;
  412. padding: 10px 16px;
  413. border: 1px solid #e2e8f0;
  414. border-radius: 8px;
  415. font-size: 14px;
  416. transition: border-color 0.2s;
  417. }
  418. .search-input:focus {
  419. outline: none;
  420. border-color: #667eea;
  421. }
  422. .filter-select {
  423. min-width: 180px;
  424. padding: 10px 16px;
  425. border: 1px solid #e2e8f0;
  426. border-radius: 8px;
  427. font-size: 14px;
  428. background: white;
  429. cursor: pointer;
  430. transition: border-color 0.2s;
  431. }
  432. .filter-select:focus {
  433. outline: none;
  434. border-color: #667eea;
  435. }
  436. .loading, .error, .empty { text-align: center; padding: 40px; color: #718096; }
  437. .error { color: #e53e3e; }
  438. .data-table { width: 100%; border-collapse: collapse; }
  439. .data-table th { text-align: left; padding: 12px; border-bottom: 2px solid #e2e8f0; font-weight: 600; color: #4a5568; font-size: 14px; }
  440. .data-table td { padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1a202c; }
  441. .data-table tbody tr:hover { background: #f7fafc; }
  442. .badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; background: #e2e8f0; color: #718096; }
  443. .badge.role { background: #dbeafe; color: #1e40af; }
  444. .badge.status-active { background: #c6f6d5; color: #22543d; }
  445. .badge.status-pending { background: #fef3c7; color: #92400e; }
  446. .badge.status-suspended { background: #fed7d7; color: #742a2a; }
  447. .badge.badge-verified { background: #c6f6d5; color: #22543d; }
  448. .badge.badge-unverified { background: #e2e8f0; color: #718096; }
  449. .actions { display: flex; gap: 8px; }
  450. .btn-icon { padding: 4px 8px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
  451. .btn-icon:hover { opacity: 1; }
  452. .btn-primary { padding: 12px 24px; background: #667eea; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  453. .btn-primary:hover { background: #5568d3; }
  454. .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
  455. .btn-secondary { padding: 12px 24px; background: #e2e8f0; color: #4a5568; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  456. .btn-secondary:hover { background: #cbd5e0; }
  457. .btn-danger { padding: 12px 24px; background: #f56565; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  458. .btn-danger:hover { background: #e53e3e; }
  459. .modal-overlay { 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; }
  460. .modal { background: white; border-radius: 12px; width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; }
  461. .modal-sm { max-width: 400px; }
  462. .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 24px; border-bottom: 1px solid #e2e8f0; }
  463. .modal-header h2 { font-size: 24px; font-weight: 700; color: #1a202c; }
  464. .btn-close { width: 32px; height: 32px; border: none; background: none; font-size: 32px; color: #718096; cursor: pointer; line-height: 1; }
  465. .btn-close:hover { color: #1a202c; }
  466. .modal-body { padding: 24px; }
  467. .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 24px; border-top: 1px solid #e2e8f0; }
  468. .form-group { margin-bottom: 20px; }
  469. .form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #4a5568; font-size: 14px; }
  470. .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; font-family: inherit; }
  471. .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #667eea; }
  472. .form-group input:disabled { background: #f7fafc; color: #718096; }
  473. .form-group textarea { resize: vertical; min-height: 80px; }
  474. </style>