- Add session-based authentication for web-UI (Login.vue, AuthController) - Add AuthSessionMiddleware: redirects unauthenticated users to /login - Add ApiKeyMiddleware: validates X-Api-Key header on all /api/v1 routes - Add BasicAuthMiddleware: registered for future M2M use - Use timingSafeEqual for all credential comparisons (timing attack prevention) - Regenerate session ID on login (session fixation prevention) - Validate redirect target after login (open redirect prevention) - Require WEBUI_USERNAME, WEBUI_PASSWORD, API_KEY as mandatory env vars - Share auth.username/authenticated via Inertia shared props - Add user dropdown in navbar (avatar icon + username + logout) - Add login i18n keys in fr.json and en.json - Remove orphan SQL migrations (users, auth_access_tokens) and @adonisjs/lucid
325 lines
9.3 KiB
Vue
325 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { Link, Head, usePage, router } from '@inertiajs/vue3'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ref, computed } from 'vue'
|
|
|
|
const { t, locale } = useI18n()
|
|
const currentLocale = ref(locale.value)
|
|
const page = usePage()
|
|
const langMenuOpen = ref(false)
|
|
const userMenuOpen = ref(false)
|
|
|
|
function toggleUserMenu() {
|
|
userMenuOpen.value = !userMenuOpen.value
|
|
if (userMenuOpen.value) langMenuOpen.value = false
|
|
}
|
|
|
|
const auth = computed(() => (page.props as any).auth as { username: string | null; authenticated: boolean })
|
|
|
|
function switchLocale(lang: string) {
|
|
locale.value = lang
|
|
currentLocale.value = lang
|
|
langMenuOpen.value = false
|
|
}
|
|
|
|
function toggleLangMenu() {
|
|
langMenuOpen.value = !langMenuOpen.value
|
|
if (langMenuOpen.value) userMenuOpen.value = false
|
|
}
|
|
|
|
function logout() {
|
|
router.post('/logout')
|
|
}
|
|
|
|
function isActive(paths: string | string[]): boolean {
|
|
const url = page.url
|
|
if (Array.isArray(paths)) {
|
|
return paths.some((p) => url === p || (p !== '/' && url.startsWith(p)))
|
|
}
|
|
return url === paths || (paths !== '/' && url.startsWith(paths))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="page.component" />
|
|
|
|
<div class="d-flex flex-column min-vh-100">
|
|
<!-- Navbar -->
|
|
<header>
|
|
<div class="bg-dark">
|
|
<div class="container-xxl">
|
|
<nav id="main-menu" class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
<!-- Brand / Logo -->
|
|
<h1 class="navbar-brand mb-0 me-3">
|
|
<Link href="/">
|
|
<span class="visually-hidden">{{ t('header.title') }}</span>
|
|
<svg
|
|
width="30"
|
|
height="30"
|
|
viewBox="0 0 100 100"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<rect width="100" height="100" fill="#ff7900" />
|
|
<rect x="14.29" y="71.43" width="71.43" height="14.29" fill="#fff" />
|
|
</svg>
|
|
</Link>
|
|
</h1>
|
|
|
|
<!-- Mobile toggle -->
|
|
<button
|
|
class="navbar-toggler"
|
|
type="button"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="#navbarContent"
|
|
aria-controls="navbarContent"
|
|
aria-expanded="false"
|
|
aria-label="Toggle navigation"
|
|
>
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
|
|
<!-- Nav links -->
|
|
<ul class="navbar-nav_container navbar-nav">
|
|
<li class="nav-item">
|
|
<Link
|
|
href="/"
|
|
class="nav-link"
|
|
:class="{
|
|
active:
|
|
isActive('/') &&
|
|
!page.url.startsWith('/configurations') &&
|
|
!page.url.startsWith('/commands'),
|
|
}"
|
|
>
|
|
{{ t('header.devices') }}
|
|
</Link>
|
|
</li>
|
|
<li class="nav-item">
|
|
<Link
|
|
href="/configurations"
|
|
class="nav-link"
|
|
:class="{ active: isActive('/configurations') }"
|
|
>
|
|
{{ t('header.configurations') }}
|
|
</Link>
|
|
</li>
|
|
<li class="nav-item">
|
|
<Link
|
|
href="/firmware-catalog"
|
|
class="nav-link"
|
|
:class="{ active: isActive('/firmware-catalog') }"
|
|
>
|
|
{{ t('header.firmware') }}
|
|
</Link>
|
|
</li>
|
|
<li class="nav-item">
|
|
<Link href="/commands" class="nav-link" :class="{ active: isActive('/commands') }">
|
|
{{ t('header.commands') }}
|
|
</Link>
|
|
</li>
|
|
<li class="nav-item">
|
|
<Link
|
|
href="/tr069-trace"
|
|
class="nav-link"
|
|
:class="{ active: isActive('/tr069-trace') }"
|
|
>
|
|
{{ t('header.tr069Trace') }}
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Right: language dropdown + logout -->
|
|
<div class="navbar-right">
|
|
<ul class="navbar-nav navbar-nav_container">
|
|
<li class="nav-item dropdown" :class="{ show: langMenuOpen }">
|
|
<a
|
|
class="nav-link dropdown-toggle"
|
|
href="javascript:void(0);"
|
|
role="button"
|
|
@click="toggleLangMenu"
|
|
:aria-expanded="langMenuOpen"
|
|
>
|
|
<span>{{ currentLocale.toUpperCase() }}</span>
|
|
</a>
|
|
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: langMenuOpen }">
|
|
<li>
|
|
<a
|
|
class="dropdown-item"
|
|
:class="{ active: currentLocale === 'fr' }"
|
|
href="javascript:void(0);"
|
|
@click="switchLocale('fr')"
|
|
>
|
|
FR — Français
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
class="dropdown-item"
|
|
:class="{ active: currentLocale === 'en' }"
|
|
href="javascript:void(0);"
|
|
@click="switchLocale('en')"
|
|
>
|
|
EN — English
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
|
|
<!-- User dropdown -->
|
|
<li v-if="auth.authenticated" class="nav-item dropdown" :class="{ show: userMenuOpen }">
|
|
<button
|
|
type="button"
|
|
class="nav-link user-menu-toggle"
|
|
@click="toggleUserMenu"
|
|
:aria-expanded="userMenuOpen"
|
|
>
|
|
<!-- Person icon -->
|
|
<div class="mb-1">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
|
</svg>
|
|
</div>
|
|
<span class="user-name">{{ auth.username }}</span>
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: userMenuOpen }">
|
|
<li>
|
|
<button type="button" class="dropdown-item" @click="logout">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true" class="me-2">
|
|
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5-5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
|
|
</svg>
|
|
{{ t('login.logout') }}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content -->
|
|
<main class="flex-grow-1" style="background-color: #f6f6f6">
|
|
<slot />
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer class="footer navbar fixed-bottom bg-dark" data-bs-theme="dark">
|
|
<h2 class="visually-hidden">Sitemap & information</h2>
|
|
<div class="container-xxl footer-terms">
|
|
<ul class="navbar-nav flex-row gap-md-3">
|
|
<li class="fw-bold text-white">© Orange 2025</li>
|
|
<li>
|
|
<a class="nav-link" href="#">{{ t('footer.legal') }}</a>
|
|
</li>
|
|
<li>
|
|
<a class="nav-link" href="#">{{ t('footer.privacy') }}</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
main {
|
|
padding-bottom: 80px;
|
|
}
|
|
|
|
#main-menu {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
#main-menu .navbar-brand {
|
|
margin: 0;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
#main-menu .navbar-brand a {
|
|
display: inline-block;
|
|
padding-bottom: 0.25rem;
|
|
}
|
|
|
|
#main-menu .navbar-brand svg {
|
|
display: block;
|
|
}
|
|
|
|
#main-menu .nav-link.active {
|
|
padding-bottom: 0.75rem;
|
|
}
|
|
|
|
.navbar-nav_container {
|
|
display: flex;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
gap: 0.25rem;
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.navbar-right {
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-right .navbar-nav {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.navbar-right .nav-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-item.dropdown .dropdown-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
left: auto;
|
|
}
|
|
|
|
.dropdown-item.active {
|
|
background-color: #ff7900;
|
|
color: #fff;
|
|
}
|
|
|
|
.user-menu-toggle {
|
|
background: none;
|
|
border: none;
|
|
padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);
|
|
color: rgba(255, 255, 255, 0.55);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.user-menu-toggle svg {
|
|
display: block;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.user-menu-toggle:hover,
|
|
.user-menu-toggle:focus {
|
|
color: rgba(255, 255, 255, 0.75);
|
|
outline: none;
|
|
}
|
|
|
|
.user-name {
|
|
line-height: 1;
|
|
}
|
|
|
|
|
|
</style>
|