Profile view UX improvements. More consistent styling across the application.

This commit is contained in:
2025-12-09 21:20:29 +00:00
parent bb34e52997
commit 9b491164fd
12 changed files with 140 additions and 173 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
+11 -4
View File
@@ -1,4 +1,11 @@
<html style="background: transparent"><meta charset="UTF-8"> <!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <html lang="en" style="background: transparent">
<div id="app"></div> <head>
<script type="module" src="/auth/restricted/main.js"></script> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="app"></div>
<script type="module" src="/auth/restricted/main.js"></script>
</body>
</html>
+61 -66
View File
@@ -37,6 +37,7 @@
--layout-max-width: 1400px; --layout-max-width: 1400px;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem); --layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease; --transition-base: 160ms ease;
--focus-ring: 0 0 0 2px var(--color-accent);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -84,6 +85,10 @@ body {
color: var(--color-text); color: var(--color-text);
line-height: 1.55; line-height: 1.55;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
-moz-hyphens: auto;
} }
body, body,
@@ -108,13 +113,6 @@ a:visited {
a:hover, a:hover,
a:focus-visible { a:focus-visible {
color: var(--color-link-hover); color: var(--color-link-hover);
text-decoration: underline;
}
a:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
} }
.app-shell { .app-shell {
@@ -187,6 +185,11 @@ a:focus-visible {
gap: 1rem; gap: 1rem;
} }
.section-description {
margin: 0;
color: var(--color-text-muted);
}
.button-row { .button-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -214,7 +217,6 @@ button {
border: 1px solid transparent; border: 1px solid transparent;
padding: 0.65rem 1.1rem; padding: 0.65rem 1.1rem;
cursor: pointer; cursor: pointer;
transition: all var(--transition-base);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -223,11 +225,6 @@ button {
color: var(--color-text); color: var(--color-text);
} }
button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
filter: opacity(0.6); filter: opacity(0.6);
@@ -437,7 +434,7 @@ th {
color: var(--color-text); color: var(--color-text);
} }
:root { --card-width: 22rem; } :root { --card-width: 16rem; }
.record-list, .record-list,
.credential-list, .credential-list,
@@ -447,7 +444,7 @@ th {
grid-auto-flow: row; grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr)); grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
justify-content: start; justify-content: start;
gap: 1rem 1.25rem; gap: 0.5rem;
align-items: stretch; align-items: stretch;
margin: 0 auto; margin: 0 auto;
} }
@@ -461,9 +458,7 @@ th {
.session-item { .session-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; padding: 0.5rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface); background: var(--color-surface);
height: 100%; height: 100%;
@@ -484,7 +479,7 @@ th {
.credential-item.current-session, .credential-item.current-session,
.credential-item.is-hovered, .credential-item.is-hovered,
.session-item.is-current, .session-item.is-current,
.session-item.is-hovered { border-color: var(--color-accent); } .session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
.credential-item.is-linked-session, .credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); } .session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
@@ -492,7 +487,7 @@ th {
.item-top { .item-top {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; height: 2.5rem;
} }
.item-icon { .item-icon {
@@ -500,9 +495,6 @@ th {
height: 40px; height: 40px;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--color-surface-subtle, transparent);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -516,12 +508,14 @@ th {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.item-actions { .item-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
gap: 0.5rem;
align-items: center; align-items: center;
} }
@@ -529,39 +523,30 @@ th {
.item-actions .badge + .badge { margin-left: 0.25rem; } .item-actions .badge + .badge { margin-left: 0.25rem; }
.item-details { .item-details {
margin-left: calc(40px + 1rem); margin-left: 40px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.credential-dates {
display: grid;
grid-auto-flow: row;
grid-template-columns: 7rem 1fr;
gap: 0.35rem 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
align-items: center;
} }
.credential-dates,
.session-dates { .session-dates {
display: grid; display: grid;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-columns: 7rem 1fr; grid-template-columns: 8em 1fr;
gap: 0.35rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-muted);
align-items: center; align-items: center;
} }
.date-label { .date-label {
font-weight: 500; font-weight: 500;
color: inherit; color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
} }
.date-value { .date-value {
color: var(--color-text); color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.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 { 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; }
@@ -578,6 +563,7 @@ th {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
white-space: nowrap;
} }
.badge-current { .badge-current {
@@ -610,28 +596,18 @@ th {
} }
.user-info { .user-info {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1.1rem 1.25rem;
display: grid; display: grid;
grid-template-columns: auto 1fr; border-radius: var(--radius-md);
gap: 0.75rem 1.25rem; background: var(--color-surface);
padding: 1.1rem 1.25rem;
} }
.user-info h3 { .user-details {
margin: 0; display: grid;
grid-column: span 2; grid-template-columns: 7em 1fr;
display: flex; gap: 0 0.5rem;
align-items: center; padding-left: 40px;
gap: 0.5rem; font-size: 0.75rem;
font-size: 1.15rem;
font-weight: 600;
}
.user-info span {
text-align: left;
color: var(--color-text);
} }
.toggle-link { .toggle-link {
@@ -653,11 +629,6 @@ th {
gap: 1.75rem; gap: 1.75rem;
} }
.credential-dates {
grid-auto-flow: row;
grid-template-columns: auto auto;
}
.global-status { .global-status {
top: 1rem; top: 1rem;
} }
@@ -738,3 +709,27 @@ body:has(#auth-iframe) {
backdrop-filter: blur(.1rem) brightness(0.7); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7); -webkit-backdrop-filter: blur(.1rem) brightness(0.7);
} }
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 1.8em;
position: relative;
}
-1
View File
@@ -33,6 +33,5 @@ const crumbs = computed(() => {
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; } .breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; }
.breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; } .breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; }
.breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; } .breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; }
.breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; }
.breadcrumbs .sep { color: var(--color-text-muted); margin: 0; } .breadcrumbs .sep { color: var(--color-text-muted); margin: 0; }
</style> </style>
+18 -1
View File
@@ -12,6 +12,8 @@
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid 'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
}]" }]"
tabindex="0" tabindex="0"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.credential_uuid)" @focusin="handleCredentialFocus(credential.credential_uuid)"
@focusout="handleCredentialBlur($event)" @focusout="handleCredentialBlur($event)"
@keydown="handleDelete($event, credential)" @keydown="handleDelete($event, credential)"
@@ -59,7 +61,6 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
@@ -84,6 +85,13 @@ const handleCredentialBlur = (event) => {
} }
} }
const handleCardClick = (event) => {
if (!event.currentTarget.matches(':focus')) {
event.currentTarget.focus()
event.stopPropagation()
}
}
const handleDelete = (event, credential) => { const handleDelete = (event, credential) => {
const apple = navigator.userAgent.includes('Mac OS') const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') { if (event.key === 'Delete' || apple && event.key === 'Backspace') {
@@ -105,3 +113,12 @@ const getCredentialAuthIcon = (credential) => {
return info[iconKey] || null return info[iconKey] || null
} }
</script> </script>
<style>
.btn-card-delete {
display: none;
}
.credential-item:focus .btn-card-delete {
display: block;
}
</style>
+14 -11
View File
@@ -19,24 +19,25 @@
@edit-name="openNameDialog" @edit-name="openNameDialog"
> >
<div class="remote-auth-inline"> <div class="remote-auth-inline">
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words from remote device:</label> <label v-if="!showDeviceInfo" class="remote-auth-label">Code words:</label>
<RemoteAuth <RemoteAuthPermit
ref="pairingEntry" ref="pairingEntry"
title="" title=""
description="" description=""
placeholder="word word word"
@completed="handlePairingCompleted" @completed="handlePairingCompleted"
@error="handlePairingError" @error="handlePairingError"
@device-info-visible="showDeviceInfo = $event" @device-info-visible="showDeviceInfo = $event"
/> />
</div> </div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
</UserBasicInfo> </UserBasicInfo>
</section> </section>
<section class="section-block"> <section class="section-block">
<div class="section-header"> <div class="section-header">
<h2>Your Passkeys</h2> <h2>Your Passkeys</h2>
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p> <p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login.</p>
<p><a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
</div> </div>
<div class="section-body"> <div class="section-body">
<CredentialList <CredentialList
@@ -50,8 +51,8 @@
@credential-hover="hoveredCredentialUuid = $event" @credential-hover="hoveredCredentialUuid = $event"
/> />
<div class="button-row"> <div class="button-row">
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button> <button @click="addNewCredential" class="btn-primary">Register New</button>
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button> <button @click="showRegLink = true" class="btn-secondary">Another Device</button>
</div> </div>
</div> </div>
</section> </section>
@@ -62,7 +63,7 @@
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
@terminate="terminateSession" @terminate="terminateSession"
@session-hover="hoveredSession = $event" @session-hover="hoveredSession = $event"
section-description="Review where you're signed in and end any sessions you no longer recognize." section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/> />
<Modal v-if="showNameDialog" @close="showNameDialog = false"> <Modal v-if="showNameDialog" @close="showNameDialog = false">
@@ -112,7 +113,7 @@ import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue' import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuth from '@/components/RemoteAuthPermit.vue' import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
@@ -167,10 +168,9 @@ const handlePairingError = (message) => {
const handleDelete = async (credential) => { const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid const credentialId = credential?.credential_uuid
if (!credentialId) return if (!credentialId) 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! You should also remove it from your password manager or device.', 'success', 3000)
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') } } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
} }
@@ -219,9 +219,12 @@ const saveName = async () => {
<style scoped> <style scoped>
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; } .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.section-header { display: flex; flex-direction: column; gap: 0.4rem; } .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; } .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; } .remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; } .remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description {
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style> </style>
+2 -21
View File
@@ -95,7 +95,7 @@ const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' }, title: { type: String, default: 'Help Another Device Sign In' },
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' }, description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter three words' }, placeholder: { type: String, default: 'Enter three words' },
action: { type: String, default: 'login' } // 'login' or 'register' action: { type: String, default: 'login' }, // 'login' or 'register'
}) })
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible']) const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
@@ -654,7 +654,6 @@ function reset() {
onMounted(async () => { onMounted(async () => {
await fetchSettings() await fetchSettings()
inputRef.value?.focus()
// Initialize cursor position // Initialize cursor position
nextTick(() => { nextTick(() => {
cursorPos.value = inputRef.value?.selectionStart ?? 0 cursorPos.value = inputRef.value?.selectionStart ?? 0
@@ -696,25 +695,14 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
max-width: 100%; max-width: 100%;
} }
/* Slot machine visual display (matches RemoteAuthInline) */ /* Slot machine visual display (matches RemoteAuthRequest) */
.slot-machine { .slot-machine {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
gap: 0; gap: 0;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
z-index: 1; z-index: 1;
} }
@@ -729,15 +717,8 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
} }
.slot-reel { .slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1 1 33.333%; flex: 1 1 33.333%;
min-width: 0;
height: 1.8em;
overflow: visible; overflow: visible;
position: relative;
border-radius: 3px;
} }
.slot-reel:not(:last-child) { .slot-reel:not(:last-child) {
@@ -360,16 +360,8 @@ defineExpose({ retry, cancel })
} }
.slot-reel { .slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 1.8em;
overflow: hidden; overflow: hidden;
position: relative;
background: var(--color-surface, rgba(255, 255, 255, 0.5)); background: var(--color-surface, rgba(255, 255, 255, 0.5));
border-radius: 3px;
} }
.slot-machine:not(.stopped) .slot-reel:nth-child(1) { .slot-machine:not(.stopped) .slot-reel:nth-child(1) {
+2 -2
View File
@@ -39,7 +39,7 @@
<!-- Remote authentication view (request new remote auth) --> <!-- Remote authentication view (request new remote auth) -->
<div v-else-if="authView === 'remote'" class="auth-view"> <div v-else-if="authView === 'remote'" class="auth-view">
<RemoteAuthInline <RemoteAuthRequest
:active="authView === 'remote'" :active="authView === 'remote'"
@authenticated="handleRemoteAuthenticated" @authenticated="handleRemoteAuthenticated"
@register="handleRemoteRegistration" @register="handleRemoteRegistration"
@@ -59,7 +59,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
const props = defineProps({ const props = defineProps({
mode: { mode: {
+17 -45
View File
@@ -1,18 +1,18 @@
<template> <template>
<section class="section-block" data-component="session-list-section"> <section class="section-block" data-component="session-list-section">
<div class="section-header"> <div class="section-header">
<h2>Active Sessions</h2> <h2>Login Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p> <p class="section-description">{{ sectionDescription }}</p>
</div> </div>
<div class="section-body"> <div class="section-body">
<div :class="['session-list']"> <div>
<template v-if="Array.isArray(sessions) && sessions.length"> <template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group"> <div v-for="(group, host) in groupedSessions" :key="host" class="session-group">
<h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]"> <h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template> <template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template>
<template v-else>🌐 Unbound host</template> <template v-else>🌐 Unbound host</template>
</h3> </h3>
<div class="session-group-sessions"> <div class="session-list">
<div <div
v-for="session in group.sessions" v-for="session in group.sessions"
:key="session.id" :key="session.id"
@@ -22,6 +22,8 @@
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid 'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
}]" }]"
tabindex="0" tabindex="0"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleSessionFocus(session)" @focusin="handleSessionFocus(session)"
@focusout="handleSessionBlur($event)" @focusout="handleSessionBlur($event)"
@keydown="handleDelete($event, session)" @keydown="handleDelete($event, session)"
@@ -94,6 +96,13 @@ const handleSessionBlur = (event) => {
} }
} }
const handleCardClick = (event) => {
if (!event.currentTarget.matches(':focus')) {
event.currentTarget.focus()
event.stopPropagation()
}
}
const handleDelete = (event, session) => { const handleDelete = (event, session) => {
const apple = navigator.userAgent.includes('Mac OS') const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') { if (event.key === 'Delete' || apple && event.key === 'Backspace') {
@@ -159,55 +168,18 @@ const groupedSessions = computed(() => {
</script> </script>
<style> <style>
.session-meta-info {
grid-column: span 2;
}
[data-component="session-list-section"] .session-list {
display: flex;
flex-direction: column;
gap: 1.5em;
}
.session-group {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.session-group-host { .session-group-host {
font-size: 1em; font-size: 1em;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0.5rem 0 0.5rem -1.2rem;
}
.session-group-host a {
color: inherit;
text-decoration: none;
}
.session-group-host a:hover {
text-decoration: underline;
} }
.session-group-host.is-current-site { .session-group-host.is-current-site {
color: var(--color-accent); color: var(--color-accent);
} }
.session-group-sessions { .btn-card-delete {
display: grid; display: none;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
gap: 0.5em;
align-items: start;
} }
.session-group-sessions .session-item { .session-item:focus .btn-card-delete {
width: auto; display: block;
height: auto;
padding: 0.75rem;
gap: 0.5rem;
}
.session-group-sessions .session-item .item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-group-sessions .session-item .item-details {
margin-left: 0;
}
.session-group-sessions .session-item .session-dates {
grid-template-columns: auto 1fr;
} }
</style> </style>
+13 -12
View File
@@ -11,12 +11,14 @@
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div> <div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div> <div class="role-line" v-if="roleName">{{ roleName }}</div>
</div> </div>
<span class="info-label"><strong>Visits:</strong></span> <div class="user-details">
<span class="info-value">{{ visits || 0 }}</span> <span class="date-label"><strong>Visits:</strong></span>
<span class="info-label"><strong>Registered:</strong></span> <span class="date-value">{{ visits || 0 }}</span>
<span class="info-value">{{ formatDate(createdAt) }}</span> <span class="date-label"><strong>Registered:</strong></span>
<span class="info-label"><strong>Last seen:</strong></span> <span class="date-value">{{ formatDate(createdAt) }}</span>
<span class="info-value">{{ formatDate(lastSeen) }}</span> <span class="date-label"><strong>Last seen:</strong></span>
<span class="date-value">{{ formatDate(lastSeen) }}</span>
</div>
<div v-if="$slots.default" class="user-info-extra"> <div v-if="$slots.default" class="user-info-extra">
<slot></slot> <slot></slot>
</div> </div>
@@ -68,7 +70,7 @@ const userLoaded = computed(() => !!props.name)
"label3 value3"; "label3 value3";
} }
@media (min-width: 769px) { @media (min-width: 720px) {
.user-info.has-extra { .user-info.has-extra {
grid-template-columns: auto 1fr 2fr; grid-template-columns: auto 1fr 2fr;
grid-template-areas: grid-template-areas:
@@ -80,7 +82,7 @@ const userLoaded = computed(() => !!props.name)
} }
} }
.user-name-heading { grid-area: heading; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } .user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } .org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } .org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
@@ -93,15 +95,14 @@ const userLoaded = computed(() => !!props.name)
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); } .user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; } .user-name-row.editing { flex: 1 1 auto; }
.icon { flex: 0 0 auto; } .icon { flex: 0 0 auto; font-size: 1.5em; width: 40px; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); } .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; } .user-name-heading .name-input { width: auto; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } .name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); } .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); } .mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); } .mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) { .user-info-extra { padding-left: 0; padding-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } } @media (max-width: 720px) { .user-info-extra { padding-left: 0; padding-top: 1rem; margin-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } }
@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>