bilhej/frontend/src/components/AppHeader.vue
Joakim Mörling 3532e4d486
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 1m55s
Add account settings dropdown and verified email change flow.
Replace the header "Byt lösenord" link with an Inställningar menu for
changing email or password. Email changes are two-step: request with
password, confirmation link to the new address, then password again on
confirm so a wrong inbox cannot take over the account.

- Backend: EmailChangeService, V10 email_change_tokens, confirm API
- Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown
- E2E: account-settings round-trips, Mailpit verification, wrong-password guard
- Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 14:33:06 +02:00

316 lines
7.8 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isSettingsActive = computed(
() =>
route.name === 'change-email' || route.name === 'change-password',
)
const settingsOpen = ref(false)
const settingsRef = ref<HTMLElement | null>(null)
function toggleSettings() {
settingsOpen.value = !settingsOpen.value
}
function closeSettings() {
settingsOpen.value = false
}
function handleDocumentClick(event: MouseEvent) {
if (!settingsRef.value?.contains(event.target as Node)) {
settingsOpen.value = false
}
}
function handleLogout() {
auth.logout()
router.push('/')
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
<header class="app-header">
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 7l10 6 10-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Bilhej
</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<template v-if="!auth.isAuthenticated">
<RouterLink to="/logga-in" class="app-header__link"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>
</template>
<template v-else>
<RouterLink
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
>Admin</RouterLink
>
<RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink
>
<div ref="settingsRef" class="app-header__settings">
<button
type="button"
class="app-header__settings-trigger"
:class="{
'app-header__settings-trigger--active': isSettingsActive,
}"
aria-haspopup="menu"
:aria-expanded="settingsOpen"
@click.stop="toggleSettings"
>
Inställningar
<svg
class="app-header__settings-chevron"
:class="{ 'app-header__settings-chevron--open': settingsOpen }"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.94a.75.75 0 111.08 1.04l-4.24 4.5a.75.75 0 01-1.08 0l-4.24-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
<div
v-if="settingsOpen"
class="app-header__settings-menu"
role="menu"
>
<RouterLink
to="/andra-epost"
class="app-header__settings-item"
role="menuitem"
@click="closeSettings"
>
Byt e-postadress
</RouterLink>
<RouterLink
to="/andra-losenord"
class="app-header__settings-item"
role="menuitem"
@click="closeSettings"
>
Byt lösenord
</RouterLink>
</div>
</div>
<button class="app-header__logout" @click="handleLogout">
Logga ut
</button>
<span class="app-header__email">{{ auth.email }}</span>
</template>
</nav>
</div>
</header>
</template>
<style scoped>
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(253, 250, 245, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.app-header__inner {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--space-lg);
}
.app-header__logo {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
font-weight: 700;
color: var(--color-ink);
text-decoration: none;
}
.app-header__logo-icon {
width: 1.5rem;
height: 1.5rem;
}
.app-header__nav {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.app-header__link {
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.app-header__link:hover,
.app-header__link.router-link-active {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
}
.app-header__link--admin:hover {
background: #e9d5ff;
color: var(--color-primary-dark);
}
.app-header__email {
color: var(--color-muted);
font-size: 0.8125rem;
padding: 0 0.5rem;
}
.app-header__settings {
position: relative;
}
.app-header__settings-trigger {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.app-header__settings-trigger:hover,
.app-header__settings-trigger--active,
.app-header__settings-trigger[aria-expanded='true'] {
color: var(--color-primary-dark);
border-color: #bfdbfe;
background: var(--color-primary-soft);
}
.app-header__settings-chevron {
width: 1rem;
height: 1rem;
transition: transform var(--transition-fast);
}
.app-header__settings-chevron--open {
transform: rotate(180deg);
}
.app-header__settings-menu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 12rem;
padding: 0.35rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.app-header__settings-item {
display: block;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.app-header__settings-item:hover {
background: var(--color-border-light);
}
.app-header__logout {
background: none;
border: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
padding: 0.35rem 0.875rem;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.app-header__logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-soft);
}
</style>