Add host-based authentication, UTC timestamps, session management, and secure cookies; fix styling issues; refactor to remove module; update database schema for sessions and reset tokens.
This commit is contained in:
		| @@ -2,13 +2,10 @@ | ||||
|   <div class="app-shell"> | ||||
|     <StatusMessage /> | ||||
|     <main class="app-main"> | ||||
|       <!-- Only render views after authentication status is determined --> | ||||
|       <template v-if="initialized"> | ||||
|         <LoginView v-if="store.currentView === 'login'" /> | ||||
|         <ProfileView v-if="store.currentView === 'profile'" /> | ||||
|         <DeviceLinkView v-if="store.currentView === 'device-link'" /> | ||||
|       </template> | ||||
|       <!-- Show loading state while determining auth status --> | ||||
|       <div v-else class="loading-container"> | ||||
|         <div class="loading-spinner"></div> | ||||
|         <p>Loading...</p> | ||||
| @@ -23,14 +20,11 @@ import { useAuthStore } from '@/stores/auth' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| import LoginView from '@/components/LoginView.vue' | ||||
| import ProfileView from '@/components/ProfileView.vue' | ||||
| import DeviceLinkView from '@/components/DeviceLinkView.vue' | ||||
| const store = useAuthStore() | ||||
| const initialized = ref(false) | ||||
|  | ||||
| onMounted(async () => { | ||||
|   // Load branding / settings first (non-blocking for auth flow) | ||||
|   await store.loadSettings() | ||||
|   // Was an error message passed in the URL hash? | ||||
|   const message = location.hash.substring(1) | ||||
|   if (message) { | ||||
|     store.showMessage(decodeURIComponent(message), 'error') | ||||
| @@ -48,31 +42,8 @@ onMounted(async () => { | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .loading-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   height: 100vh; | ||||
|   gap: 1rem; | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 4px solid var(--color-border); | ||||
|   border-top: 4px solid var(--color-primary); | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| .loading-container p { | ||||
|   color: var(--color-text-muted); | ||||
|   margin: 0; | ||||
| } | ||||
| .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 1rem; } | ||||
| .loading-spinner { width: 40px; height: 40px; border: 4px solid var(--color-border); border-top: 4px solid var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; } | ||||
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | ||||
| .loading-container p { color: var(--color-text-muted); margin: 0; } | ||||
| </style> | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ref } from 'vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import CredentialList from '@/components/CredentialList.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import SessionList from '@/components/SessionList.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
|  | ||||
| const props = defineProps({ | ||||
| @@ -57,18 +58,45 @@ function handleDelete(credential) { | ||||
|     /> | ||||
|     <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" :allow-delete="true" @delete="handleDelete" /> | ||||
|       <div class="registration-actions"> | ||||
|         <button | ||||
|           class="btn-secondary reg-token-btn" | ||||
|           @click="$emit('generateUserRegistrationLink', selectedUser)" | ||||
|           :disabled="loading" | ||||
|         >Generate Registration Token</button> | ||||
|         <p class="matrix-hint muted"> | ||||
|           Generate a one-time registration link so this user can register or add another passkey. | ||||
|           Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device. | ||||
|         </p> | ||||
|       </div> | ||||
|       <section class="section-block" data-section="registered-passkeys"> | ||||
|         <div class="section-header"> | ||||
|           <h2>Registered Passkeys</h2> | ||||
|         </div> | ||||
|         <div class="section-body"> | ||||
|           <CredentialList | ||||
|             :credentials="userDetail.credentials" | ||||
|             :aaguid-info="userDetail.aaguid_info" | ||||
|             :allow-delete="true" | ||||
|             @delete="handleDelete" | ||||
|           /> | ||||
|         </div> | ||||
|       </section> | ||||
|       <SessionList | ||||
|         :sessions="userDetail.sessions || []" | ||||
|         :allow-terminate="false" | ||||
|         :empty-message="'This user has no active sessions.'" | ||||
|         :section-description="'View the active sessions for this user.'" | ||||
|       /> | ||||
|     </template> | ||||
|     <div class="actions"> | ||||
|       <button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button> | ||||
|     <div class="actions ancillary-actions"> | ||||
|       <button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button> | ||||
|     </div> | ||||
|     <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> | ||||
|     <RegistrationLinkModal | ||||
|       v-if="showRegModal" | ||||
|       :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" | ||||
|       :auto-copy="false" | ||||
|       :user-name="userDetail?.display_name || selectedUser.display_name" | ||||
|       @close="$emit('closeRegModal')" | ||||
|       @copied="onLinkCopied" | ||||
|     /> | ||||
| @@ -77,9 +105,10 @@ function handleDelete(credential) { | ||||
|  | ||||
| <style scoped> | ||||
| .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } | ||||
| .cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); } | ||||
| .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } | ||||
| .actions button { width: auto; } | ||||
| .ancillary-actions { margin-top: -0.5rem; } | ||||
| .reg-token-btn { align-self: flex-start; } | ||||
| .registration-actions { display: flex; flex-direction: column; gap: 0.5rem; } | ||||
| .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } | ||||
| .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } | ||||
| .matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| /* Passkey Authentication – Unified Layout */ | ||||
|  | ||||
| :root { | ||||
|   color-scheme: light dark; | ||||
| @@ -440,60 +439,110 @@ th { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-list { | ||||
| :root { --card-width: 22rem; } | ||||
|  | ||||
| .record-list, | ||||
| .credential-list, | ||||
| .session-list { | ||||
|   width: 100%; | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: repeat(auto-fit, var(--card-width)); | ||||
|   justify-content: start; | ||||
|   gap: 1rem 1.25rem; | ||||
|   align-items: stretch; | ||||
|   margin: 0 auto; | ||||
|   max-width: calc(var(--card-width) * 4 + 3 * 1.25rem); | ||||
| } | ||||
|  | ||||
| .credential-item { | ||||
| @media (max-width: 1100px) { | ||||
|   .record-list, | ||||
|   .credential-list, | ||||
|   .session-list { max-width: calc(var(--card-width) * 3 + 2 * 1.25rem); } | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   /* Keep record-list responsive, but leave credential-list and session-list as fixed-width grid */ | ||||
|   .record-list { display: flex; flex-direction: column; max-width: 100%; } | ||||
| } | ||||
|  | ||||
| .record-item, | ||||
| .credential-item, | ||||
| .session-item { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.75rem; | ||||
|   padding: 0.85rem 1rem; | ||||
|   padding: 1rem; | ||||
|   border: 1px solid var(--color-border); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border-radius: var(--radius-md); | ||||
|   background: var(--color-surface); | ||||
|   height: 100%; | ||||
|   transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .credential-item.current-session { | ||||
|   border-color: var(--color-accent); | ||||
|   background: rgba(37, 99, 235, 0.08); | ||||
| .record-item:hover, | ||||
| .credential-item:hover, | ||||
| .session-item:hover { | ||||
|   border-color: var(--color-border-strong); | ||||
|   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); | ||||
|   transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .credential-header { | ||||
| .record-item.is-current, | ||||
| .credential-item.current-session, | ||||
| .session-item.is-current { border-color: var(--color-accent); } | ||||
|  | ||||
| .item-top { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 1rem; | ||||
|   align-items: flex-start; | ||||
|   flex-wrap: wrap; | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
|  | ||||
| .credential-icon { | ||||
| .item-icon { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   background: var(--color-surface-subtle, transparent); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border: 1px solid var(--color-border); | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .credential-info { | ||||
|   flex: 1 1 auto; | ||||
| .auth-icon { | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .credential-info h4 { | ||||
| .item-title { | ||||
|   flex: 1; | ||||
|   margin: 0; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 600; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| .item-actions { | ||||
|   flex-shrink: 0; | ||||
|   display: flex; | ||||
|   gap: 0.5rem; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .item-actions .badge + .btn-card-delete { margin-left: 0.25rem; } | ||||
| .item-actions .badge + .badge { margin-left: 0.25rem; } | ||||
|  | ||||
| .item-details { | ||||
|   margin-left: calc(40px + 1rem); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.5rem; | ||||
| } | ||||
|  | ||||
| .credential-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   grid-template-columns: 7rem 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   color: var(--color-text-muted); | ||||
| @@ -509,27 +558,59 @@ th { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-actions { | ||||
|   margin-left: auto; | ||||
| .btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } | ||||
| .btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); } | ||||
| .btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; } | ||||
|  | ||||
|  | ||||
| .session-emoji { | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .session-details { | ||||
|   font-size: 0.9rem; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .session-badges { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: var(--space-xs); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential { | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--color-danger); | ||||
|   padding: 0.25rem 0.35rem; | ||||
|   font-size: 1.05rem; | ||||
| .badge { | ||||
|   padding: 0.2rem 0.5rem; | ||||
|   border-radius: var(--radius-sm); | ||||
|   font-size: 0.8rem; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:hover:not(:disabled) { | ||||
|   background: rgba(220, 38, 38, 0.08); | ||||
| .badge-current { | ||||
|   background: var(--color-accent); | ||||
|   color: var(--color-accent-contrast); | ||||
|   box-shadow: 0 0 0 1px var(--color-accent) inset; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: not-allowed; | ||||
| .badge:not(.badge-current) { | ||||
|   background: var(--color-surface-subtle); | ||||
|   color: var(--color-text-muted); | ||||
|   border: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
|  | ||||
| .session-meta-info { | ||||
|   font-size: 0.8rem; | ||||
|   color: var(--color-text-muted); | ||||
|   font-family: monospace; | ||||
| } | ||||
|  | ||||
| .empty-state { | ||||
|   text-align: center; | ||||
|   padding: var(--space-lg); | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .empty-state p { | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| .user-info { | ||||
| @@ -597,7 +678,6 @@ th { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Dialog styles for auth views */ | ||||
| .dialog-backdrop { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   | ||||
| @@ -6,10 +6,10 @@ | ||||
|       <div | ||||
|         v-for="credential in credentials" | ||||
|         :key="credential.credential_uuid" | ||||
|         :class="['credential-item', { 'current-session': credential.is_current_session }]" | ||||
|         :class="['credential-item', { 'current-session': credential.is_current_session } ]" | ||||
|       > | ||||
|         <div class="credential-header"> | ||||
|           <div class="credential-icon"> | ||||
|         <div class="item-top"> | ||||
|           <div class="item-icon"> | ||||
|             <img | ||||
|               v-if="getCredentialAuthIcon(credential)" | ||||
|               :src="getCredentialAuthIcon(credential)" | ||||
| @@ -20,24 +20,28 @@ | ||||
|             > | ||||
|             <span v-else class="auth-emoji">🔑</span> | ||||
|           </div> | ||||
|           <div class="credential-info"> | ||||
|             <h4>{{ getCredentialAuthName(credential) }}</h4> | ||||
|           </div> | ||||
|           <div class="credential-dates"> | ||||
|             <span class="date-label">Created:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||
|             <span class="date-label" v-if="credential.last_used">Last used:</span> | ||||
|             <span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span> | ||||
|           </div> | ||||
|           <div class="credential-actions" v-if="allowDelete"> | ||||
|           <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4> | ||||
|           <div class="item-actions"> | ||||
|             <span v-if="credential.is_current_session" class="badge badge-current">Current</span> | ||||
|             <button | ||||
|               v-if="allowDelete" | ||||
|               @click="$emit('delete', credential)" | ||||
|               class="btn-delete-credential" | ||||
|               class="btn-card-delete" | ||||
|               :disabled="credential.is_current_session" | ||||
|               :title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'" | ||||
|             >🗑️</button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="item-details"> | ||||
|           <div class="credential-dates"> | ||||
|             <span class="date-label">Created:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||
|             <span class="date-label">Last used:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.last_used) }}</span> | ||||
|             <span class="date-label">Last verified:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.last_verified) }}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| @@ -67,121 +71,3 @@ const getCredentialAuthIcon = (credential) => { | ||||
|   return info[iconKey] || null | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .credential-list { | ||||
|   width: 100%; | ||||
|   margin-top: var(--space-sm); | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||
|   gap: 1rem 1.25rem; | ||||
|   align-items: stretch; | ||||
| } | ||||
|  | ||||
| .credential-item { | ||||
|   border: 1px solid var(--color-border); | ||||
|   border-radius: var(--radius-sm); | ||||
|   padding: 0.85rem 1rem; | ||||
|   background: var(--color-surface); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.75rem; | ||||
|   width: 28rem; | ||||
|   height: 100%; | ||||
|   transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; | ||||
| } | ||||
|  | ||||
| .credential-item:hover { | ||||
|   border-color: var(--color-border-strong); | ||||
|   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); | ||||
|   transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .credential-item.current-session { | ||||
|   border-color: var(--color-accent); | ||||
|   background: rgba(37, 99, 235, 0.08); | ||||
| } | ||||
|  | ||||
| .credential-header { | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 1rem; | ||||
|   flex-wrap: wrap; | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
|  | ||||
| .credential-icon { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   background: var(--color-surface-subtle, transparent); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
| .auth-icon { | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .credential-info { | ||||
|   flex: 1 1 150px; | ||||
|   min-width: 0; | ||||
| } | ||||
|  | ||||
| .credential-info h4 { | ||||
|   margin: 0; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 600; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| .credential-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   align-items: center; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .date-label { | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .date-value { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-actions { | ||||
|   margin-left: auto; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
|   color: var(--color-danger); | ||||
|   padding: 0.25rem 0.35rem; | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:hover:not(:disabled) { | ||||
|   background: rgba(220, 38, 38, 0.08); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| @media (max-width: 600px) { | ||||
|   .credential-list { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -5,74 +5,39 @@ | ||||
|         <h1>📱 Add Another Device</h1> | ||||
|         <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> | ||||
|       </header> | ||||
|       <section class="section-block"> | ||||
|         <div class="section-body"> | ||||
|           <div class="device-link-section"> | ||||
|             <div class="qr-container"> | ||||
|               <a :href="url" class="qr-link" @click="copyLink"> | ||||
|                 <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||
|                 <p v-if="url"> | ||||
|                   {{ url.replace(/^[^:]+:\/\//, '') }} | ||||
|                 </p> | ||||
|                 <p v-else> | ||||
|                   <em>Generating link...</em> | ||||
|                 </p> | ||||
|               </a> | ||||
|               <p> | ||||
|                 <strong>Scan and visit the URL on another device.</strong><br> | ||||
|                 <small>⚠️ Expires in 24 hours and can only be used once.</small> | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="button-row"> | ||||
|             <button @click="authStore.currentView = 'profile'" class="btn-secondary"> | ||||
|               Back to Profile | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
|       <RegistrationLinkModal | ||||
|         inline | ||||
|         :endpoint="'/auth/api/create-link'" | ||||
|         :user-name="userName" | ||||
|         :auto-copy="false" | ||||
|         :prefix-copy-with-user-name="!!userName" | ||||
|         show-close-in-inline | ||||
|         @copied="onCopied" | ||||
|       /> | ||||
|       <div class="button-row" style="margin-top:1rem;"> | ||||
|         <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, nextTick } from 'vue' | ||||
| import { ref, onMounted } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import QRCode from 'qrcode/lib/browser' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const url = ref(null) | ||||
| const qrCanvas = ref(null) | ||||
|  | ||||
| const copyLink = async (event) => { | ||||
|   event.preventDefault() | ||||
|   if (url.value) { | ||||
|     await navigator.clipboard.writeText(url.value) | ||||
|     authStore.showMessage('Link copied to clipboard!') | ||||
|     authStore.currentView = 'profile' | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function drawQr() { | ||||
|   if (!url.value || !qrCanvas.value) return | ||||
|   await nextTick() | ||||
|   QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => { | ||||
|     if (error) console.error('Failed to generate QR code:', error) | ||||
|   }) | ||||
| const userName = ref(null) | ||||
| const onCopied = () => { | ||||
|   authStore.showMessage('Link copied to clipboard!', 'success', 2500) | ||||
|   authStore.currentView = 'profile' | ||||
| } | ||||
|  | ||||
| onMounted(async () => { | ||||
|   try { | ||||
|     const response = await fetch('/auth/api/create-link', { method: 'POST' }) | ||||
|     const result = await response.json() | ||||
|     if (result.detail) throw new Error(result.detail) | ||||
|  | ||||
|     url.value = result.url | ||||
|     await drawQr() | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(`Failed to create device link: ${error.message}`, 'error') | ||||
|     authStore.currentView = 'profile' | ||||
|   } | ||||
|   // Extract optional admin-provided query parameters (?user=Name&emoji=😀) | ||||
|   const params = new URLSearchParams(location.search) | ||||
|   const qUser = params.get('user') | ||||
|   if (qUser) userName.value = qUser.trim() | ||||
| }) | ||||
|  | ||||
| </script> | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|           :created-at="authStore.userInfo.user.created_at" | ||||
|           :last-seen="authStore.userInfo.user.last_seen" | ||||
|           :loading="authStore.isLoading" | ||||
|           update-endpoint="/auth/api/user/display-name" | ||||
|           update-endpoint="/auth/api/user-display-name" | ||||
|           @saved="authStore.loadUserInfo()" | ||||
|           @edit-name="openNameDialog" | ||||
|         /> | ||||
| @@ -35,25 +35,19 @@ | ||||
|             @delete="handleDelete" | ||||
|           /> | ||||
|           <div class="button-row"> | ||||
|             <button @click="addNewCredential" class="btn-primary"> | ||||
|               Add New Passkey | ||||
|             </button> | ||||
|             <button @click="authStore.currentView = 'device-link'" class="btn-secondary"> | ||||
|               Add Another Device | ||||
|             </button> | ||||
|             <button @click="addNewCredential" class="btn-primary">Add New Passkey</button> | ||||
|             <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="button-row"> | ||||
|           <button @click="logout" class="btn-danger logout-button"> | ||||
|             Logout | ||||
|           </button> | ||||
|         </div> | ||||
|       </section> | ||||
|       <SessionList | ||||
|         :sessions="sessions" | ||||
|         :terminating-sessions="terminatingSessions" | ||||
|         @terminate="terminateSession" | ||||
|         section-description="Review where you're signed in and end any sessions you no longer recognize." | ||||
|       /> | ||||
|  | ||||
|       <!-- Name Edit Dialog --> | ||||
|       <Modal v-if="showNameDialog" @close="showNameDialog = false"> | ||||
|         <h3>Edit Display Name</h3> | ||||
|         <form @submit.prevent="saveName" class="modal-form"> | ||||
| @@ -65,6 +59,21 @@ | ||||
|           /> | ||||
|         </form> | ||||
|       </Modal> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="button-row logout-row single"> | ||||
|           <button @click="logoutEverywhere" class="btn-danger logout-button">Logout all sessions</button> | ||||
|         </div> | ||||
|         <p class="logout-note">Immediately revokes access for every device and browser signed in to your account.</p> | ||||
|       </section> | ||||
|       <RegistrationLinkModal | ||||
|         v-if="showRegLink" | ||||
|         :endpoint="'/auth/api/create-link'" | ||||
|         :auto-copy="false" | ||||
|         :prefix-copy-with-user-name="false" | ||||
|         @close="showRegLink = false" | ||||
|         @copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)" | ||||
|       /> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
| @@ -76,35 +85,25 @@ import CredentialList from '@/components/CredentialList.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import Modal from '@/components/Modal.vue' | ||||
| import NameEditForm from '@/components/NameEditForm.vue' | ||||
| import SessionList from '@/components/SessionList.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import passkey from '@/utils/passkey' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const updateInterval = ref(null) | ||||
| const showNameDialog = ref(false) | ||||
| const showRegLink = ref(false) | ||||
| const newName = ref('') | ||||
| const saving = ref(false) | ||||
|  | ||||
| watch(showNameDialog, (newVal) => { | ||||
|   if (newVal) { | ||||
|     newName.value = authStore.userInfo?.user?.user_name || '' | ||||
|   } | ||||
| }) | ||||
| watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   updateInterval.value = setInterval(() => { | ||||
|     // Trigger Vue reactivity to update formatDate fields | ||||
|     if (authStore.userInfo) { | ||||
|       authStore.userInfo = { ...authStore.userInfo } | ||||
|     } | ||||
|   }, 60000) // Update every minute | ||||
|   updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) | ||||
| }) | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   if (updateInterval.value) { | ||||
|     clearInterval(updateInterval.value) | ||||
|   } | ||||
| }) | ||||
| onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) | ||||
|  | ||||
| const addNewCredential = async () => { | ||||
|   try { | ||||
| @@ -116,9 +115,7 @@ const addNewCredential = async () => { | ||||
|   } catch (error) { | ||||
|     console.error('Failed to add new passkey:', error) | ||||
|     authStore.showMessage(error.message, 'error') | ||||
|   } finally { | ||||
|     authStore.isLoading = false | ||||
|   } | ||||
|   } finally { authStore.isLoading = false } | ||||
| } | ||||
|  | ||||
| const handleDelete = async (credential) => { | ||||
| @@ -128,80 +125,55 @@ const handleDelete = async (credential) => { | ||||
|   try { | ||||
|     await authStore.deleteCredential(credentialId) | ||||
|     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') | ||||
|   } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') } | ||||
| } | ||||
|  | ||||
| const sessions = computed(() => authStore.userInfo?.sessions || []) | ||||
| const terminatingSessions = ref({}) | ||||
|  | ||||
| const terminateSession = async (session) => { | ||||
|   const sessionId = session?.id | ||||
|   if (!sessionId) return | ||||
|   terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } | ||||
|   try { await authStore.terminateSession(sessionId) } | ||||
|   catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) } | ||||
|   finally { | ||||
|     const next = { ...terminatingSessions.value } | ||||
|     delete next[sessionId] | ||||
|     terminatingSessions.value = next | ||||
|   } | ||||
| } | ||||
|  | ||||
| const logout = async () => { | ||||
|   await authStore.logout() | ||||
| } | ||||
|  | ||||
| const openNameDialog = () => { | ||||
|   newName.value = authStore.userInfo?.user?.user_name || '' | ||||
|   showNameDialog.value = true | ||||
| } | ||||
|  | ||||
| const logoutEverywhere = async () => { await authStore.logoutEverywhere() } | ||||
| const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true } | ||||
| const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | ||||
|  | ||||
| const breadcrumbEntries = computed(() => { | ||||
|   const entries = [{ label: 'Auth', href: authStore.uiHref() }] | ||||
|   if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }) | ||||
|   return entries | ||||
| }) | ||||
| const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: authStore.uiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }); return entries }) | ||||
|  | ||||
| const saveName = async () => { | ||||
|   const name = newName.value.trim() | ||||
|   if (!name) { | ||||
|     authStore.showMessage('Name cannot be empty', 'error') | ||||
|     return | ||||
|   } | ||||
|   if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return } | ||||
|   try { | ||||
|     saving.value = true | ||||
|     const res = await fetch('/auth/api/user/display-name', { | ||||
|       method: 'PUT', | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       body: JSON.stringify({ display_name: name }) | ||||
|     }) | ||||
|     const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) | ||||
|     const data = await res.json() | ||||
|     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||
|   showNameDialog.value = false | ||||
|     showNameDialog.value = false | ||||
|     await authStore.loadUserInfo() | ||||
|     authStore.showMessage('Name updated successfully!', 'success', 3000) | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Failed to update name', 'error') | ||||
|   } finally { | ||||
|     saving.value = false | ||||
|   } | ||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } | ||||
|   finally { saving.value = false } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .view-lede { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
|   font-size: 1rem; | ||||
| } | ||||
|  | ||||
| .section-header { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.4rem; | ||||
| } | ||||
|  | ||||
| .section-description { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .logout-button { | ||||
|   align-self: flex-start; | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   .logout-button { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; } | ||||
| .section-header { display: flex; flex-direction: column; gap: 0.4rem; } | ||||
| .section-description { margin: 0; color: var(--color-text-muted); } | ||||
| .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; } | ||||
| .logout-button { align-self: flex-start; } | ||||
| .logout-row { gap: 1rem; } | ||||
| .logout-row.single { justify-content: flex-start; } | ||||
| .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } | ||||
| @media (max-width: 720px) { .logout-button { width: 100%; } } | ||||
| </style> | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| <template> | ||||
|   <div class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> | ||||
|   <div v-if="!inline" class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> | ||||
|     <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> | ||||
|       <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;"> | ||||
|         <h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2> | ||||
|       <div class="reg-header-row"> | ||||
|         <h2 id="regTitle" class="reg-title"> | ||||
|           📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span> | ||||
|         </h2> | ||||
|         <button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button> | ||||
|       </div> | ||||
|       <div class="device-link-section"> | ||||
| @@ -14,28 +16,62 @@ | ||||
|           <div v-else> | ||||
|             <em>Generating link...</em> | ||||
|           </div> | ||||
|           <p> | ||||
|             <strong>Scan and visit the URL on another device.</strong><br> | ||||
|             <small>⚠️ Expires in 24 hours and one-time use.</small> | ||||
|           <p class="reg-help"> | ||||
|             <span v-if="userName">The user should open this link on the device where they want to register.</span> | ||||
|             <span v-else>Open or scan this link on the device you wish to register to your account.</span> | ||||
|             <br><small>{{ expirationMessage }}</small> | ||||
|           </p> | ||||
|           <div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;"> | ||||
|       <div class="reg-actions"> | ||||
|         <button class="btn-secondary" @click="$emit('close')">Close</button> | ||||
|         <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div v-else class="registration-inline-wrapper"> | ||||
|     <div class="registration-inline-block section-block"> | ||||
|       <div class="section-header"> | ||||
|         <h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2> | ||||
|       </div> | ||||
|       <div class="section-body"> | ||||
|         <div class="device-link-section"> | ||||
|           <div class="qr-container"> | ||||
|             <a v-if="url" :href="url" @click.prevent="copy" class="qr-link"> | ||||
|               <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||
|               <p>{{ displayUrl }}</p> | ||||
|             </a> | ||||
|             <div v-else> | ||||
|               <em>Generating link...</em> | ||||
|             </div> | ||||
|             <p class="reg-help"> | ||||
|               <span v-if="userName">The user should open this link on the device where they want to register.</span> | ||||
|               <span v-else>Open this link on the device you wish to connect with.</span> | ||||
|               <br><small>{{ expirationMessage }}</small> | ||||
|             </p> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="button-row" style="margin-top:1rem;"> | ||||
|           <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> | ||||
|           <button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, watch, computed, nextTick } from 'vue' | ||||
| import QRCode from 'qrcode/lib/browser' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   endpoint: { type: String, required: true }, // POST endpoint returning {url, expires} | ||||
|   autoCopy: { type: Boolean, default: true } | ||||
|   endpoint: { type: String, required: true }, | ||||
|   autoCopy: { type: Boolean, default: true }, | ||||
|   userName: { type: String, default: null }, | ||||
|   inline: { type: Boolean, default: false }, | ||||
|   showCloseInInline: { type: Boolean, default: false }, | ||||
|   prefixCopyWithUserName: { type: Boolean, default: false } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['close','generated','copied']) | ||||
| @@ -46,6 +82,16 @@ const qrCanvas = ref(null) | ||||
|  | ||||
| const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') | ||||
|  | ||||
| const expirationMessage = computed(() => { | ||||
|   if (!expires.value) return '⚠️ Expires soon and can only be used once.' | ||||
|   const timeStr = formatDate(expires.value) | ||||
|   if (timeStr.startsWith('In ')) { | ||||
|     return `⚠️ Expires ${timeStr.substring(3)} and can only be used once.` | ||||
|   } else { | ||||
|     return `⚠️ Expires ${timeStr} and can only be used once.` | ||||
|   } | ||||
| }) | ||||
|  | ||||
| async function fetchLink() { | ||||
|   try { | ||||
|     const res = await fetch(props.endpoint, { method: 'POST' }) | ||||
| @@ -73,15 +119,35 @@ async function drawQR() { | ||||
|  | ||||
| async function copy() { | ||||
|   if (!url.value) return | ||||
|   try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ } | ||||
|   let text = url.value | ||||
|   if (props.prefixCopyWithUserName && props.userName) { | ||||
|     text = `${props.userName} ${text}` | ||||
|   } | ||||
|   try { | ||||
|     await navigator.clipboard.writeText(text) | ||||
|     emit('copied', text) | ||||
|     if (!props.inline) emit('close') | ||||
|   } catch (_) { | ||||
|     /* ignore */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(fetchLink) | ||||
| watch(url, () => drawQR(), { flush: 'post' }) | ||||
|  | ||||
| </script> | ||||
| <style scoped> | ||||
| .icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } | ||||
| .icon-btn:hover { opacity:1; } | ||||
| /* Minimal extra styling; main look comes from global styles */ | ||||
| .qr-link { text-decoration:none; color:inherit; } | ||||
| .reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; } | ||||
| .reg-title { margin:0; font-size:1.25rem; font-weight:600; } | ||||
| .device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); } | ||||
| .qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; } | ||||
| .qr-code { display:block; } | ||||
| .reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; } | ||||
| .reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; } | ||||
| .registration-inline-block .qr-container { align-items:flex-start; } | ||||
| .registration-inline-block .reg-help { text-align:left; } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										64
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| <template> | ||||
|   <section class="section-block" data-component="session-list-section"> | ||||
|     <div class="section-header"> | ||||
|       <h2>Active Sessions</h2> | ||||
|       <p class="section-description">{{ sectionDescription }}</p> | ||||
|     </div> | ||||
|     <div class="section-body"> | ||||
|       <div :class="['session-list']"> | ||||
|         <template v-if="Array.isArray(sessions) && sessions.length"> | ||||
|           <div | ||||
|             v-for="session in sessions" | ||||
|             :key="session.id" | ||||
|             :class="['session-item', { 'is-current': session.is_current }]" | ||||
|           > | ||||
|             <div class="item-top"> | ||||
|               <div class="item-icon"> | ||||
|                 <span class="session-emoji">🌐</span> | ||||
|               </div> | ||||
|               <h4 class="item-title">{{ sessionHostLabel(session) }}</h4> | ||||
|               <div class="item-actions"> | ||||
|                 <span v-if="session.is_current" class="badge badge-current">Current</span> | ||||
|                 <span v-else-if="session.is_current_host" class="badge">This host</span> | ||||
|                 <button | ||||
|                   v-if="allowTerminate" | ||||
|                   @click="$emit('terminate', session)" | ||||
|                   class="btn-card-delete" | ||||
|                   :disabled="isTerminating(session.id)" | ||||
|                   :title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'" | ||||
|                 >🗑️</button> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="item-details"> | ||||
|               <div class="session-details">Last used: {{ formatDate(session.last_renewed) }}</div> | ||||
|               <div class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </template> | ||||
|         <div v-else class="empty-state"><p>{{ emptyMessage }}</p></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { } from 'vue' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   sessions: { type: Array, default: () => [] }, | ||||
|   allowTerminate: { type: Boolean, default: true }, | ||||
|   emptyMessage: { type: String, default: 'You currently have no other active sessions.' }, | ||||
|   sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." }, | ||||
|   terminatingSessions: { type: Object, default: () => ({}) } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['terminate']) | ||||
|  | ||||
| const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId] | ||||
|  | ||||
| const sessionHostLabel = (session) => { | ||||
|   if (!session || !session.host) return 'Unbound host' | ||||
|   return session.host | ||||
| } | ||||
| </script> | ||||
| @@ -4,7 +4,7 @@ import { register, authenticate } from '@/utils/passkey' | ||||
| export const useAuthStore = defineStore('auth', { | ||||
|   state: () => ({ | ||||
|     // Auth State | ||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} | ||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} | ||||
|     settings: null, // Server provided settings (/auth/settings) | ||||
|     isLoading: false, | ||||
|  | ||||
| @@ -91,8 +91,7 @@ export const useAuthStore = defineStore('auth', { | ||||
|     }, | ||||
|     selectView() { | ||||
|       if (!this.userInfo) this.currentView = 'login' | ||||
|       else if (this.userInfo.authenticated) this.currentView = 'profile' | ||||
|       else this.currentView = 'login' | ||||
|       else this.currentView = 'profile' | ||||
|     }, | ||||
|     async loadUserInfo() { | ||||
|       const response = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||
| @@ -134,9 +133,44 @@ export const useAuthStore = defineStore('auth', { | ||||
|  | ||||
|       await this.loadUserInfo() | ||||
|     }, | ||||
|     async terminateSession(sessionId) { | ||||
|       try { | ||||
|         const res = await fetch(`/auth/api/session/${sessionId}`, { method: 'DELETE' }) | ||||
|         let payload = null | ||||
|         try { | ||||
|           payload = await res.json() | ||||
|         } catch (_) { | ||||
|           // ignore JSON parse errors | ||||
|         } | ||||
|         if (!res.ok || payload?.detail) { | ||||
|           const message = payload?.detail || 'Failed to terminate session' | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         if (payload?.current_session_terminated) { | ||||
|           sessionStorage.clear() | ||||
|           location.reload() | ||||
|           return | ||||
|         } | ||||
|         await this.loadUserInfo() | ||||
|         this.showMessage('Session terminated', 'success', 2500) | ||||
|       } catch (error) { | ||||
|         console.error('Terminate session error:', error) | ||||
|         throw error | ||||
|       } | ||||
|     }, | ||||
|     async logout() { | ||||
|       try { | ||||
|         await fetch('/auth/api/logout', {method: 'POST'}) | ||||
|         const res = await fetch('/auth/api/logout', {method: 'POST'}) | ||||
|         if (!res.ok) { | ||||
|           let message = 'Logout failed' | ||||
|           try { | ||||
|             const data = await res.json() | ||||
|             if (data?.detail) message = data.detail | ||||
|           } catch (_) { | ||||
|             // ignore JSON parse errors | ||||
|           } | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         sessionStorage.clear() | ||||
|         location.reload() | ||||
|       } catch (error) { | ||||
| @@ -144,5 +178,25 @@ export const useAuthStore = defineStore('auth', { | ||||
|         this.showMessage(error.message, 'error') | ||||
|       } | ||||
|     }, | ||||
|     async logoutEverywhere() { | ||||
|       try { | ||||
|         const res = await fetch('/auth/api/logout-all', {method: 'POST'}) | ||||
|         if (!res.ok) { | ||||
|           let message = 'Logout failed' | ||||
|           try { | ||||
|             const data = await res.json() | ||||
|             if (data?.detail) message = data.detail | ||||
|           } catch (_) { | ||||
|             // ignore JSON parse errors | ||||
|           } | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         sessionStorage.clear() | ||||
|         location.reload() | ||||
|       } catch (error) { | ||||
|         console.error('Logout-all error:', error) | ||||
|         this.showMessage(error.message, 'error') | ||||
|       } | ||||
|     }, | ||||
|   } | ||||
| }) | ||||
|   | ||||
| @@ -5,16 +5,18 @@ export function formatDate(dateString) { | ||||
|  | ||||
|   const date = new Date(dateString) | ||||
|   const now = new Date() | ||||
|   const diffMs = now - date | ||||
|   const diffMinutes = Math.floor(diffMs / (1000 * 60)) | ||||
|   const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) | ||||
|   const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) | ||||
|   const diffMs = date - now  // Changed to date - now for future/past | ||||
|   const isFuture = diffMs > 0 | ||||
|   const absDiffMs = Math.abs(diffMs) | ||||
|   const diffMinutes = Math.round(absDiffMs / (1000 * 60)) | ||||
|   const diffHours = Math.round(absDiffMs / (1000 * 60 * 60)) | ||||
|   const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24)) | ||||
|  | ||||
|   if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString() | ||||
|   if (diffMinutes === 0) return 'Just now' | ||||
|   if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` | ||||
|   if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` | ||||
|   return diffDays === 1 ? 'a day ago' : `${diffDays} days ago` | ||||
|   if (absDiffMs < 1000 * 60) return 'Now' | ||||
|   if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` | ||||
|   if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` | ||||
|   if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago` | ||||
|   return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) | ||||
| } | ||||
|  | ||||
| export function getCookie(name) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko