Compare commits
	
		
			5 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 39beb31347 | ||
|   | 41e6eb9a5a | ||
|   | d5bc3e773d | ||
|   | ac0256c366 | ||
|   | 6439437e8b | 
| @@ -1,4 +1,5 @@ | |||||||
| localhost { | localhost { | ||||||
|  | 	# Setup the authentication site at /auth/ | ||||||
| 	import auth/setup | 	import auth/setup | ||||||
| 	# Only users with myapp:reports and auth admin permissions | 	# Only users with myapp:reports and auth admin permissions | ||||||
| 	handle_path /reports { | 	handle_path /reports { | ||||||
| @@ -22,16 +23,3 @@ localhost { | |||||||
| 		reverse_proxy :3000 | 		reverse_proxy :3000 | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| example.com { |  | ||||||
| 	# Public endpoints in handle blocks before auth |  | ||||||
| 	@public path /favicon.ico /.well-known/* |  | ||||||
| 	handle @public { |  | ||||||
| 		root * /var/www/ |  | ||||||
| 		file_server |  | ||||||
| 	} |  | ||||||
| 	# The rest of the site protected, /auth/ reserved for auth service |  | ||||||
| 	import auth/all perm=auth:admin { |  | ||||||
| 		reverse_proxy :3000 |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -1,6 +0,0 @@ | |||||||
| # Enable auth site at /auth (setup) and require authentication on all paths |  | ||||||
| import setup |  | ||||||
| handle { |  | ||||||
|     import require {args[0]} |  | ||||||
|     {block} |  | ||||||
| } |  | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| # Permission to use within your endpoints that need authentication/authorization, that | # Permission to use within your endpoints that need authentication/authorization | ||||||
| # is different depending on the route (otherwise use auth/all). | # Argument is mandatory and provides a query string to /auth/api/forward | ||||||
|  | #   "" means just authentication | ||||||
|  | #   perm=yourservice:login to require specific permission | ||||||
| forward_auth {$AUTH_UPSTREAM:localhost:4401} { | forward_auth {$AUTH_UPSTREAM:localhost:4401} { | ||||||
|     uri /auth/api/forward?{args[0]} |     uri /auth/api/forward?{args[0]} | ||||||
|     header_up Connection keep-alive  # Much higher performance |     header_up Connection keep-alive  # Much higher performance | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
|     <ProfileView v-if="store.currentView === 'profile'" /> |     <ProfileView v-if="store.currentView === 'profile'" /> | ||||||
|     <DeviceLinkView v-if="store.currentView === 'device-link'" /> |     <DeviceLinkView v-if="store.currentView === 'device-link'" /> | ||||||
|     <ResetView v-if="store.currentView === 'reset'" /> |     <ResetView v-if="store.currentView === 'reset'" /> | ||||||
|   <PermissionDeniedView v-if="store.currentView === 'permission-denied'" /> |     <PermissionDeniedView v-if="store.currentView === 'permission-denied'" /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @@ -44,9 +44,9 @@ onMounted(async () => { | |||||||
|   if (reset) { |   if (reset) { | ||||||
|     store.resetToken = reset |     store.resetToken = reset | ||||||
|     // Remove query param to avoid lingering in history / clipboard |     // Remove query param to avoid lingering in history / clipboard | ||||||
|   const targetPath = '/auth/' |     const targetPath = '/auth/' | ||||||
|   const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' |     const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' | ||||||
|   history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) |     history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) | ||||||
|   } |   } | ||||||
|   try { |   try { | ||||||
|     await store.loadUserInfo() |     await store.loadUserInfo() | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||||
|  | import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||||
| import CredentialList from '@/components/CredentialList.vue' | import CredentialList from '@/components/CredentialList.vue' | ||||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||||
| @@ -318,6 +319,27 @@ const pageHeading = computed(() => { | |||||||
|   return (authStore.settings?.rp_name || 'Passkey') + ' Admin' |   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) => { | watch(selectedUser, async (u) => { | ||||||
|   if (!u) { userDetail.value = null; return } |   if (!u) { userDetail.value = null; return } | ||||||
|   try { |   try { | ||||||
| @@ -432,17 +454,8 @@ async function submitDialog() { | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="container"> | ||||||
|     <h1> |     <h1>{{ pageHeading }}</h1> | ||||||
|       {{ pageHeading }} |     <Breadcrumbs :entries="breadcrumbEntries" /> | ||||||
|       <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> |  | ||||||
|     <div v-if="loading">Loading…</div> |     <div v-if="loading">Loading…</div> | ||||||
|     <div v-else-if="error" class="error">{{ error }}</div> |     <div v-else-if="error" class="error">{{ error }}</div> | ||||||
|     <div v-else> |     <div v-else> | ||||||
| @@ -454,9 +467,8 @@ async function submitDialog() { | |||||||
|       </div> |       </div> | ||||||
|       <div v-else> |       <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"> |   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> | ||||||
|           <h2>Organizations</h2> |           <h2>Organizations</h2> | ||||||
|           <div class="actions"> |           <div class="actions"> | ||||||
| @@ -484,8 +496,6 @@ async function submitDialog() { | |||||||
|             </tbody> |             </tbody> | ||||||
|           </table> |           </table> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <!-- User Detail Page --> |  | ||||||
|         <div v-if="selectedUser" class="card user-detail"> |         <div v-if="selectedUser" class="card user-detail"> | ||||||
|           <UserBasicInfo |           <UserBasicInfo | ||||||
|             v-if="userDetail && !userDetail.error" |             v-if="userDetail && !userDetail.error" | ||||||
| @@ -519,7 +529,7 @@ async function submitDialog() { | |||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <!-- Organization Detail Page --> |          | ||||||
|         <div v-else-if="selectedOrg" class="card"> |         <div v-else-if="selectedOrg" class="card"> | ||||||
|           <h2 class="org-title" :title="selectedOrg.uuid"> |           <h2 class="org-title" :title="selectedOrg.uuid"> | ||||||
|             <span class="org-name">{{ selectedOrg.display_name }}</span> |             <span class="org-name">{{ selectedOrg.display_name }}</span> | ||||||
| @@ -533,7 +543,7 @@ async function submitDialog() { | |||||||
|                 class="perm-matrix-grid" |                 class="perm-matrix-grid" | ||||||
|                 :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" |                 :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" | ||||||
|               > |               > | ||||||
|                 <!-- Headers --> |                  | ||||||
|                 <div class="grid-head perm-head">Permission</div> |                 <div class="grid-head perm-head">Permission</div> | ||||||
|                 <div |                 <div | ||||||
|                   v-for="r in selectedOrg.roles" |                   v-for="r in selectedOrg.roles" | ||||||
| @@ -545,7 +555,7 @@ async function submitDialog() { | |||||||
|                 </div> |                 </div> | ||||||
|                 <div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</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"> |                 <template v-for="pid in selectedOrg.permissions" :key="pid"> | ||||||
|                   <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> |                   <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> | ||||||
|                   <div |                   <div | ||||||
| @@ -583,14 +593,14 @@ async function submitDialog() { | |||||||
|                 </div> |                 </div> | ||||||
|               </div> |               </div> | ||||||
|               <template v-if="r.users.length > 0"> |               <template v-if="r.users.length > 0"> | ||||||
|         <ul class="user-list"> |                 <ul class="user-list"> | ||||||
|                   <li |                   <li | ||||||
|                     v-for="u in r.users" |                     v-for="u in r.users" | ||||||
|                     :key="u.uuid" |                     :key="u.uuid" | ||||||
|                     class="user-chip" |                     class="user-chip" | ||||||
|                     draggable="true" |                     draggable="true" | ||||||
|                     @dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)" |                     @dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)" | ||||||
|           @click="openUser(u)" |                     @click="openUser(u)" | ||||||
|                     :title="u.uuid" |                     :title="u.uuid" | ||||||
|                   > |                   > | ||||||
|                     <span class="name">{{ u.display_name }}</span> |                     <span class="name">{{ u.display_name }}</span> | ||||||
| @@ -606,7 +616,7 @@ async function submitDialog() { | |||||||
|           </div> |           </div> | ||||||
|         </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> |           <h2>All Permissions</h2> | ||||||
|           <div class="actions"> |           <div class="actions"> | ||||||
|             <button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button> |             <button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button> | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								frontend/src/components/Breadcrumbs.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/src/components/Breadcrumbs.vue
									
									
									
									
									
										Normal 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> | ||||||
| @@ -17,6 +17,7 @@ | |||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import { computed } from 'vue' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="container"> | ||||||
|     <div class="view active"> |     <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 |       <UserBasicInfo | ||||||
|         v-if="authStore.userInfo?.user" |         v-if="authStore.userInfo?.user" | ||||||
|         :name="authStore.userInfo.user.user_name" |         :name="authStore.userInfo.user.user_name" | ||||||
| @@ -9,7 +10,7 @@ | |||||||
|         :created-at="authStore.userInfo.user.created_at" |         :created-at="authStore.userInfo.user.created_at" | ||||||
|         :last-seen="authStore.userInfo.user.last_seen" |         :last-seen="authStore.userInfo.user.last_seen" | ||||||
|         :loading="authStore.isLoading" |         :loading="authStore.isLoading" | ||||||
|   update-endpoint="/auth/api/user/display-name" |         update-endpoint="/auth/api/user/display-name" | ||||||
|         @saved="authStore.loadUserInfo()" |         @saved="authStore.loadUserInfo()" | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
| @@ -80,6 +81,7 @@ | |||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onUnmounted, computed } from 'vue' | import { ref, onMounted, onUnmounted, computed } from 'vue' | ||||||
|  | import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import { formatDate } from '@/utils/helpers' | import { formatDate } from '@/utils/helpers' | ||||||
| import passkey from '@/utils/passkey' | import passkey from '@/utils/passkey' | ||||||
|   | |||||||
| @@ -130,6 +130,7 @@ export const useAuthStore = defineStore('auth', { | |||||||
|     async logout() { |     async logout() { | ||||||
|       try { |       try { | ||||||
|         await fetch('/auth/api/logout', {method: 'POST'}) |         await fetch('/auth/api/logout', {method: 'POST'}) | ||||||
|  |         sessionStorage.clear() | ||||||
|         location.reload() |         location.reload() | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error('Logout error:', error) |         console.error('Logout error:', error) | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ build-backend = "hatchling.build" | |||||||
|  |  | ||||||
| [project] | [project] | ||||||
| name = "passkey" | name = "passkey" | ||||||
| version = "0.1.0" | version = "0.1.2" | ||||||
| description = "Passkey Authentication for Web Services" | description = "Passkey Authentication for Web Services" | ||||||
| authors = [ | authors = [ | ||||||
|     {name = "Leo Vasanko"}, |     {name = "Leo Vasanko"}, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user