qdadm 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -143,6 +143,16 @@ export class SessionAuthAdapter {
143
143
  this._user = null
144
144
  }
145
145
 
146
+ /**
147
+ * Destroy session (catastrophic/silent logout)
148
+ *
149
+ * Clears token WITHOUT triggering normal logout signals.
150
+ * Used by DebugBar to test how the app handles unexpected session loss.
151
+ */
152
+ destroySession() {
153
+ this._token = null
154
+ }
155
+
146
156
  /**
147
157
  * Validate that the adapter is properly configured
148
158
  *
@@ -316,6 +326,15 @@ export class LocalStorageSessionAuthAdapter extends SessionAuthAdapter {
316
326
  return original
317
327
  }
318
328
 
329
+ /**
330
+ * Destroy session - clears token and localStorage
331
+ * @override
332
+ */
333
+ destroySession = () => {
334
+ this._token = null
335
+ localStorage.removeItem(this._storageKey)
336
+ }
337
+
319
338
  // ─────────────────────────────────────────────────────────────────
320
339
  // Signal integration
321
340
  // ─────────────────────────────────────────────────────────────────
@@ -35,10 +35,10 @@ const props = defineProps({
35
35
  type: [String, Boolean],
36
36
  default: null
37
37
  },
38
- // Allow closing the banner
38
+ // Allow closing the banner (default: true)
39
39
  closable: {
40
40
  type: Boolean,
41
- default: false
41
+ default: true
42
42
  }
43
43
  })
44
44
 
@@ -6,8 +6,7 @@
6
6
  * <CopyableId :value="entityId" label="ID" />
7
7
  * <CopyableId :value="apiKey" label="API Key" />
8
8
  */
9
- import { ref } from 'vue'
10
- import { useToast } from 'primevue/usetoast'
9
+ import { ref, inject } from 'vue'
11
10
  import Button from 'primevue/button'
12
11
 
13
12
  const props = defineProps({
@@ -21,29 +20,19 @@ const props = defineProps({
21
20
  }
22
21
  })
23
22
 
24
- const toast = useToast()
23
+ const orchestrator = inject('qdadmOrchestrator', null)
25
24
  const copied = ref(false)
26
25
 
27
26
  async function copyToClipboard() {
28
27
  try {
29
28
  await navigator.clipboard.writeText(props.value)
30
29
  copied.value = true
31
- toast.add({
32
- severity: 'success',
33
- summary: 'Copied',
34
- detail: `${props.label} copied to clipboard`,
35
- life: 2000
36
- })
30
+ orchestrator?.toast.success('Copied', `${props.label} copied to clipboard`, 'CopyableId')
37
31
  setTimeout(() => {
38
32
  copied.value = false
39
33
  }, 2000)
40
34
  } catch {
41
- toast.add({
42
- severity: 'error',
43
- summary: 'Error',
44
- detail: 'Failed to copy to clipboard',
45
- life: 3000
46
- })
35
+ orchestrator?.toast.error('Error', 'Failed to copy to clipboard', 'CopyableId')
47
36
  }
48
37
  }
49
38
  </script>
@@ -18,7 +18,6 @@ export { default as DefaultMenu } from './layout/defaults/DefaultMenu.vue'
18
18
  export { default as DefaultFooter } from './layout/defaults/DefaultFooter.vue'
19
19
  export { default as DefaultUserInfo } from './layout/defaults/DefaultUserInfo.vue'
20
20
  export { default as DefaultBreadcrumb } from './layout/defaults/DefaultBreadcrumb.vue'
21
- export { default as DefaultToaster } from './layout/defaults/DefaultToaster.vue'
22
21
 
23
22
  // Forms
24
23
  export { default as FormPage } from './forms/FormPage.vue'
@@ -44,7 +44,7 @@ import DefaultMenu from './defaults/DefaultMenu.vue'
44
44
  import DefaultFooter from './defaults/DefaultFooter.vue'
45
45
  import DefaultUserInfo from './defaults/DefaultUserInfo.vue'
46
46
  import DefaultBreadcrumb from './defaults/DefaultBreadcrumb.vue'
47
- import DefaultToaster from './defaults/DefaultToaster.vue'
47
+ // DefaultToaster removed - Toast is now provided by Kernel at root level
48
48
 
49
49
  const slots = useSlots()
50
50
  const hasMainSlot = !!slots.main
@@ -110,11 +110,7 @@ provide('qdadmNavlinksOverride', navlinksOverride)
110
110
  </div>
111
111
  </div>
112
112
 
113
- <!-- Toaster zone: toast notifications -->
114
- <Zone
115
- :name="LAYOUT_ZONES.TOASTER"
116
- :default-component="DefaultToaster"
117
- />
113
+ <!-- Toast is now provided by Kernel at root level -->
118
114
 
119
115
  <!-- Confirm dialog (global) -->
120
116
  <ConfirmDialog />
@@ -81,6 +81,13 @@ const childNavlinks = computed(() => {
81
81
  const entityName = route.meta?.entity
82
82
  if (!entityName) return []
83
83
 
84
+ // Get current entity's manager to determine idField
85
+ const currentManager = getManager(entityName)
86
+ const entityId = route.params[currentManager?.idField || 'id']
87
+
88
+ // Only show children when we have an entity ID (not on create pages)
89
+ if (!entityId) return []
90
+
84
91
  const children = getChildRoutes(entityName)
85
92
  if (children.length === 0) return []
86
93
 
@@ -93,10 +100,6 @@ const childNavlinks = computed(() => {
93
100
 
94
101
  if (navRoutes.length === 0) return []
95
102
 
96
- // Get current entity's manager to determine idField
97
- const currentManager = getManager(entityName)
98
- const entityId = route.params[currentManager?.idField || 'id']
99
-
100
103
  // Build navlinks to child routes
101
104
  return navRoutes.map(childRoute => {
102
105
  const childManager = childRoute.meta?.entity ? getManager(childRoute.meta.entity) : null
@@ -174,6 +174,7 @@ watch(() => route?.path, () => {
174
174
  align-items: center;
175
175
  gap: 0.75rem;
176
176
  padding: 0.625rem 1.5rem;
177
+ font-size: 0.9375rem;
177
178
  color: var(--p-surface-300, #cbd5e1);
178
179
  text-decoration: none;
179
180
  transition: all 0.15s;
@@ -11,7 +11,6 @@ export { default as DefaultMenu } from './DefaultMenu.vue'
11
11
  export { default as DefaultFooter } from './DefaultFooter.vue'
12
12
  export { default as DefaultUserInfo } from './DefaultUserInfo.vue'
13
13
  export { default as DefaultBreadcrumb } from './DefaultBreadcrumb.vue'
14
- export { default as DefaultToaster } from './DefaultToaster.vue'
15
14
 
16
15
  // FormLayout defaults
17
16
  export { default as DefaultFormActions } from './DefaultFormActions.vue'
@@ -218,6 +218,7 @@ function onSort(event) {
218
218
  :label="resolveLabel(action.label)"
219
219
  :icon="action.icon"
220
220
  :severity="action.severity"
221
+ :size="action.size || 'small'"
221
222
  :loading="action.isLoading"
222
223
  @click="action.onClick"
223
224
  />
@@ -19,7 +19,6 @@
19
19
  */
20
20
  import { ref, inject, computed } from 'vue'
21
21
  import { useRouter } from 'vue-router'
22
- import { useToast } from 'primevue/usetoast'
23
22
  import Card from 'primevue/card'
24
23
  import InputText from 'primevue/inputtext'
25
24
  import Password from 'primevue/password'
@@ -102,7 +101,6 @@ const props = defineProps({
102
101
  const emit = defineEmits(['login', 'error'])
103
102
 
104
103
  const router = useRouter()
105
- const toast = useToast()
106
104
  const authAdapter = inject('authAdapter', null)
107
105
  const orchestrator = inject('qdadmOrchestrator', null)
108
106
  const appConfig = inject('qdadmApp', {})
@@ -115,12 +113,7 @@ const displayTitle = computed(() => props.title || appConfig.name || 'Admin')
115
113
 
116
114
  async function handleLogin() {
117
115
  if (!authAdapter?.login) {
118
- toast.add({
119
- severity: 'error',
120
- summary: 'Configuration Error',
121
- detail: 'No auth adapter configured',
122
- life: 5000
123
- })
116
+ orchestrator?.toast.error('Configuration Error', 'No auth adapter configured', 'LoginPage')
124
117
  return
125
118
  }
126
119
 
@@ -131,17 +124,13 @@ async function handleLogin() {
131
124
  password: password.value
132
125
  })
133
126
 
134
- toast.add({
135
- severity: 'success',
136
- summary: 'Welcome',
137
- detail: `Logged in as ${result.user?.username || result.user?.email || username.value}`,
138
- life: 3000
139
- })
140
-
141
- // Emit business signal if enabled
142
- if (props.emitSignal && orchestrator?.signals) {
143
- orchestrator.signals.emit('auth:login', { user: result.user })
144
- }
127
+ // Emit auth:login signal for debug bar and other listeners
128
+ orchestrator?.signals.emit('auth:login', { user: result.user })
129
+ orchestrator?.toast.success(
130
+ 'Welcome',
131
+ `Logged in as ${result.user?.username || result.user?.email || username.value}`,
132
+ 'LoginPage'
133
+ )
145
134
 
146
135
  emit('login', result)
147
136
  router.push(props.redirectTo)
@@ -153,20 +142,12 @@ async function handleLogin() {
153
142
  || error.message
154
143
  || 'Invalid credentials'
155
144
 
156
- toast.add({
157
- severity: 'error',
158
- summary: 'Login Failed',
159
- detail: message,
160
- life: 5000
145
+ orchestrator?.signals.emit('auth:login:error', {
146
+ username: username.value,
147
+ error: message,
148
+ status: error.response?.status
161
149
  })
162
-
163
- if (orchestrator?.signals) {
164
- orchestrator.signals.emit('auth:login:error', {
165
- username: username.value,
166
- error: message,
167
- status: error.response?.status
168
- })
169
- }
150
+ orchestrator?.toast.error('Login Failed', message, 'LoginPage')
170
151
 
171
152
  emit('error', error)
172
153
  } finally {
@@ -1,6 +1,5 @@
1
1
  import { ref, computed, provide, inject, onUnmounted } from 'vue'
2
2
  import { useRoute, useRouter } from 'vue-router'
3
- import { useToast } from 'primevue/usetoast'
4
3
  import { useDirtyState } from './useDirtyState'
5
4
  import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
6
5
  import { useBreadcrumb } from './useBreadcrumb'
@@ -81,7 +80,14 @@ export function useBareForm(options = {}) {
81
80
  // Router, route, toast - common dependencies
82
81
  const router = useRouter()
83
82
  const route = useRoute()
84
- const toast = useToast()
83
+ const orchestrator = inject('qdadmOrchestrator', null)
84
+
85
+ // Toast helper - wraps orchestrator.toast for legacy compatibility
86
+ const toast = {
87
+ add({ severity, summary, detail, emitter }) {
88
+ orchestrator?.toast[severity]?.(summary, detail, emitter)
89
+ }
90
+ }
85
91
 
86
92
  // Common state
87
93
  const loading = ref(false)
@@ -79,7 +79,6 @@
79
79
  */
80
80
  import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
81
81
  import { useRouter, useRoute } from 'vue-router'
82
- import { useToast } from 'primevue/usetoast'
83
82
  import { useConfirm } from 'primevue/useconfirm'
84
83
  import { useDirtyState } from './useDirtyState'
85
84
  import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
@@ -117,7 +116,6 @@ export function useEntityItemFormPage(config = {}) {
117
116
 
118
117
  const router = useRouter()
119
118
  const route = useRoute()
120
- const toast = useToast()
121
119
  const confirm = useConfirm()
122
120
 
123
121
  // Use useEntityItemPage for common infrastructure
@@ -130,6 +128,13 @@ export function useEntityItemFormPage(config = {}) {
130
128
 
131
129
  const { manager, orchestrator, entityId, getInitialDataWithParent, parentConfig, parentId, parentData, parentChain, getChainDepth, hydrator } = itemPage
132
130
 
131
+ // Toast helper - wraps orchestrator.toast for legacy compatibility
132
+ const toast = {
133
+ add({ severity, summary, detail, emitter }) {
134
+ orchestrator?.toast[severity]?.(summary, detail, emitter)
135
+ }
136
+ }
137
+
133
138
  // Read config from manager with option overrides
134
139
  const entityName = config.entityName ?? manager.label
135
140
  const routePrefix = config.routePrefix ?? manager.routePrefix
@@ -449,11 +454,14 @@ export function useEntityItemFormPage(config = {}) {
449
454
  }
450
455
 
451
456
  if (listRoute?.name) {
452
- return { name: listRoute.name, params: route.params }
457
+ // Filter out entity ID param (doesn't exist in create mode)
458
+ const params = { ...route.params }
459
+ delete params[manager.idField]
460
+ return { name: listRoute.name, params }
453
461
  }
454
462
  }
455
463
 
456
- // Default: top-level entity list
464
+ // Default: top-level entity list (no params needed)
457
465
  return { name: routePrefix }
458
466
  }
459
467
 
@@ -1,6 +1,5 @@
1
1
  import { ref, computed, watch, onMounted, inject, provide } from 'vue'
2
2
  import { useRouter, useRoute } from 'vue-router'
3
- import { useToast } from 'primevue/usetoast'
4
3
  import { useConfirm } from 'primevue/useconfirm'
5
4
  import { useHooks } from './useHooks.js'
6
5
  import { useEntityItemPage } from './useEntityItemPage.js'
@@ -139,11 +138,17 @@ export function useListPage(config = {}) {
139
138
 
140
139
  const router = useRouter()
141
140
  const route = useRoute()
142
- const toast = useToast()
143
141
  const confirm = useConfirm()
144
142
 
145
143
  // Get EntityManager via orchestrator
146
144
  const orchestrator = inject('qdadmOrchestrator')
145
+
146
+ // Toast helper - wraps orchestrator.toast for legacy compatibility
147
+ const toast = {
148
+ add({ severity, summary, detail, emitter }) {
149
+ orchestrator?.toast[severity]?.(summary, detail, emitter)
150
+ }
151
+ }
147
152
  if (!orchestrator) {
148
153
  throw new Error(
149
154
  '[qdadm] Orchestrator not provided.\n' +
@@ -43,12 +43,14 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
43
43
  import ToastService from 'primevue/toastservice'
44
44
  import ConfirmationService from 'primevue/confirmationservice'
45
45
  import Tooltip from 'primevue/tooltip'
46
+ import Toast from 'primevue/toast'
47
+ import ToastListener from '../toast/ToastListener.vue'
46
48
 
47
49
  import { createQdadm } from '../plugin.js'
48
50
  import { initModules, getRoutes, setSectionOrder, alterMenuSections, registry } from '../module/moduleRegistry.js'
49
51
  import { createModuleLoader } from './ModuleLoader.js'
50
52
  import { createKernelContext } from './KernelContext.js'
51
- import { ToastBridgeModule } from '../toast/ToastBridgeModule.js'
53
+ // ToastBridgeModule no longer used - Toast/ToastListener rendered at root level
52
54
  import { Orchestrator } from '../orchestrator/Orchestrator.js'
53
55
  import { createSignalBus } from './SignalBus.js'
54
56
  import { createZoneRegistry } from '../zones/ZoneRegistry.js'
@@ -101,7 +103,7 @@ export class Kernel {
101
103
  * @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
102
104
  * @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
103
105
  * @param {object} options.debugBar - Debug bar config { module: DebugModule, component: QdadmDebugBar, ...options }
104
- * @param {boolean} options.toast - Enable ToastBridgeModule (default: true). Set to false to disable.
106
+ * @param {boolean} options.toast - Reserved for future use (toast is always enabled when PrimeVue is configured)
105
107
  */
106
108
  constructor(options) {
107
109
  // Auto-inject DebugModule if debugBar.module is provided
@@ -127,13 +129,8 @@ export class Kernel {
127
129
  }
128
130
  }
129
131
 
130
- // Auto-inject ToastBridgeModule unless toast: false
131
- // Handles toast:* signals from useSignalToast() composable
132
- if (options.toast !== false) {
133
- options.moduleDefs = options.moduleDefs || []
134
- // Add at beginning for high priority (loads early)
135
- options.moduleDefs.unshift(new ToastBridgeModule())
136
- }
132
+ // Note: Toast is now handled at root level via _createVueApp()
133
+ // ToastBridgeModule is no longer auto-injected to avoid duplicate listeners
137
134
 
138
135
  this.options = options
139
136
  this.vueApp = null
@@ -718,7 +715,28 @@ export class Kernel {
718
715
  const { authAdapter } = this.options
719
716
  if (!authAdapter) return
720
717
 
718
+ // Track if user was ever authenticated (to detect session loss)
719
+ let wasEverAuthenticated = authAdapter.isAuthenticated()
720
+
721
+ // Listen for session loss signal and show toast
722
+ const debug = this.options.debug ?? false
723
+ this.signals.on('auth:session-lost', () => {
724
+ if (debug) {
725
+ console.warn('[Kernel] auth:session-lost received, emitting toast:warn')
726
+ }
727
+ this.orchestrator.toast.warn(
728
+ 'Session lost',
729
+ 'Your session has expired. Please log in again.',
730
+ 'Kernel'
731
+ )
732
+ })
733
+
721
734
  this.router.beforeEach((to, from, next) => {
735
+ // Update auth tracking
736
+ if (authAdapter.isAuthenticated()) {
737
+ wasEverAuthenticated = true
738
+ }
739
+
722
740
  // Check if route or any parent is explicitly public
723
741
  const isPublic = to.matched.some(record =>
724
742
  record.meta.public === true || record.meta.requiresAuth === false
@@ -733,8 +751,24 @@ export class Kernel {
733
751
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth === true)
734
752
 
735
753
  if (requiresAuth && !authAdapter.isAuthenticated()) {
736
- // Redirect to login (if exists) or emit signal
737
- const loginRoute = this.router.hasRoute('login') ? { name: 'login' } : '/'
754
+ // Session loss detected - emit signal if user was previously authenticated
755
+ if (wasEverAuthenticated) {
756
+ const debug = this.options.debug ?? false
757
+ if (debug) {
758
+ console.warn('[Kernel] Session lost detected, emitting auth:session-lost')
759
+ }
760
+ this.signals.emit('auth:session-lost', {
761
+ reason: 'token_missing',
762
+ redirectTo: 'login'
763
+ })
764
+ // Reset tracking for next session
765
+ wasEverAuthenticated = false
766
+ }
767
+
768
+ // Redirect to login with session_lost param (if exists) or emit signal
769
+ const loginRoute = this.router.hasRoute('login')
770
+ ? { name: 'login', query: { session_lost: '1' } }
771
+ : '/'
738
772
  next(loginRoute)
739
773
  } else {
740
774
  next()
@@ -847,6 +881,11 @@ export class Kernel {
847
881
  deferred: this.deferred,
848
882
  entityAuthAdapter: this.options.entityAuthAdapter || null
849
883
  })
884
+
885
+ // Apply toast config if provided (life defaults per severity)
886
+ if (this.options.toast) {
887
+ this.orchestrator.setToastConfig(this.options.toast)
888
+ }
850
889
  }
851
890
 
852
891
  /**
@@ -1060,35 +1099,44 @@ export class Kernel {
1060
1099
  /**
1061
1100
  * Create Vue app instance
1062
1101
  *
1063
- * When debugBar is enabled, wraps the root component to include QdadmDebugBar
1064
- * at the app level, ensuring it's visible on all pages (including login).
1102
+ * Always wraps the root component with:
1103
+ * - Toast (for global toast notifications on all pages including login)
1104
+ * - QdadmDebugBar (if debugBar is enabled)
1065
1105
  */
1066
1106
  _createVueApp() {
1067
1107
  if (!this.options.root) {
1068
1108
  throw new Error('[Kernel] root component is required')
1069
1109
  }
1070
1110
 
1071
- // Always wrap root with Toast (and DebugBar if enabled)
1072
1111
  const OriginalRoot = this.options.root
1073
1112
  const DebugBarComponent = this.options.debugBar?.component && QdadmDebugBar ? QdadmDebugBar : null
1113
+ const hasPrimeVue = !!this.options.primevue?.plugin
1114
+
1115
+ // Wrap root with Toast + ToastListener (for global toasts on all pages) and optionally DebugBar
1116
+ // Note: Apps should NOT include their own <Toast /> - Kernel provides it at root level
1117
+ const WrappedRoot = defineComponent({
1118
+ name: 'QdadmRootWrapper',
1119
+ setup() {
1120
+ return () => {
1121
+ const children = [h(OriginalRoot)]
1122
+
1123
+ // Add global Toast and ToastListener if PrimeVue is configured
1124
+ if (hasPrimeVue) {
1125
+ children.push(h(Toast))
1126
+ children.push(h(ToastListener))
1127
+ }
1128
+
1129
+ // Add DebugBar if enabled
1130
+ if (DebugBarComponent) {
1131
+ children.push(h(DebugBarComponent))
1132
+ }
1074
1133
 
1075
- // Wrap root with DebugBar if enabled
1076
- // Note: Toast must be included by apps in their App.vue for pages outside BaseLayout
1077
- if (DebugBarComponent) {
1078
- const WrappedRoot = defineComponent({
1079
- name: 'QdadmRootWrapper',
1080
- components: { OriginalRoot, DebugBarComponent },
1081
- setup() {
1082
- return () => h('div', { id: 'qdadm-root', style: 'display: contents' }, [
1083
- h(OriginalRoot),
1084
- h(DebugBarComponent)
1085
- ])
1134
+ return h('div', { id: 'qdadm-root', style: 'display: contents' }, children)
1086
1135
  }
1087
- })
1088
- this.vueApp = createApp(WrappedRoot)
1089
- } else {
1090
- this.vueApp = createApp(OriginalRoot)
1091
- }
1136
+ }
1137
+ })
1138
+
1139
+ this.vueApp = createApp(WrappedRoot)
1092
1140
  }
1093
1141
 
1094
1142
  /**
@@ -3,7 +3,7 @@
3
3
  * AuthPanel - Auth collector display with activity indicator
4
4
  * Each event has its own timer and fades out before destruction
5
5
  */
6
- import { onMounted, ref, onUnmounted } from 'vue'
6
+ import { onMounted, ref, onUnmounted, inject } from 'vue'
7
7
  import ObjectTree from '../ObjectTree.vue'
8
8
 
9
9
  const props = defineProps({
@@ -11,6 +11,19 @@ const props = defineProps({
11
11
  entries: { type: Array, required: true }
12
12
  })
13
13
 
14
+ // Inject auth adapter for token loss simulation
15
+ const authAdapter = inject('authAdapter', null)
16
+
17
+ /**
18
+ * Destroy session without normal logout flow
19
+ * Simulates catastrophic token loss (server revocation, storage corruption)
20
+ */
21
+ function destroySession() {
22
+ if (authAdapter?.destroySession) {
23
+ authAdapter.destroySession()
24
+ }
25
+ }
26
+
14
27
  // Local events with fade state
15
28
  const localEvents = ref([])
16
29
  const timers = new Map()
@@ -173,34 +186,44 @@ function getEventLabel(event) {
173
186
 
174
187
  <template>
175
188
  <div class="auth-panel">
176
- <!-- Invariant entries (user, token, permissions, adapter) -->
177
- <div
178
- v-for="(entry, idx) in entries"
179
- :key="idx"
180
- class="auth-item"
181
- :class="{ 'auth-item--impersonated': entry.type === 'impersonated' }"
182
- >
183
- <div class="auth-header" :class="{ 'auth-header--impersonated': entry.type === 'impersonated' }">
184
- <i :class="['pi', getIcon(entry.type)]" />
185
- <span class="auth-label">{{ entry.label || entry.type }}</span>
186
- </div>
187
- <div v-if="entry.message" class="auth-message">{{ entry.message }}</div>
188
- <ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
189
+ <!-- Debug actions -->
190
+ <div class="debug-panel-toolbar">
191
+ <button class="debug-toolbar-btn debug-toolbar-btn--danger" @click="destroySession" title="Clear token without logout (simulate session loss)">
192
+ <i class="pi pi-power-off" />
193
+ Destroy Session
194
+ </button>
189
195
  </div>
190
196
 
191
- <!-- Recent auth events with individual timers -->
192
- <TransitionGroup name="event">
197
+ <div class="auth-panel-content">
198
+ <!-- Invariant entries (user, token, permissions, adapter) -->
193
199
  <div
194
- v-for="event in localEvents"
195
- :key="event.id"
196
- class="auth-activity"
197
- :class="[event.type, { fading: event.fading }]"
200
+ v-for="(entry, idx) in entries"
201
+ :key="idx"
202
+ class="auth-item"
203
+ :class="{ 'auth-item--impersonated': entry.type === 'impersonated' }"
198
204
  >
199
- <i :class="['pi', getEventIcon(event.type)]" />
200
- <span>{{ getEventLabel(event) }}</span>
201
- <span class="auth-time">{{ formatTime(event.timestamp) }}</span>
205
+ <div class="auth-header" :class="{ 'auth-header--impersonated': entry.type === 'impersonated' }">
206
+ <i :class="['pi', getIcon(entry.type)]" />
207
+ <span class="auth-label">{{ entry.label || entry.type }}</span>
208
+ </div>
209
+ <div v-if="entry.message" class="auth-message">{{ entry.message }}</div>
210
+ <ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
202
211
  </div>
203
- </TransitionGroup>
212
+
213
+ <!-- Recent auth events with individual timers -->
214
+ <TransitionGroup name="event">
215
+ <div
216
+ v-for="event in localEvents"
217
+ :key="event.id"
218
+ class="auth-activity"
219
+ :class="[event.type, { fading: event.fading }]"
220
+ >
221
+ <i :class="['pi', getEventIcon(event.type)]" />
222
+ <span>{{ getEventLabel(event) }}</span>
223
+ <span class="auth-time">{{ formatTime(event.timestamp) }}</span>
224
+ </div>
225
+ </TransitionGroup>
226
+ </div>
204
227
  </div>
205
228
  </template>
206
229
 
@@ -132,8 +132,8 @@ function getDomainColor(name) {
132
132
  <button
133
133
  v-for="p in presets"
134
134
  :key="p.pattern"
135
- class="preset-btn"
136
- :class="{ 'preset-active': filterPattern === p.pattern }"
135
+ class="debug-toolbar-btn"
136
+ :class="{ 'debug-toolbar-btn--active': filterPattern === p.pattern }"
137
137
  @click="applyPreset(p.pattern)"
138
138
  >
139
139
  {{ p.label }}
@@ -45,10 +45,10 @@ function isHighlighted(zoneName) {
45
45
 
46
46
  <template>
47
47
  <div class="zones-panel">
48
- <div class="zones-toolbar">
48
+ <div class="debug-panel-toolbar">
49
49
  <button
50
- class="zones-toggle"
51
- :class="{ 'zones-toggle-on': !isFilterActive() }"
50
+ class="debug-toolbar-btn"
51
+ :class="{ 'debug-toolbar-btn--active': !isFilterActive() }"
52
52
  :title="isFilterActive() ? 'Click to show all zones' : 'Click to show current page only'"
53
53
  @click="toggleFilter"
54
54
  >
@@ -56,8 +56,8 @@ function isHighlighted(zoneName) {
56
56
  All Pages
57
57
  </button>
58
58
  <button
59
- class="zones-toggle"
60
- :class="{ 'zones-toggle-internal': showInternalZones() }"
59
+ class="debug-toolbar-btn"
60
+ :class="{ 'debug-toolbar-btn--active': showInternalZones() }"
61
61
  :title="showInternalZones() ? 'Click to hide internal zones' : 'Click to show internal zones (prefixed with _)'"
62
62
  @click="toggleInternalFilter"
63
63
  >
@@ -65,11 +65,12 @@ function isHighlighted(zoneName) {
65
65
  Internal
66
66
  </button>
67
67
  </div>
68
- <div v-if="entries.length === 0" class="zones-empty">
69
- <i class="pi pi-inbox" />
70
- <span>No zones</span>
71
- </div>
72
- <div v-for="zone in entries" :key="zone.name" class="zone-item">
68
+ <div class="zones-panel-content">
69
+ <div v-if="entries.length === 0" class="zones-empty">
70
+ <i class="pi pi-inbox" />
71
+ <span>No zones</span>
72
+ </div>
73
+ <div v-for="zone in entries" :key="zone.name" class="zone-item">
73
74
  <div class="zone-header">
74
75
  <button
75
76
  class="zone-highlight"
@@ -96,6 +97,7 @@ function isHighlighted(zoneName) {
96
97
  Default: {{ zone.defaultName || '(component)' }}
97
98
  </div>
98
99
  </div>
100
+ </div>
99
101
  </div>
100
102
  </template>
101
103
 
@@ -638,6 +638,104 @@ $debug-info: #3b82f6;
638
638
  color: #f4f4f5;
639
639
  }
640
640
 
641
+ /* =============================================================================
642
+ PANEL TOOLBAR (unified action bar at top of panels)
643
+ ============================================================================= */
644
+
645
+ .debug-panel-toolbar {
646
+ display: flex;
647
+ align-items: center;
648
+ gap: 8px;
649
+ padding: 8px 12px;
650
+ background: #27272a;
651
+ border-bottom: 1px solid #3f3f46;
652
+ }
653
+
654
+ /* Toolbar button - standard */
655
+ .debug-toolbar-btn {
656
+ display: inline-flex;
657
+ align-items: center;
658
+ gap: 4px;
659
+ padding: 3px 8px;
660
+ background: #3f3f46;
661
+ border: none;
662
+ border-radius: 3px;
663
+ color: #a1a1aa;
664
+ cursor: pointer;
665
+ font-size: 10px;
666
+ white-space: nowrap;
667
+ }
668
+
669
+ .debug-toolbar-btn:hover {
670
+ background: #52525b;
671
+ color: #f4f4f5;
672
+ }
673
+
674
+ .debug-toolbar-btn .pi {
675
+ font-size: 10px;
676
+ }
677
+
678
+ /* Toolbar button - danger variant */
679
+ .debug-toolbar-btn--danger {
680
+ background: rgba(239, 68, 68, 0.15);
681
+ color: #f87171;
682
+ }
683
+
684
+ .debug-toolbar-btn--danger:hover {
685
+ background: rgba(239, 68, 68, 0.3);
686
+ color: #fca5a5;
687
+ }
688
+
689
+ /* Toolbar button - active/pressed state */
690
+ .debug-toolbar-btn--active {
691
+ background: #8b5cf6;
692
+ color: white;
693
+ }
694
+
695
+ .debug-toolbar-btn--active:hover {
696
+ background: #7c3aed;
697
+ }
698
+
699
+ /* Toolbar input */
700
+ .debug-toolbar-input {
701
+ flex: 1;
702
+ min-width: 0;
703
+ font-size: 11px;
704
+ padding: 4px 8px;
705
+ background: #18181b;
706
+ border: 1px solid #3f3f46;
707
+ border-radius: 4px;
708
+ color: #f4f4f5;
709
+ }
710
+
711
+ .debug-toolbar-input:focus {
712
+ outline: none;
713
+ border-color: #8b5cf6;
714
+ }
715
+
716
+ .debug-toolbar-input::placeholder {
717
+ color: #71717a;
718
+ }
719
+
720
+ /* Toolbar icon (standalone) */
721
+ .debug-toolbar-icon {
722
+ color: #71717a;
723
+ font-size: 12px;
724
+ }
725
+
726
+ /* Toolbar label/text */
727
+ .debug-toolbar-label {
728
+ font-size: 10px;
729
+ color: #71717a;
730
+ }
731
+
732
+ /* Toolbar separator */
733
+ .debug-toolbar-separator {
734
+ width: 1px;
735
+ height: 16px;
736
+ background: #3f3f46;
737
+ }
738
+
641
739
  /* =============================================================================
642
740
  OBJECT TREE
643
741
  ============================================================================= */
@@ -726,7 +824,14 @@ $debug-info: #3b82f6;
726
824
  ============================================================================= */
727
825
 
728
826
  .auth-panel {
729
- padding: 8px;
827
+ padding: 0;
828
+ display: flex;
829
+ flex-direction: column;
830
+ gap: 8px;
831
+ }
832
+
833
+ .auth-panel-content {
834
+ padding: 0 8px 8px 8px;
730
835
  display: flex;
731
836
  flex-direction: column;
732
837
  gap: 8px;
@@ -2420,7 +2525,14 @@ $debug-info: #3b82f6;
2420
2525
  ============================================================================= */
2421
2526
 
2422
2527
  .zones-panel {
2423
- padding: 8px;
2528
+ padding: 0;
2529
+ display: flex;
2530
+ flex-direction: column;
2531
+ gap: 8px;
2532
+ }
2533
+
2534
+ .zones-panel-content {
2535
+ padding: 0 8px 8px 8px;
2424
2536
  display: flex;
2425
2537
  flex-direction: column;
2426
2538
  gap: 8px;
@@ -75,6 +75,63 @@ export class Orchestrator {
75
75
  return this._signals
76
76
  }
77
77
 
78
+ /**
79
+ * Set toast configuration (life defaults per severity)
80
+ * @param {Object} config - { success: 3000, error: 5000, warn: 5000, info: 3000 }
81
+ */
82
+ setToastConfig(config) {
83
+ this._toastConfig = { ...this._toastConfig, ...config }
84
+ this._toastHelper = null // Reset helper to pick up new config
85
+ }
86
+
87
+ /**
88
+ * Toast helper - wraps signal-based toasts
89
+ *
90
+ * Usage:
91
+ * orchestrator.toast.success('Saved', 'Item saved successfully')
92
+ * orchestrator.toast.error('Error', 'Failed to save', 'MyComponent')
93
+ * orchestrator.toast.warn('Warning', 'Check your input', { life: 10000, emitter: 'Custom' })
94
+ *
95
+ * Third param can be:
96
+ * - string: treated as emitter
97
+ * - object: { life, emitter }
98
+ *
99
+ * @returns {Object} Toast helper with success/error/warn/info methods
100
+ */
101
+ get toast() {
102
+ if (!this._toastHelper) {
103
+ // Default life values per severity (can be overridden via setToastConfig)
104
+ const defaults = {
105
+ success: 3000,
106
+ error: 5000,
107
+ warn: 5000,
108
+ info: 3000,
109
+ ...this._toastConfig
110
+ }
111
+
112
+ const emit = (severity, summary, detail, options) => {
113
+ if (this._signals) {
114
+ // Options can be string (emitter) or object { life, emitter }
115
+ const opts = typeof options === 'string' ? { emitter: options } : (options || {})
116
+ this._signals.emit(`toast:${severity}`, {
117
+ summary,
118
+ detail,
119
+ life: opts.life ?? defaults[severity],
120
+ emitter: opts.emitter
121
+ })
122
+ }
123
+ }
124
+
125
+ this._toastHelper = {
126
+ success: (summary, detail, options) => emit('success', summary, detail, options),
127
+ error: (summary, detail, options) => emit('error', summary, detail, options),
128
+ warn: (summary, detail, options) => emit('warn', summary, detail, options),
129
+ info: (summary, detail, options) => emit('info', summary, detail, options)
130
+ }
131
+ }
132
+ return this._toastHelper
133
+ }
134
+
78
135
  /**
79
136
  * Set the entity AuthAdapter for permission checks
80
137
  * This adapter will be injected into all newly registered managers
@@ -0,0 +1,318 @@
1
+ /**
2
+ * qdadm - Button Styles
3
+ *
4
+ * Global button styling - outlined style as default
5
+ * with severity variants (primary, secondary, success, warn, danger)
6
+ */
7
+
8
+ @use 'variables' as *;
9
+
10
+ // =============================================================================
11
+ // Base Button - Outlined by Default
12
+ // =============================================================================
13
+
14
+ .p-button {
15
+ // Outlined style as default (use !important to override PrimeVue theme)
16
+ background: transparent !important;
17
+ border: 1px solid var(--p-primary-500) !important;
18
+ color: var(--p-primary-500) !important;
19
+ transition: background-color 0.2s, border-color 0.2s, color 0.2s;
20
+
21
+ &:hover:not(:disabled) {
22
+ background: var(--p-primary-50) !important;
23
+ border-color: var(--p-primary-600) !important;
24
+ color: var(--p-primary-600) !important;
25
+ }
26
+
27
+ &:focus {
28
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-primary-200);
29
+ }
30
+
31
+ &:disabled {
32
+ opacity: 0.6;
33
+ cursor: not-allowed;
34
+ }
35
+ }
36
+
37
+ // =============================================================================
38
+ // Severity Variants
39
+ // =============================================================================
40
+
41
+ // Secondary (gray)
42
+ .p-button-secondary {
43
+ border-color: var(--p-surface-400) !important;
44
+ color: var(--p-surface-600) !important;
45
+
46
+ &:hover:not(:disabled) {
47
+ background: var(--p-surface-100) !important;
48
+ border-color: var(--p-surface-500) !important;
49
+ color: var(--p-surface-700) !important;
50
+ }
51
+
52
+ &:focus {
53
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-surface-200);
54
+ }
55
+ }
56
+
57
+ // Success (green)
58
+ .p-button-success {
59
+ border-color: var(--p-green-500) !important;
60
+ color: var(--p-green-600) !important;
61
+
62
+ &:hover:not(:disabled) {
63
+ background: var(--p-green-50) !important;
64
+ border-color: var(--p-green-600) !important;
65
+ color: var(--p-green-700) !important;
66
+ }
67
+
68
+ &:focus {
69
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-green-200);
70
+ }
71
+ }
72
+
73
+ // Warning (orange)
74
+ .p-button-warn {
75
+ border-color: var(--p-orange-500) !important;
76
+ color: var(--p-orange-600) !important;
77
+
78
+ &:hover:not(:disabled) {
79
+ background: var(--p-orange-50) !important;
80
+ border-color: var(--p-orange-600) !important;
81
+ color: var(--p-orange-700) !important;
82
+ }
83
+
84
+ &:focus {
85
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-orange-200);
86
+ }
87
+ }
88
+
89
+ // Danger (red)
90
+ .p-button-danger {
91
+ border-color: var(--p-red-500) !important;
92
+ color: var(--p-red-600) !important;
93
+
94
+ &:hover:not(:disabled) {
95
+ background: var(--p-red-50) !important;
96
+ border-color: var(--p-red-600) !important;
97
+ color: var(--p-red-700) !important;
98
+ }
99
+
100
+ &:focus {
101
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-red-200);
102
+ }
103
+ }
104
+
105
+ // Info (blue)
106
+ .p-button-info {
107
+ border-color: var(--p-blue-500) !important;
108
+ color: var(--p-blue-600) !important;
109
+
110
+ &:hover:not(:disabled) {
111
+ background: var(--p-blue-50) !important;
112
+ border-color: var(--p-blue-600) !important;
113
+ color: var(--p-blue-700) !important;
114
+ }
115
+
116
+ &:focus {
117
+ box-shadow: 0 0 0 2px var(--p-surface-0), 0 0 0 4px var(--p-blue-200);
118
+ }
119
+ }
120
+
121
+ // Contrast (dark)
122
+ .p-button-contrast {
123
+ border-color: var(--p-surface-900) !important;
124
+ color: var(--p-surface-900) !important;
125
+
126
+ &:hover:not(:disabled) {
127
+ background: var(--p-surface-900) !important;
128
+ border-color: var(--p-surface-900) !important;
129
+ color: var(--p-surface-0) !important;
130
+ }
131
+ }
132
+
133
+ // =============================================================================
134
+ // Filled Variant (explicit)
135
+ // =============================================================================
136
+
137
+ .p-button-filled,
138
+ .p-button.p-button-raised {
139
+ background: var(--p-primary-500) !important;
140
+ border-color: var(--p-primary-500) !important;
141
+ color: white !important;
142
+
143
+ &:hover:not(:disabled) {
144
+ background: var(--p-primary-600) !important;
145
+ border-color: var(--p-primary-600) !important;
146
+ color: white !important;
147
+ }
148
+
149
+ &.p-button-secondary {
150
+ background: var(--p-surface-500) !important;
151
+ border-color: var(--p-surface-500) !important;
152
+ color: white !important;
153
+
154
+ &:hover:not(:disabled) {
155
+ background: var(--p-surface-600) !important;
156
+ border-color: var(--p-surface-600) !important;
157
+ }
158
+ }
159
+
160
+ &.p-button-success {
161
+ background: var(--p-green-500) !important;
162
+ border-color: var(--p-green-500) !important;
163
+ color: white !important;
164
+
165
+ &:hover:not(:disabled) {
166
+ background: var(--p-green-600) !important;
167
+ border-color: var(--p-green-600) !important;
168
+ }
169
+ }
170
+
171
+ &.p-button-warn {
172
+ background: var(--p-orange-500) !important;
173
+ border-color: var(--p-orange-500) !important;
174
+ color: white !important;
175
+
176
+ &:hover:not(:disabled) {
177
+ background: var(--p-orange-600) !important;
178
+ border-color: var(--p-orange-600) !important;
179
+ }
180
+ }
181
+
182
+ &.p-button-danger {
183
+ background: var(--p-red-500) !important;
184
+ border-color: var(--p-red-500) !important;
185
+ color: white !important;
186
+
187
+ &:hover:not(:disabled) {
188
+ background: var(--p-red-600) !important;
189
+ border-color: var(--p-red-600) !important;
190
+ }
191
+ }
192
+ }
193
+
194
+ // =============================================================================
195
+ // Text Button (no border)
196
+ // =============================================================================
197
+
198
+ .p-button-text {
199
+ background: transparent !important;
200
+ border-color: transparent !important;
201
+
202
+ &:hover:not(:disabled) {
203
+ background: var(--p-primary-50) !important;
204
+ border-color: transparent !important;
205
+ }
206
+
207
+ &.p-button-secondary:hover:not(:disabled) {
208
+ background: var(--p-surface-100) !important;
209
+ }
210
+
211
+ &.p-button-success:hover:not(:disabled) {
212
+ background: var(--p-green-50) !important;
213
+ }
214
+
215
+ &.p-button-warn:hover:not(:disabled) {
216
+ background: var(--p-orange-50) !important;
217
+ }
218
+
219
+ &.p-button-danger:hover:not(:disabled) {
220
+ background: var(--p-red-50) !important;
221
+ }
222
+ }
223
+
224
+ // =============================================================================
225
+ // Link Button
226
+ // =============================================================================
227
+
228
+ .p-button-link {
229
+ background: transparent !important;
230
+ border-color: transparent !important;
231
+ color: var(--p-primary-500) !important;
232
+ text-decoration: none;
233
+
234
+ &:hover:not(:disabled) {
235
+ background: transparent !important;
236
+ border-color: transparent !important;
237
+ text-decoration: underline;
238
+ }
239
+ }
240
+
241
+ // =============================================================================
242
+ // Icon-only Button - Keep original PrimeVue style (no outlined override)
243
+ // =============================================================================
244
+
245
+ .p-button-icon-only {
246
+ // Reset to PrimeVue defaults - no outlined style for icon buttons
247
+ background: unset !important;
248
+ border: unset !important;
249
+ color: unset !important;
250
+
251
+ &:hover:not(:disabled) {
252
+ background: unset !important;
253
+ border-color: unset !important;
254
+ color: unset !important;
255
+ }
256
+ }
257
+
258
+ // =============================================================================
259
+ // Button Sizes
260
+ // =============================================================================
261
+
262
+ .p-button-sm {
263
+ font-size: $font-size-sm;
264
+ padding: 0.375rem 0.75rem;
265
+ }
266
+
267
+ .p-button-lg {
268
+ font-size: $font-size-lg;
269
+ padding: 0.75rem 1.25rem;
270
+ }
271
+
272
+ // =============================================================================
273
+ // Tags / Badges - Outlined Style
274
+ // =============================================================================
275
+
276
+ .p-tag {
277
+ background: transparent !important;
278
+ border: 1px solid var(--p-primary-500) !important;
279
+ color: var(--p-primary-500) !important;
280
+ font-weight: $font-weight-medium;
281
+ white-space: nowrap;
282
+ }
283
+
284
+ .p-tag-secondary {
285
+ background: transparent !important;
286
+ border-color: var(--p-surface-400) !important;
287
+ color: var(--p-surface-600) !important;
288
+ }
289
+
290
+ .p-tag-success {
291
+ background: transparent !important;
292
+ border-color: var(--p-green-500) !important;
293
+ color: var(--p-green-600) !important;
294
+ }
295
+
296
+ .p-tag-warn {
297
+ background: transparent !important;
298
+ border-color: var(--p-orange-500) !important;
299
+ color: var(--p-orange-600) !important;
300
+ }
301
+
302
+ .p-tag-danger {
303
+ background: transparent !important;
304
+ border-color: var(--p-red-500) !important;
305
+ color: var(--p-red-600) !important;
306
+ }
307
+
308
+ .p-tag-info {
309
+ background: transparent !important;
310
+ border-color: var(--p-blue-500) !important;
311
+ color: var(--p-blue-600) !important;
312
+ }
313
+
314
+ .p-tag-contrast {
315
+ background: transparent !important;
316
+ border-color: var(--p-surface-900) !important;
317
+ color: var(--p-surface-900) !important;
318
+ }
@@ -12,6 +12,20 @@
12
12
 
13
13
  @use 'variables' as *;
14
14
 
15
+ // =============================================================================
16
+ // DataTable Compact Rows
17
+ // =============================================================================
18
+
19
+ .p-datatable {
20
+ .p-datatable-tbody > tr > td {
21
+ padding: 0.5rem 0.75rem !important;
22
+ }
23
+
24
+ .p-datatable-thead > tr > th {
25
+ padding: 0.625rem 0.75rem !important;
26
+ }
27
+ }
28
+
15
29
  // =============================================================================
16
30
  // Table Actions
17
31
  // =============================================================================
@@ -195,9 +195,16 @@
195
195
  .form-actions {
196
196
  @include mobile {
197
197
  flex-direction: column;
198
+ gap: 0.5rem;
199
+
200
+ .form-actions-left {
201
+ width: 100%;
202
+ flex-direction: column;
203
+ }
198
204
 
199
205
  .p-button {
200
206
  width: 100%;
207
+ white-space: nowrap;
201
208
  }
202
209
  }
203
210
  }
@@ -26,6 +26,7 @@
26
26
 
27
27
  // Component-specific styles
28
28
  @use './alerts';
29
+ @use './buttons';
29
30
  @use './code';
30
31
  @use './dialogs';
31
32
  @use './markdown';
@@ -1,16 +0,0 @@
1
- <script setup>
2
- /**
3
- * DefaultToaster - Default toaster component for BaseLayout
4
- *
5
- * Renders the Toast component for notifications.
6
- * Uses PrimeVue Toast with default positioning.
7
- *
8
- * NOTE: For pages outside BaseLayout (like LoginPage),
9
- * apps must include Toast in their App.vue root component.
10
- */
11
- import Toast from 'primevue/toast'
12
- </script>
13
-
14
- <template>
15
- <Toast position="top-right" />
16
- </template>