Implement breadcrumb navigation.

This commit is contained in:
Leo Vasanko
2025-09-27 20:47:45 -06:00
parent 654618883d
commit 6439437e8b
5 changed files with 83 additions and 28 deletions

View File

@@ -5,7 +5,7 @@
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</div>
</template>
@@ -44,9 +44,9 @@ onMounted(async () => {
if (reset) {
store.resetToken = reset
// Remove query param to avoid lingering in history / clipboard
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
}
try {
await store.loadUserInfo()

View File

@@ -1,5 +1,6 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
@@ -318,6 +319,27 @@ const pageHeading = computed(() => {
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: '/auth/' },
{ label: 'Admin', href: '/auth/admin/' }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
}
return entries
})
watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return }
try {
@@ -432,17 +454,8 @@ async function submitDialog() {
<template>
<div class="container">
<h1>
{{ pageHeading }}
<a href="/auth/" class="back-link" title="Back to User App">User</a>
<a
v-if="info?.is_global_admin && (selectedOrg || selectedUser)"
@click.prevent="goOverview"
href="#overview"
class="nav-link"
title="Back to overview"
>Overview</a>
</h1>
<h1>{{ pageHeading }}</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<div v-if="loading">Loading</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
@@ -454,9 +467,8 @@ async function submitDialog() {
</div>
<div v-else>
<!-- Removed user-specific info (current org, effective permissions, admin flags) -->
<!-- Overview Page -->
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>Organizations</h2>
<div class="actions">
@@ -484,8 +496,6 @@ async function submitDialog() {
</tbody>
</table>
</div>
<!-- User Detail Page -->
<div v-if="selectedUser" class="card user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
@@ -519,7 +529,7 @@ async function submitDialog() {
/>
</div>
<!-- Organization Detail Page -->
<div v-else-if="selectedOrg" class="card">
<h2 class="org-title" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
@@ -533,7 +543,7 @@ async function submitDialog() {
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
>
<!-- Headers -->
<div class="grid-head perm-head">Permission</div>
<div
v-for="r in selectedOrg.roles"
@@ -545,7 +555,7 @@ async function submitDialog() {
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button"></div>
<!-- Data Rows -->
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<div
@@ -583,14 +593,14 @@ async function submitDialog() {
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<ul class="user-list">
<li
v-for="u in r.users"
:key="u.uuid"
class="user-chip"
draggable="true"
@dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)"
@click="openUser(u)"
@click="openUser(u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
@@ -606,7 +616,7 @@ async function submitDialog() {
</div>
</div>
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>

View File

@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue'
// Props:
// entries: Array<{ label:string, href:string }>
// showHome: include leading home icon (defaults true)
// homeHref: home link target (default '/')
const props = defineProps({
entries: { type: Array, default: () => [] },
showHome: { type: Boolean, default: true },
homeHref: { type: String, default: '/' }
})
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> </span>
</li>
</ol>
</nav>
</template>
<style scoped>
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; }
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; }
.breadcrumbs li { display: inline-flex; align-items: center; }
.breadcrumbs a { text-decoration: none; color: #0366d6; padding: 0 .15rem; border-radius:4px; }
.breadcrumbs a:hover, .breadcrumbs a:focus { text-decoration: underline; }
.breadcrumbs .sep { color: #888; margin: 0 .1rem; }
@media (prefers-color-scheme: dark) {
.breadcrumbs a { color: #4ea3ff; }
.breadcrumbs .sep { color: #aaa; }
}
</style>

View File

@@ -17,6 +17,7 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
const authStore = useAuthStore()

View File

@@ -1,7 +1,8 @@
<template>
<div class="container">
<div class="view active">
<h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1>
<h1>👋 Welcome!</h1>
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
<UserBasicInfo
v-if="authStore.userInfo?.user"
:name="authStore.userInfo.user.user_name"
@@ -9,7 +10,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()"
/>
@@ -80,6 +81,7 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
import passkey from '@/utils/passkey'