Unify user info across admin app and profile view.
This commit is contained in:
		| @@ -1,6 +1,7 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||||
| import CredentialList from '@/components/CredentialList.vue' | import CredentialList from '@/components/CredentialList.vue' | ||||||
|  | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||||
| import StatusMessage from '@/components/StatusMessage.vue' | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| @@ -368,30 +369,17 @@ async function toggleRolePermission(role, permId, checked) { | |||||||
| function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } } | function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } } | ||||||
| function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } } | function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } } | ||||||
|  |  | ||||||
| // Admin user rename | async function onUserNameSaved() { | ||||||
| const editingUserName = ref(false) |   await loadOrgs() | ||||||
| const editUserNameValue = ref('') |   if (selectedUser.value) { | ||||||
| const editUserNameValid = computed(()=> true) // backend validates |     try { | ||||||
| function beginEditUserName() { |       const r = await fetch(`/auth/admin/users/${selectedUser.value.uuid}`) | ||||||
|   if (!selectedUser.value) return |       const jd = await r.json() | ||||||
|   editingUserName.value = true |       if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed') | ||||||
|   editUserNameValue.value = '' |       userDetail.value = jd | ||||||
| } |     } catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') } | ||||||
| function cancelEditUserName() { editingUserName.value = false } |  | ||||||
| async function submitEditUserName() { |  | ||||||
|   if (!editingUserName.value) return |  | ||||||
|   try { |  | ||||||
|     const res = await fetch(`/auth/admin/users/${selectedUser.value.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: editUserNameValue.value }) }) |  | ||||||
|     const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Rename failed') |  | ||||||
|     editingUserName.value = false |  | ||||||
|     await loadOrgs() |  | ||||||
|     const r = await fetch(`/auth/admin/users/${selectedUser.value.uuid}`) |  | ||||||
|     const jd = await r.json(); if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed') |  | ||||||
|     userDetail.value = jd |  | ||||||
|     authStore.showMessage('User renamed', 'success', 1500) |  | ||||||
|   } catch (e) { |  | ||||||
|     authStore.showMessage(e.message || 'Rename failed') |  | ||||||
|   } |   } | ||||||
|  |   authStore.showMessage('User renamed', 'success', 1500) | ||||||
| } | } | ||||||
|  |  | ||||||
| async function submitDialog() { | async function submitDialog() { | ||||||
| @@ -488,24 +476,26 @@ async function submitDialog() { | |||||||
|  |  | ||||||
|         <!-- User Detail Page --> |         <!-- User Detail Page --> | ||||||
|         <div v-if="selectedUser" class="card user-detail"> |         <div v-if="selectedUser" class="card user-detail"> | ||||||
|           <h2 class="user-title"> |           <UserBasicInfo | ||||||
|             <span v-if="!editingUserName">{{ userDetail?.display_name || selectedUser.display_name }} <button class="icon-btn" @click="beginEditUserName" title="Rename user">✏️</button></span> |             v-if="userDetail && !userDetail.error" | ||||||
|             <span v-else> |             :name="userDetail.display_name || selectedUser.display_name" | ||||||
|               <input v-model="editUserNameValue" :placeholder="userDetail?.display_name || selectedUser.display_name" maxlength="64" @keyup.enter="submitEditUserName" /> |             :visits="userDetail.visits" | ||||||
|               <button class="icon-btn" @click="submitEditUserName">💾</button> |             :created-at="userDetail.created_at" | ||||||
|               <button class="icon-btn" @click="cancelEditUserName">✖</button> |             :last-seen="userDetail.last_seen" | ||||||
|             </span> |             :loading="loading" | ||||||
|           </h2> |             :update-endpoint="`/auth/admin/users/${selectedUser.uuid}/display-name`" | ||||||
|           <div v-if="userDetail && !userDetail.error" class="user-meta"> |             @saved="onUserNameSaved" | ||||||
|             <p class="small">Organization: {{ userDetail.org.display_name }}</p> |           > | ||||||
|             <p class="small">Role: {{ userDetail.role }}</p> |             <span><strong>Organization:</strong></span> | ||||||
|             <p class="small">Visits: {{ userDetail.visits }}</p> |             <span>{{ userDetail.org.display_name }}</span> | ||||||
|             <p class="small">Created: {{ userDetail.created_at ? new Date(userDetail.created_at).toLocaleString() : '—' }}</p> |             <span><strong>Role:</strong></span> | ||||||
|             <p class="small">Last Seen: {{ userDetail.last_seen ? new Date(userDetail.last_seen).toLocaleString() : '—' }}</p> |             <span>{{ userDetail.role }}</span> | ||||||
|  |           </UserBasicInfo> | ||||||
|  |           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> | ||||||
|  |           <template v-if="userDetail && !userDetail.error"> | ||||||
|             <h3 class="cred-title">Registered Passkeys</h3> |             <h3 class="cred-title">Registered Passkeys</h3> | ||||||
|             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> |             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> | ||||||
|           </div> |           </template> | ||||||
|           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> |  | ||||||
|           <div class="actions"> |           <div class="actions"> | ||||||
|             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button> |             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button> | ||||||
|             <button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button> |             <button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button> | ||||||
|   | |||||||
| @@ -2,33 +2,16 @@ | |||||||
|   <div class="container"> |   <div class="container"> | ||||||
|     <div class="view active"> |     <div class="view active"> | ||||||
|   <h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> |   <h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> | ||||||
|       <div v-if="authStore.userInfo?.user" class="user-info"> |       <UserBasicInfo | ||||||
|         <h3 class="user-name-heading"> |         v-if="authStore.userInfo?.user" | ||||||
|           <span class="icon">👤</span> |         :name="authStore.userInfo.user.user_name" | ||||||
|           <span v-if="!editingName" class="user-name-row"> |         :visits="authStore.userInfo.user.visits || 0" | ||||||
|             <span class="display-name" :title="authStore.userInfo.user.user_name">{{ authStore.userInfo.user.user_name }}</span> |         :created-at="authStore.userInfo.user.created_at" | ||||||
|             <button class="mini-btn" @click="startEdit" title="Edit name">✏️</button> |         :last-seen="authStore.userInfo.user.last_seen" | ||||||
|           </span> |         :loading="authStore.isLoading" | ||||||
|           <span v-else class="user-name-row editing"> |         update-endpoint="/auth/user/display-name" | ||||||
|             <input |         @saved="authStore.loadUserInfo()" | ||||||
|               v-model="newName" |       /> | ||||||
|               class="name-input" |  | ||||||
|               :placeholder="authStore.userInfo.user.user_name" |  | ||||||
|               :disabled="authStore.isLoading" |  | ||||||
|               maxlength="64" |  | ||||||
|               @keyup.enter="saveName" |  | ||||||
|             /> |  | ||||||
|             <button class="mini-btn" @click="saveName" :disabled="authStore.isLoading" title="Save name">💾</button> |  | ||||||
|             <button class="mini-btn" @click="cancelEdit" :disabled="authStore.isLoading" title="Cancel">✖</button> |  | ||||||
|           </span> |  | ||||||
|         </h3> |  | ||||||
|         <span><strong>Visits:</strong></span> |  | ||||||
|         <span>{{ authStore.userInfo.user.visits || 0 }}</span> |  | ||||||
|         <span><strong>Registered:</strong></span> |  | ||||||
|         <span>{{ formatDate(authStore.userInfo.user.created_at) }}</span> |  | ||||||
|         <span><strong>Last seen:</strong></span> |  | ||||||
|         <span>{{ formatDate(authStore.userInfo.user.last_seen) }}</span> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <h2>Your Passkeys</h2> |       <h2>Your Passkeys</h2> | ||||||
|       <div class="credential-list"> |       <div class="credential-list"> | ||||||
| @@ -100,6 +83,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue' | |||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import { formatDate } from '@/utils/helpers' | import { formatDate } from '@/utils/helpers' | ||||||
| import passkey from '@/utils/passkey' | import passkey from '@/utils/passkey' | ||||||
|  | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const updateInterval = ref(null) | const updateInterval = ref(null) | ||||||
| @@ -150,7 +134,6 @@ const addNewCredential = async () => { | |||||||
|  |  | ||||||
| const deleteCredential = async (credentialId) => { | const deleteCredential = async (credentialId) => { | ||||||
|   if (!confirm('Are you sure you want to delete this passkey?')) return |   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||||
|  |  | ||||||
|   try { |   try { | ||||||
|     await authStore.deleteCredential(credentialId) |     await authStore.deleteCredential(credentialId) | ||||||
|     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) |     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) | ||||||
| @@ -165,95 +148,10 @@ const logout = async () => { | |||||||
| } | } | ||||||
|  |  | ||||||
| const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | ||||||
|  |  | ||||||
| // Name editing state & actions |  | ||||||
| const editingName = ref(false) |  | ||||||
| const newName = ref('') |  | ||||||
| function startEdit() { editingName.value = true; newName.value = '' } |  | ||||||
| function cancelEdit() { editingName.value = false } |  | ||||||
| async function saveName() { |  | ||||||
|   try { |  | ||||||
|     authStore.isLoading = true |  | ||||||
|   const res = await fetch('/auth/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: newName.value }) }) |  | ||||||
|     const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') |  | ||||||
|     await authStore.loadUserInfo() |  | ||||||
|     editingName.value = false |  | ||||||
|     authStore.showMessage('Name updated', 'success', 1500) |  | ||||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } |  | ||||||
|   finally { authStore.isLoading = false } |  | ||||||
| } |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .user-info { | /* Removed inline user info styles; now provided by UserBasicInfo component */ | ||||||
|   display: grid; |  | ||||||
|   grid-template-columns: auto 1fr; |  | ||||||
|   gap: 10px; |  | ||||||
| } |  | ||||||
| .user-info h3 { |  | ||||||
|   grid-column: span 2; |  | ||||||
| } |  | ||||||
| .user-info span { |  | ||||||
|   text-align: left; |  | ||||||
| } |  | ||||||
| .user-name-heading { |  | ||||||
|   display: flex; |  | ||||||
|   align-items: center; |  | ||||||
|   gap: 0.4rem; |  | ||||||
|   flex-wrap: wrap; |  | ||||||
|   margin: 0 0 0.25rem 0; |  | ||||||
| } |  | ||||||
| .user-name-row { |  | ||||||
|   display: inline-flex; |  | ||||||
|   align-items: center; |  | ||||||
|   gap: 0.35rem; |  | ||||||
|   max-width: 100%; |  | ||||||
| } |  | ||||||
| .user-name-row.editing { flex: 1 1 auto; } |  | ||||||
| .icon { flex: 0 0 auto; } |  | ||||||
| .display-name { |  | ||||||
|   font-weight: 600; |  | ||||||
|   font-size: 1.05em; |  | ||||||
|   line-height: 1.2; |  | ||||||
|   max-width: 14ch; |  | ||||||
|   overflow: hidden; |  | ||||||
|   text-overflow: ellipsis; |  | ||||||
|   white-space: nowrap; |  | ||||||
| } |  | ||||||
| .name-input { |  | ||||||
|   width: auto; |  | ||||||
|   flex: 1 1 140px; |  | ||||||
|   min-width: 120px; |  | ||||||
|   padding: 6px 8px; |  | ||||||
|   font-size: 0.9em; |  | ||||||
|   border: 1px solid #a9c5d6; |  | ||||||
|   border-radius: 6px; |  | ||||||
| } |  | ||||||
| .user-name-heading .name-input { width: auto; } |  | ||||||
| .name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; } |  | ||||||
| .mini-btn { |  | ||||||
|   width: auto; |  | ||||||
|   padding: 4px 6px; |  | ||||||
|   margin: 0; |  | ||||||
|   font-size: 0.75em; |  | ||||||
|   line-height: 1; |  | ||||||
|   background: #eef5fa; |  | ||||||
|   border: 1px solid #b7d2e3; |  | ||||||
|   border-radius: 6px; |  | ||||||
|   cursor: pointer; |  | ||||||
|   transition: background 0.2s, transform 0.15s; |  | ||||||
| } |  | ||||||
| .mini-btn:hover:not(:disabled) { background: #dcecf6; } |  | ||||||
| .mini-btn:active:not(:disabled) { transform: translateY(1px); } |  | ||||||
| .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } |  | ||||||
| @media (max-width: 480px) { |  | ||||||
|   .user-name-heading { flex-direction: column; align-items: flex-start; } |  | ||||||
|   .user-name-row.editing { width: 100%; } |  | ||||||
|   .display-name { max-width: 100%; } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
|  |  | ||||||
| <style scoped> |  | ||||||
| .admin-link { | .admin-link { | ||||||
|   font-size: 0.6em; |   font-size: 0.6em; | ||||||
|   margin-left: 0.75rem; |   margin-left: 0.75rem; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko