Unify user info across admin app and profile view.
This commit is contained in:
		| @@ -1,6 +1,7 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||
| import CredentialList from '@/components/CredentialList.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| 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 closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } } | ||||
|  | ||||
| // Admin user rename | ||||
| const editingUserName = ref(false) | ||||
| const editUserNameValue = ref('') | ||||
| const editUserNameValid = computed(()=> true) // backend validates | ||||
| function beginEditUserName() { | ||||
|   if (!selectedUser.value) return | ||||
|   editingUserName.value = true | ||||
|   editUserNameValue.value = '' | ||||
| } | ||||
| 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 | ||||
| async function onUserNameSaved() { | ||||
|   await loadOrgs() | ||||
|   if (selectedUser.value) { | ||||
|     try { | ||||
|       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') | ||||
|       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') | ||||
|     } catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') } | ||||
|   } | ||||
|   authStore.showMessage('User renamed', 'success', 1500) | ||||
| } | ||||
|  | ||||
| async function submitDialog() { | ||||
| @@ -488,24 +476,26 @@ async function submitDialog() { | ||||
|  | ||||
|         <!-- User Detail Page --> | ||||
|         <div v-if="selectedUser" class="card user-detail"> | ||||
|           <h2 class="user-title"> | ||||
|             <span v-if="!editingUserName">{{ userDetail?.display_name || selectedUser.display_name }} <button class="icon-btn" @click="beginEditUserName" title="Rename user">✏️</button></span> | ||||
|             <span v-else> | ||||
|               <input v-model="editUserNameValue" :placeholder="userDetail?.display_name || selectedUser.display_name" maxlength="64" @keyup.enter="submitEditUserName" /> | ||||
|               <button class="icon-btn" @click="submitEditUserName">💾</button> | ||||
|               <button class="icon-btn" @click="cancelEditUserName">✖</button> | ||||
|             </span> | ||||
|           </h2> | ||||
|           <div v-if="userDetail && !userDetail.error" class="user-meta"> | ||||
|             <p class="small">Organization: {{ userDetail.org.display_name }}</p> | ||||
|             <p class="small">Role: {{ userDetail.role }}</p> | ||||
|             <p class="small">Visits: {{ userDetail.visits }}</p> | ||||
|             <p class="small">Created: {{ userDetail.created_at ? new Date(userDetail.created_at).toLocaleString() : '—' }}</p> | ||||
|             <p class="small">Last Seen: {{ userDetail.last_seen ? new Date(userDetail.last_seen).toLocaleString() : '—' }}</p> | ||||
|           <UserBasicInfo | ||||
|             v-if="userDetail && !userDetail.error" | ||||
|             :name="userDetail.display_name || selectedUser.display_name" | ||||
|             :visits="userDetail.visits" | ||||
|             :created-at="userDetail.created_at" | ||||
|             :last-seen="userDetail.last_seen" | ||||
|             :loading="loading" | ||||
|             :update-endpoint="`/auth/admin/users/${selectedUser.uuid}/display-name`" | ||||
|             @saved="onUserNameSaved" | ||||
|           > | ||||
|             <span><strong>Organization:</strong></span> | ||||
|             <span>{{ userDetail.org.display_name }}</span> | ||||
|             <span><strong>Role:</strong></span> | ||||
|             <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> | ||||
|             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> | ||||
|           </div> | ||||
|           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> | ||||
|           </template> | ||||
|           <div class="actions"> | ||||
|             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</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="view active"> | ||||
|   <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"> | ||||
|         <h3 class="user-name-heading"> | ||||
|           <span class="icon">👤</span> | ||||
|           <span v-if="!editingName" class="user-name-row"> | ||||
|             <span class="display-name" :title="authStore.userInfo.user.user_name">{{ authStore.userInfo.user.user_name }}</span> | ||||
|             <button class="mini-btn" @click="startEdit" title="Edit name">✏️</button> | ||||
|           </span> | ||||
|           <span v-else class="user-name-row editing"> | ||||
|             <input | ||||
|               v-model="newName" | ||||
|               class="name-input" | ||||
|               :placeholder="authStore.userInfo.user.user_name" | ||||
|               :disabled="authStore.isLoading" | ||||
|               maxlength="64" | ||||
|               @keyup.enter="saveName" | ||||
|       <UserBasicInfo | ||||
|         v-if="authStore.userInfo?.user" | ||||
|         :name="authStore.userInfo.user.user_name" | ||||
|         :visits="authStore.userInfo.user.visits || 0" | ||||
|         :created-at="authStore.userInfo.user.created_at" | ||||
|         :last-seen="authStore.userInfo.user.last_seen" | ||||
|         :loading="authStore.isLoading" | ||||
|         update-endpoint="/auth/user/display-name" | ||||
|         @saved="authStore.loadUserInfo()" | ||||
|       /> | ||||
|             <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> | ||||
|       <div class="credential-list"> | ||||
| @@ -100,6 +83,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
| import passkey from '@/utils/passkey' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const updateInterval = ref(null) | ||||
| @@ -150,7 +134,6 @@ const addNewCredential = async () => { | ||||
|  | ||||
| const deleteCredential = async (credentialId) => { | ||||
|   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||
|  | ||||
|   try { | ||||
|     await authStore.deleteCredential(credentialId) | ||||
|     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)) | ||||
|  | ||||
| // 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> | ||||
|  | ||||
| <style scoped> | ||||
| .user-info { | ||||
|   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> | ||||
| /* Removed inline user info styles; now provided by UserBasicInfo component */ | ||||
| .admin-link { | ||||
|   font-size: 0.6em; | ||||
|   margin-left: 0.75rem; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko