@quazardous/qdadm 2.22.0 → 2.23.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": "@quazardous/qdadm",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
package/src/auth/index.ts CHANGED
@@ -15,6 +15,9 @@ export {
15
15
  } from './SessionAuthAdapter'
16
16
 
17
17
  export {
18
+ OAuthCodeAdapter,
19
+ createOAuthCodeAdapter,
20
+ type OAuthCodeAdapterOptions,
18
21
  GoogleOAuthAdapter,
19
22
  createGoogleOAuthAdapter,
20
23
  createPkceChallenge,
@@ -1,23 +1,10 @@
1
1
  /**
2
2
  * Google sign-in for qdadm, authorization-code + PKCE (#1775).
3
3
  *
4
- * ## The one rule this class exists to enforce
5
- *
6
- * qdadm **never** validates a Google credential in the browser. Decoding a JWT
7
- * client-side proves nothing: anyone can mint one. The authorization code is
8
- * therefore useless to the front — it is posted to *your* backend, which
9
- * exchanges it with Google using the client secret, verifies the identity, and
10
- * issues **its own** session. That session token is the only thing this adapter
11
- * ever stores.
12
- *
13
- * There is deliberately no code path that turns a Google response into a
14
- * session without your backend. That is a structural constraint, not advice.
15
- *
16
- * ## The backend contract is open
17
- *
18
- * `exchangeUrl` receives a POST and returns `{ token, user }`. Nothing about it
19
- * is JavaScript: implement it in Python, Go, PHP, anything. Because the
20
- * contract is open, configuration is enough for the common case —
4
+ * `OAuthCodeAdapter` with Google's defaults: its authorization endpoint, a
5
+ * `/auth/google/callback` redirect, and `offlineAccess` for a refresh token.
6
+ * The rule and the backend contract are the generic adapter's: the browser
7
+ * never validates a Google credential, your backend redeems the code.
21
8
  *
22
9
  * ```ts
23
10
  * new GoogleOAuthAdapter({
@@ -26,205 +13,47 @@
26
13
  * })
27
14
  * ```
28
15
  *
29
- * — and an app with a Python backend writes no adapter code at all. Override
30
- * `exchange()` only when your endpoint cannot match the documented shape.
31
- *
32
16
  * See docs/auth-google.md for the wire contract and a reference backend.
33
17
  *
34
18
  * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
35
19
  */
36
20
 
37
- import { LocalStorageSessionAuthAdapter, type AuthUser, type SessionData } from '../SessionAuthAdapter'
38
- import { createPkceChallenge, storeAttempt, takeAttempt, type StoredAttempt } from './pkce'
39
-
40
- /** What the front sends your backend. */
41
- export interface OAuthExchangePayload {
42
- /** The authorization code the provider handed back. */
43
- code: string
44
- /** The PKCE verifier that proves this code belongs to this browser. */
45
- codeVerifier: string
46
- /** Echoed so the backend can pin the exchange to the same redirect. */
47
- redirectUri: string
48
- }
21
+ import type { AuthUser } from '../SessionAuthAdapter'
22
+ import { OAuthCodeAdapter, type OAuthCodeAdapterOptions } from './OAuthCodeAdapter'
49
23
 
50
- export interface GoogleOAuthAdapterOptions {
51
- /** OAuth client id. Not a secret — the client SECRET stays on your backend. */
52
- clientId: string
53
- /**
54
- * Your endpoint that trades the code for a session. POSTed
55
- * `OAuthExchangePayload`, expected to answer `{ token, user }`.
56
- */
57
- exchangeUrl?: string
58
- /**
59
- * Where the provider redirects back. Must match a URI registered in the
60
- * provider console **exactly** — that is the single most common setup
61
- * failure. Defaults to `/auth/google/callback` on the current origin.
62
- */
63
- redirectUri?: string
64
- /** Scopes requested. Defaults to identity only. */
65
- scopes?: string[]
24
+ export interface GoogleOAuthAdapterOptions extends Omit<OAuthCodeAdapterOptions, 'authorizeEndpoint'> {
66
25
  /**
67
26
  * Ask for a refresh token. Requires `prompt: 'consent'` on Google to be
68
27
  * issued reliably, and the token stays with your backend — the front never
69
28
  * sees it.
70
29
  */
71
30
  offlineAccess?: boolean
72
- /** localStorage key for the session (see ADR 0008 on shared origins). */
73
- storageKey?: string
74
- /** Authorization endpoint. Overridable for tests and for other providers. */
31
+ /** Authorization endpoint. Defaults to Google's; overridable for tests. */
75
32
  authorizeEndpoint?: string
76
33
  }
77
34
 
78
35
  const GOOGLE_AUTHORIZE = 'https://accounts.google.com/o/oauth2/v2/auth'
79
36
 
80
37
  export class GoogleOAuthAdapter<TUser extends AuthUser = AuthUser>
81
- extends LocalStorageSessionAuthAdapter<TUser> {
82
- protected _clientId: string
83
- protected _exchangeUrl: string | null
84
- protected _redirectUri: string
85
- protected _scopes: string[]
38
+ extends OAuthCodeAdapter<TUser> {
39
+ static override readonly label: string = 'GoogleOAuthAdapter'
40
+ static override readonly defaultRedirectPath: string = '/auth/google/callback'
41
+
86
42
  protected _offlineAccess: boolean
87
- protected _authorizeEndpoint: string
88
43
 
89
44
  constructor(options: GoogleOAuthAdapterOptions) {
90
- super({ storageKey: options.storageKey ?? 'qdadm_auth' })
91
-
92
- if (!options?.clientId) {
93
- throw new Error('[GoogleOAuthAdapter] clientId is required')
94
- }
95
-
96
- this._clientId = options.clientId
97
- this._exchangeUrl = options.exchangeUrl ?? null
98
- this._redirectUri =
99
- options.redirectUri ??
100
- (typeof window !== 'undefined'
101
- ? `${window.location.origin}/auth/google/callback`
102
- : '/auth/google/callback')
103
- this._scopes = options.scopes ?? ['openid', 'email', 'profile']
45
+ super({ ...options, authorizeEndpoint: options?.authorizeEndpoint ?? GOOGLE_AUTHORIZE })
104
46
  this._offlineAccess = options.offlineAccess ?? false
105
- this._authorizeEndpoint = options.authorizeEndpoint ?? GOOGLE_AUTHORIZE
106
47
  }
107
48
 
108
- /** The redirect URI this adapter will use — handy for error messages. */
109
- get redirectUri(): string {
110
- return this._redirectUri
111
- }
112
-
113
- /**
114
- * Build the authorization URL for a fresh attempt.
115
- *
116
- * Stashes the PKCE verifier, the CSRF state and where the user was heading,
117
- * so the callback can finish what this started.
118
- */
119
- async buildAuthorizeUrl(redirectTo = '/'): Promise<string> {
120
- const { verifier, challenge, state } = await createPkceChallenge()
121
- storeAttempt({ verifier, state, redirectTo })
122
-
123
- const url = new URL(this._authorizeEndpoint)
124
- url.searchParams.set('client_id', this._clientId)
125
- url.searchParams.set('redirect_uri', this._redirectUri)
126
- url.searchParams.set('response_type', 'code')
127
- url.searchParams.set('scope', this._scopes.join(' '))
128
- url.searchParams.set('state', state)
129
- url.searchParams.set('code_challenge', challenge)
130
- url.searchParams.set('code_challenge_method', 'S256')
49
+ protected override extraAuthorizeParams(): Record<string, string> {
50
+ const params = super.extraAuthorizeParams()
131
51
  if (this._offlineAccess) {
132
- url.searchParams.set('access_type', 'offline')
52
+ params.access_type = 'offline'
133
53
  // Google only re-issues a refresh token when consent is asked again.
134
- url.searchParams.set('prompt', 'consent')
135
- }
136
-
137
- return url.toString()
138
- }
139
-
140
- /** Send the browser to the provider. */
141
- async beginLogin(redirectTo = '/'): Promise<void> {
142
- window.location.assign(await this.buildAuthorizeUrl(redirectTo))
143
- }
144
-
145
- /**
146
- * Trade the code for a session **on your backend**.
147
- *
148
- * Override this when your endpoint's shape differs. Whatever you do, the
149
- * exchange must happen server-side: the browser has no client secret and
150
- * cannot verify anything Google says.
151
- */
152
- async exchange(payload: OAuthExchangePayload): Promise<SessionData<TUser>> {
153
- if (!this._exchangeUrl) {
154
- throw new Error(
155
- '[GoogleOAuthAdapter] no exchangeUrl configured, and exchange() was not overridden. ' +
156
- 'The authorization code must be redeemed by your backend — the browser cannot do it.'
157
- )
158
- }
159
-
160
- const response = await fetch(this._exchangeUrl, {
161
- method: 'POST',
162
- headers: { 'content-type': 'application/json' },
163
- credentials: 'same-origin',
164
- body: JSON.stringify(payload),
165
- })
166
-
167
- if (!response.ok) {
168
- const detail = await response.text().catch(() => '')
169
- throw new Error(
170
- `[GoogleOAuthAdapter] exchange failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ''}`
171
- )
172
- }
173
-
174
- const data = (await response.json()) as SessionData<TUser>
175
- if (!data?.token || !data?.user) {
176
- throw new Error(
177
- '[GoogleOAuthAdapter] exchange endpoint must return { token, user } — see docs/auth-google.md'
178
- )
179
- }
180
- return data
181
- }
182
-
183
- /**
184
- * Finish the login from the callback route's query string.
185
- *
186
- * @returns the session plus where to send the user next.
187
- * @throws when the provider reported an error, when no attempt is pending,
188
- * or when `state` does not match — each of which is a reason to refuse.
189
- */
190
- async completeLogin(
191
- query: URLSearchParams | Record<string, string | undefined>
192
- ): Promise<SessionData<TUser> & { redirectTo: string }> {
193
- const read = (key: string): string | undefined =>
194
- query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key]
195
-
196
- const providerError = read('error')
197
- if (providerError) {
198
- throw new Error(`[GoogleOAuthAdapter] provider refused the login: ${providerError}`)
199
- }
200
-
201
- const attempt: StoredAttempt | null = takeAttempt()
202
- if (!attempt) {
203
- // No pending attempt: a replayed or bookmarked callback URL. Refusing is
204
- // the point — the attempt is consumed on read.
205
- throw new Error('[GoogleOAuthAdapter] no login attempt pending for this callback')
206
- }
207
-
208
- const state = read('state')
209
- if (!state || state !== attempt.state) {
210
- throw new Error('[GoogleOAuthAdapter] state mismatch — refusing the callback')
211
- }
212
-
213
- const code = read('code')
214
- if (!code) {
215
- throw new Error('[GoogleOAuthAdapter] callback carried no authorization code')
54
+ params.prompt = 'consent'
216
55
  }
217
-
218
- const session = await this.exchange({
219
- code,
220
- codeVerifier: attempt.verifier,
221
- redirectUri: this._redirectUri,
222
- })
223
-
224
- this.setSession(session.token, session.user)
225
- this.persist()
226
-
227
- return { ...session, redirectTo: attempt.redirectTo || '/' }
56
+ return params
228
57
  }
229
58
  }
230
59
 
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Sign-in with any OAuth 2 / OIDC provider, authorization-code + PKCE (#2264).
3
+ *
4
+ * ## The one rule this class exists to enforce
5
+ *
6
+ * qdadm **never** validates a provider credential in the browser. Decoding a
7
+ * JWT client-side proves nothing: anyone can mint one. The authorization code
8
+ * is therefore useless to the front — it is posted to *your* backend, which
9
+ * exchanges it with the provider using the client secret, verifies the
10
+ * identity, and issues **its own** session. That session token is the only
11
+ * thing this adapter ever stores.
12
+ *
13
+ * There is deliberately no code path that turns a provider response into a
14
+ * session without your backend. That is a structural constraint, not advice.
15
+ *
16
+ * ## The backend contract is open
17
+ *
18
+ * `exchangeUrl` receives a POST and returns `{ token, user }`. Nothing about it
19
+ * is JavaScript: implement it in Python, Go, PHP, anything. Keycloak, Auth0,
20
+ * Okta or Entra ID only change the authorization endpoint the browser goes to:
21
+ *
22
+ * ```ts
23
+ * new OAuthCodeAdapter({
24
+ * clientId: 'my-admin',
25
+ * authorizeEndpoint: 'https://sso.example.com/realms/acme/protocol/openid-connect/auth',
26
+ * exchangeUrl: '/auth/oidc/exchange',
27
+ * })
28
+ * ```
29
+ *
30
+ * Override `exchange()` only when your endpoint cannot match the documented
31
+ * shape. See docs/auth-oidc.md for the wire contract and provider examples.
32
+ *
33
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
34
+ */
35
+
36
+ import { LocalStorageSessionAuthAdapter, type AuthUser, type SessionData } from '../SessionAuthAdapter'
37
+ import { createPkceChallenge, storeAttempt, takeAttempt, type StoredAttempt } from './pkce'
38
+
39
+ /** What the front sends your backend. */
40
+ export interface OAuthExchangePayload {
41
+ /** The authorization code the provider handed back. */
42
+ code: string
43
+ /** The PKCE verifier that proves this code belongs to this browser. */
44
+ codeVerifier: string
45
+ /** Echoed so the backend can pin the exchange to the same redirect. */
46
+ redirectUri: string
47
+ }
48
+
49
+ export interface OAuthCodeAdapterOptions {
50
+ /** OAuth client id. Not a secret — the client SECRET stays on your backend. */
51
+ clientId: string
52
+ /** The provider's authorization endpoint, where the browser is sent to sign in. */
53
+ authorizeEndpoint: string
54
+ /**
55
+ * Your endpoint that trades the code for a session. POSTed
56
+ * `OAuthExchangePayload`, expected to answer `{ token, user }`.
57
+ */
58
+ exchangeUrl?: string
59
+ /**
60
+ * Where the provider redirects back. Must match a URI registered with the
61
+ * provider **exactly** — that is the single most common setup failure.
62
+ * Defaults to `/auth/callback` on the current origin.
63
+ */
64
+ redirectUri?: string
65
+ /** Scopes requested. Defaults to identity only. */
66
+ scopes?: string[]
67
+ /**
68
+ * Extra query parameters for the authorization URL: `audience` (Auth0),
69
+ * `kc_idp_hint` (Keycloak), `prompt`, `login_hint`… The parameters the flow
70
+ * itself sets (client id, redirect, scope, state, PKCE) cannot be overridden.
71
+ */
72
+ authorizeParams?: Record<string, string>
73
+ /** localStorage key for the session (see ADR 0008 on shared origins). */
74
+ storageKey?: string
75
+ }
76
+
77
+ /** Parameters the flow owns: letting a caller set them would break PKCE or CSRF protection. */
78
+ const FLOW_PARAMS = [
79
+ 'client_id',
80
+ 'redirect_uri',
81
+ 'response_type',
82
+ 'scope',
83
+ 'state',
84
+ 'code_challenge',
85
+ 'code_challenge_method',
86
+ ]
87
+
88
+ export class OAuthCodeAdapter<TUser extends AuthUser = AuthUser>
89
+ extends LocalStorageSessionAuthAdapter<TUser> {
90
+ /** Prefix of this adapter's error messages. Subclasses name themselves. */
91
+ static readonly label: string = 'OAuthCodeAdapter'
92
+ /** Redirect path on the current origin when `redirectUri` is not given. */
93
+ static readonly defaultRedirectPath: string = '/auth/callback'
94
+
95
+ protected _clientId: string
96
+ protected _authorizeEndpoint: string
97
+ protected _exchangeUrl: string | null
98
+ protected _redirectUri: string
99
+ protected _scopes: string[]
100
+ protected _authorizeParams: Record<string, string>
101
+
102
+ constructor(options: OAuthCodeAdapterOptions) {
103
+ super({ storageKey: options?.storageKey ?? 'qdadm_auth' })
104
+
105
+ if (!options?.clientId) {
106
+ throw new Error(`${this.errorPrefix} clientId is required`)
107
+ }
108
+ if (!options.authorizeEndpoint) {
109
+ throw new Error(`${this.errorPrefix} authorizeEndpoint is required: the provider's authorization URL`)
110
+ }
111
+
112
+ this._clientId = options.clientId
113
+ this._authorizeEndpoint = options.authorizeEndpoint
114
+ this._exchangeUrl = options.exchangeUrl ?? null
115
+ const redirectPath = (this.constructor as typeof OAuthCodeAdapter).defaultRedirectPath
116
+ this._redirectUri =
117
+ options.redirectUri ??
118
+ (typeof window !== 'undefined' ? `${window.location.origin}${redirectPath}` : redirectPath)
119
+ this._scopes = options.scopes ?? ['openid', 'email', 'profile']
120
+ this._authorizeParams = { ...(options.authorizeParams ?? {}) }
121
+ }
122
+
123
+ /** The redirect URI this adapter will use — handy for error messages. */
124
+ get redirectUri(): string {
125
+ return this._redirectUri
126
+ }
127
+
128
+ protected get errorPrefix(): string {
129
+ return `[${(this.constructor as typeof OAuthCodeAdapter).label}]`
130
+ }
131
+
132
+ /**
133
+ * Provider-specific query parameters for the authorization URL. Subclasses
134
+ * add their own here (Google's `access_type`, for instance).
135
+ */
136
+ protected extraAuthorizeParams(): Record<string, string> {
137
+ return { ...this._authorizeParams }
138
+ }
139
+
140
+ /**
141
+ * Build the authorization URL for a fresh attempt.
142
+ *
143
+ * Stashes the PKCE verifier, the CSRF state and where the user was heading,
144
+ * so the callback can finish what this started.
145
+ */
146
+ async buildAuthorizeUrl(redirectTo = '/'): Promise<string> {
147
+ const extra = this.extraAuthorizeParams()
148
+ const overridden = Object.keys(extra).filter((key) => FLOW_PARAMS.includes(key))
149
+ if (overridden.length > 0) {
150
+ throw new Error(
151
+ `${this.errorPrefix} authorizeParams cannot set ${overridden.join(', ')}: the flow sets them itself`
152
+ )
153
+ }
154
+
155
+ const { verifier, challenge, state } = await createPkceChallenge()
156
+ storeAttempt({ verifier, state, redirectTo })
157
+
158
+ const url = new URL(this._authorizeEndpoint)
159
+ url.searchParams.set('client_id', this._clientId)
160
+ url.searchParams.set('redirect_uri', this._redirectUri)
161
+ url.searchParams.set('response_type', 'code')
162
+ url.searchParams.set('scope', this._scopes.join(' '))
163
+ url.searchParams.set('state', state)
164
+ url.searchParams.set('code_challenge', challenge)
165
+ url.searchParams.set('code_challenge_method', 'S256')
166
+ for (const [key, value] of Object.entries(extra)) {
167
+ url.searchParams.set(key, value)
168
+ }
169
+
170
+ return url.toString()
171
+ }
172
+
173
+ /** Send the browser to the provider. */
174
+ async beginLogin(redirectTo = '/'): Promise<void> {
175
+ window.location.assign(await this.buildAuthorizeUrl(redirectTo))
176
+ }
177
+
178
+ /**
179
+ * Trade the code for a session **on your backend**.
180
+ *
181
+ * Override this when your endpoint's shape differs. Whatever you do, the
182
+ * exchange must happen server-side: the browser has no client secret and
183
+ * cannot verify anything the provider says.
184
+ */
185
+ async exchange(payload: OAuthExchangePayload): Promise<SessionData<TUser>> {
186
+ if (!this._exchangeUrl) {
187
+ throw new Error(
188
+ `${this.errorPrefix} no exchangeUrl configured, and exchange() was not overridden. ` +
189
+ 'The authorization code must be redeemed by your backend — the browser cannot do it.'
190
+ )
191
+ }
192
+
193
+ const response = await fetch(this._exchangeUrl, {
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json' },
196
+ credentials: 'same-origin',
197
+ body: JSON.stringify(payload),
198
+ })
199
+
200
+ if (!response.ok) {
201
+ const detail = await response.text().catch(() => '')
202
+ throw new Error(
203
+ `${this.errorPrefix} exchange failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ''}`
204
+ )
205
+ }
206
+
207
+ const data = (await response.json()) as SessionData<TUser>
208
+ if (!data?.token || !data?.user) {
209
+ throw new Error(
210
+ `${this.errorPrefix} exchange endpoint must return { token, user } — see docs/auth-oidc.md`
211
+ )
212
+ }
213
+ return data
214
+ }
215
+
216
+ /**
217
+ * Finish the login from the callback route's query string.
218
+ *
219
+ * @returns the session plus where to send the user next.
220
+ * @throws when the provider reported an error, when no attempt is pending,
221
+ * or when `state` does not match — each of which is a reason to refuse.
222
+ */
223
+ async completeLogin(
224
+ query: URLSearchParams | Record<string, string | undefined>
225
+ ): Promise<SessionData<TUser> & { redirectTo: string }> {
226
+ const read = (key: string): string | undefined =>
227
+ query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key]
228
+
229
+ const providerError = read('error')
230
+ if (providerError) {
231
+ throw new Error(`${this.errorPrefix} provider refused the login: ${providerError}`)
232
+ }
233
+
234
+ const attempt: StoredAttempt | null = takeAttempt()
235
+ if (!attempt) {
236
+ // No pending attempt: a replayed or bookmarked callback URL. Refusing is
237
+ // the point — the attempt is consumed on read.
238
+ throw new Error(`${this.errorPrefix} no login attempt pending for this callback`)
239
+ }
240
+
241
+ const state = read('state')
242
+ if (!state || state !== attempt.state) {
243
+ throw new Error(`${this.errorPrefix} state mismatch — refusing the callback`)
244
+ }
245
+
246
+ const code = read('code')
247
+ if (!code) {
248
+ throw new Error(`${this.errorPrefix} callback carried no authorization code`)
249
+ }
250
+
251
+ const session = await this.exchange({
252
+ code,
253
+ codeVerifier: attempt.verifier,
254
+ redirectUri: this._redirectUri,
255
+ })
256
+
257
+ this.setSession(session.token, session.user)
258
+ this.persist()
259
+
260
+ return { ...session, redirectTo: attempt.redirectTo || '/' }
261
+ }
262
+ }
263
+
264
+ export function createOAuthCodeAdapter<TUser extends AuthUser = AuthUser>(
265
+ options: OAuthCodeAdapterOptions
266
+ ): OAuthCodeAdapter<TUser> {
267
+ return new OAuthCodeAdapter<TUser>(options)
268
+ }
@@ -6,11 +6,17 @@
6
6
  * @module auth/oauth
7
7
  */
8
8
 
9
+ export {
10
+ OAuthCodeAdapter,
11
+ createOAuthCodeAdapter,
12
+ type OAuthCodeAdapterOptions,
13
+ type OAuthExchangePayload,
14
+ } from './OAuthCodeAdapter'
15
+
9
16
  export {
10
17
  GoogleOAuthAdapter,
11
18
  createGoogleOAuthAdapter,
12
19
  type GoogleOAuthAdapterOptions,
13
- type OAuthExchangePayload,
14
20
  } from './GoogleOAuthAdapter'
15
21
 
16
22
  export {
@@ -32,7 +32,8 @@
32
32
  * - #error: Custom error display
33
33
  * - #loading: Custom loading display
34
34
  */
35
- import { computed, type PropType } from 'vue'
35
+ import { computed, inject, type PropType } from 'vue'
36
+ import { GUARD_DIALOG_HOST } from '../../composables/useGuardStore'
36
37
  import PageHeader from '../layout/PageHeader.vue'
37
38
  import FormActions from './FormActions.vue'
38
39
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
@@ -122,6 +123,10 @@ const headerActions = computed<ResolvedAction[]>(() =>
122
123
  // Get error message from fetchError
123
124
  const fetchErrorMessage = computed<string | null>(() => formatFetchError(props.fetchError))
124
125
 
126
+ // A qdadm layout (AppLayout, BaseLayout) renders the registered guard dialog: rendering it here too
127
+ // stacked two identical dialogs (#2267). Without such a layout, this page renders it.
128
+ const layoutRendersGuardDialog = inject(GUARD_DIALOG_HOST, false)
129
+
125
130
  // Guard dialog handlers
126
131
  function onGuardSaveAndLeave(): void {
127
132
  if (props.guardDialog?.onSaveAndLeave) {
@@ -225,7 +230,7 @@ function onGuardStay(): void {
225
230
 
226
231
  <!-- Unsaved Changes Dialog -->
227
232
  <UnsavedChangesDialog
228
- v-if="guardDialog"
233
+ v-if="guardDialog && !layoutRendersGuardDialog"
229
234
  :visible="guardDialog.visible.value"
230
235
  :saving="saving"
231
236
  :hasOnSave="guardDialog.hasOnSave"
@@ -19,7 +19,7 @@ import { useNavigation, type NavSection } from '../../composables/useNavigation'
19
19
  import { prettifyRole } from '../../utils/formatters'
20
20
  import { useApp } from '../../composables/useApp'
21
21
  import { useAuth } from '../../composables/useAuth'
22
- import { useGuardDialog } from '../../composables/useGuardStore'
22
+ import { useGuardDialog, GUARD_DIALOG_HOST } from '../../composables/useGuardStore'
23
23
  import { useNavContext, type BreadcrumbItem, type NavLinkItem } from '../../composables/useNavContext'
24
24
  import QdButton from '../base/QdButton.vue'
25
25
  import Breadcrumb from 'primevue/breadcrumb'
@@ -56,6 +56,8 @@ const homeRoute = inject<string>('qdadmHomeRoute', 'home')
56
56
 
57
57
  // Guard dialog from shared store (registered by useBareForm/useEntityItemFormPage when a form is active)
58
58
  const guardDialog = useGuardDialog()
59
+ // This layout renders it: pages below do not render their own (#2267)
60
+ provide(GUARD_DIALOG_HOST, true)
59
61
 
60
62
  const router = useRouter()
61
63
  const route = useRoute()
@@ -35,7 +35,7 @@ import { RouterView } from 'vue-router'
35
35
  import ConfirmDialog from 'primevue/confirmdialog'
36
36
  import Zone from './Zone.vue'
37
37
  import { LAYOUT_ZONES } from '../../zones/zones'
38
- import { useGuardDialog } from '../../composables/useGuardStore'
38
+ import { useGuardDialog, GUARD_DIALOG_HOST } from '../../composables/useGuardStore'
39
39
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
40
40
  import type { BreadcrumbItem, NavLinkItem } from '../../composables/useNavContext'
41
41
 
@@ -52,6 +52,8 @@ const hasMainSlot: boolean = !!slots.main
52
52
 
53
53
  // Guard dialog from shared store (registered by useBareForm/useEntityItemFormPage when a form is active)
54
54
  const guardDialog = useGuardDialog()
55
+ // This layout renders it: pages below do not render their own (#2267)
56
+ provide(GUARD_DIALOG_HOST, true)
55
57
 
56
58
  // Provide breadcrumb/navlinks override mechanism for child pages
57
59
  const breadcrumbOverride = ref<BreadcrumbItem[] | null>(null)
@@ -155,19 +155,23 @@ function getWrappedComponent(block: BlockConfig): Component | undefined {
155
155
  <div :data-zone="name" class="qdadm-zone">
156
156
  <template v-if="hasBlocks">
157
157
  <template v-for="block in blocks" :key="block.id || block.weight">
158
- <!-- Render wrapped blocks using memoized component -->
159
- <component
160
- v-if="block.wrappers"
161
- :is="getWrappedComponent(block)"
162
- />
163
- <!-- Render simple blocks directly, passing Zone's slot content -->
164
- <component
165
- v-else
166
- :is="block.component"
167
- v-bind="{ ...blockProps, ...block.props }"
168
- >
169
- <slot />
170
- </component>
158
+ <!-- Each block in its own marker (#2363): no box (display: contents), so the layout is unchanged, and the
159
+ page tools can tell which block rendered what — blocks with several root elements included. -->
160
+ <div :data-zone-block="block.id ?? ''" style="display: contents">
161
+ <!-- Render wrapped blocks using memoized component -->
162
+ <component
163
+ v-if="block.wrappers"
164
+ :is="getWrappedComponent(block)"
165
+ />
166
+ <!-- Render simple blocks directly, passing Zone's slot content -->
167
+ <component
168
+ v-else
169
+ :is="block.component"
170
+ v-bind="{ ...blockProps, ...block.props }"
171
+ >
172
+ <slot />
173
+ </component>
174
+ </div>
171
175
  </template>
172
176
  </template>
173
177
  <component
@@ -36,6 +36,7 @@ import {
36
36
  type CreateContext,
37
37
  } from './useEntityItemPage'
38
38
  import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
39
+ import { registerPageState } from './usePageState'
39
40
  import { deepClone } from '../utils/transformers'
40
41
  import { getSiblingRoutes } from '../module/moduleRegistry'
41
42
  import {
@@ -227,6 +228,19 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
227
228
  onUnmounted(() => unregisterGuardDialog(guardDialog!))
228
229
  }
229
230
 
231
+ // What this form is doing, for the debug tools and the MCP (#2363)
232
+ onUnmounted(
233
+ registerPageState(() => ({
234
+ kind: 'form',
235
+ entity: String((manager as { name?: unknown }).name ?? ''),
236
+ mode: mode.value,
237
+ dirtyFields: [...dirtyFields.value],
238
+ errors: { ...errors.value },
239
+ saving: saving.value,
240
+ loading: loading.value,
241
+ }))
242
+ )
243
+
230
244
  // ============ BREADCRUMB ============
231
245
 
232
246
  const { breadcrumbItems } = useBreadcrumb({ entity: data })
@@ -33,7 +33,8 @@
33
33
  * <ShowPage v-bind="show.props.value" v-on="show.events" />
34
34
  * ```
35
35
  */
36
- import { ref, computed, type Ref, type ComputedRef } from 'vue'
36
+ import { ref, computed, onUnmounted, type Ref, type ComputedRef } from 'vue'
37
+ import { registerPageState } from './usePageState'
37
38
  import { useRouter, type Router } from 'vue-router'
38
39
  import { useConfirm } from 'primevue/useconfirm'
39
40
  import { requireDeleteConfirmation } from './confirmDelete'
@@ -310,6 +311,17 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
310
311
 
311
312
  const { manager, orchestrator: baseOrchestrator, data, loading, error, entityId, entityLabel, isLoaded, hydrator } = base
312
313
 
314
+ // What this show page is doing, for the debug tools and the MCP (#2363)
315
+ onUnmounted(
316
+ registerPageState(() => ({
317
+ kind: 'show',
318
+ entity: String((manager as { name?: unknown }).name ?? ''),
319
+ loaded: isLoaded.value,
320
+ loading: loading.value,
321
+ error: error.value,
322
+ }))
323
+ )
324
+
313
325
  // Reload when THIS record changed outside the session (#1888 lot D).
314
326
  // Scoped by id: an event about another record must not reload every open
315
327
  // detail page. An event carrying no id concerns the whole entity and applies.
@@ -5,7 +5,13 @@
5
5
  * and parent components (AppLayout) to render it.
6
6
  */
7
7
 
8
- import { shallowRef, type ShallowRef, type Ref } from 'vue'
8
+ import { shallowRef, type InjectionKey, type ShallowRef, type Ref } from 'vue'
9
+
10
+ /**
11
+ * Provided by a layout that renders the registered guard dialog (#2267). FormPage renders its own dialog only
12
+ * without one above it, so a dirty form never shows the dialog twice.
13
+ */
14
+ export const GUARD_DIALOG_HOST: InjectionKey<boolean> = Symbol('qdadm.guardDialogHost')
9
15
 
10
16
  /**
11
17
  * Guard dialog interface
@@ -37,6 +37,7 @@ import {
37
37
  } from '../routeState'
38
38
  import { useConfirm } from 'primevue/useconfirm'
39
39
  import { requireDeleteConfirmation } from './confirmDelete'
40
+ import { registerPageState, activeFilters } from './usePageState'
40
41
  import { useHooks } from './useHooks.js'
41
42
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
42
43
  import { useActiveStack } from '../chain/useActiveStack.js'
@@ -1184,6 +1185,23 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1184
1185
  window.removeEventListener('resize', handleResize)
1185
1186
  })
1186
1187
 
1188
+ // What this list is doing, for the debug tools and the MCP (#2363): counts and names, never row contents
1189
+ onUnmounted(
1190
+ registerPageState(() => ({
1191
+ kind: 'list',
1192
+ entity: String(entity),
1193
+ rows: displayItems.value.length,
1194
+ total: totalRecords.value,
1195
+ page: page.value,
1196
+ pageSize: pageSize.value,
1197
+ sort: sortField.value ? { field: sortField.value, order: sortOrder.value < 0 ? 'desc' : 'asc' } : null,
1198
+ search: searchQuery.value,
1199
+ filters: activeFilters(filterValues.value as Record<string, unknown>),
1200
+ selected: selected.value.length,
1201
+ loading: loading.value,
1202
+ }))
1203
+ )
1204
+
1187
1205
  // Reload when this entity changed outside the session (#1888 lot D).
1188
1206
  //
1189
1207
  // Only fires for entities the app declared in sse.entities, so an app with
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What the page on screen is doing (#2363), for the debug tools and the MCP's `page_snapshot`.
3
+ *
4
+ * The list, form and show composables register a getter while their page lives. Pages can nest (a list of loans
5
+ * inside a book's show page), so the store is a stack: the innermost page answers, and the outer one answers again
6
+ * once the inner one is gone. Counts and names only — never row contents.
7
+ */
8
+
9
+ export interface ListPageState {
10
+ kind: 'list'
11
+ entity: string
12
+ rows: number
13
+ total: number
14
+ page: number
15
+ pageSize: number
16
+ sort: { field: string; order: 'asc' | 'desc' } | null
17
+ search: string
18
+ /** Active filters only: empty values are left out. */
19
+ filters: Record<string, unknown>
20
+ selected: number
21
+ loading: boolean
22
+ }
23
+
24
+ export interface FormPageState {
25
+ kind: 'form'
26
+ entity: string
27
+ mode: 'create' | 'edit'
28
+ dirtyFields: string[]
29
+ errors: Record<string, string>
30
+ saving: boolean
31
+ loading: boolean
32
+ }
33
+
34
+ export interface ShowPageState {
35
+ kind: 'show'
36
+ entity: string
37
+ loaded: boolean
38
+ loading: boolean
39
+ error: string | null
40
+ }
41
+
42
+ export type PageState = ListPageState | FormPageState | ShowPageState
43
+ export type PageStateGetter = () => PageState
44
+
45
+ const stack: PageStateGetter[] = []
46
+
47
+ /** Register the page's state; call the returned function when the page goes away. */
48
+ export function registerPageState(getter: PageStateGetter): () => void {
49
+ stack.push(getter)
50
+ return () => {
51
+ const at = stack.lastIndexOf(getter)
52
+ if (at >= 0) stack.splice(at, 1)
53
+ }
54
+ }
55
+
56
+ /** The innermost page's state, or null when no qdadm page is on screen. */
57
+ export function currentPageState(): PageState | null {
58
+ const getter = stack[stack.length - 1]
59
+ if (!getter) return null
60
+ try {
61
+ return getter()
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ /** Active filter values: what a user set, not the empty defaults. */
68
+ export function activeFilters(values: Record<string, unknown> | null | undefined): Record<string, unknown> {
69
+ const active: Record<string, unknown> = {}
70
+ for (const [name, value] of Object.entries(values ?? {})) {
71
+ if (value === null || value === undefined || value === '') continue
72
+ if (Array.isArray(value) && value.length === 0) continue
73
+ active[name] = value
74
+ }
75
+ return active
76
+ }
package/src/index.ts CHANGED
@@ -164,8 +164,9 @@ export * from './entity/auth'
164
164
  // SESSION AUTH (user authentication)
165
165
  // ════════════════════════════════════════════════════════════════════════════
166
166
  export { LocalStorageSessionAuthAdapter } from './auth'
167
- export { GoogleOAuthAdapter, createGoogleOAuthAdapter, createPkceChallenge } from './auth'
167
+ export { OAuthCodeAdapter, createOAuthCodeAdapter, GoogleOAuthAdapter, createGoogleOAuthAdapter, createPkceChallenge } from './auth'
168
168
  export type {
169
+ OAuthCodeAdapterOptions,
169
170
  GoogleOAuthAdapterOptions,
170
171
  OAuthExchangePayload,
171
172
  PkceChallenge,
@@ -18,6 +18,8 @@ import { createQdadm } from '../plugin.js'
18
18
  import { createNotificationStore, NOTIFICATION_KEY } from '../notifications/NotificationStore'
19
19
  import { I18N_INJECTION_KEY } from '../i18n/useI18n'
20
20
  import { warnWithoutQdadmVitePlugin } from './vitePluginCheck'
21
+ import { currentPageState } from '../composables/usePageState'
22
+ import { explainGrant } from '../security/explainGrant'
21
23
  import type { Kernel } from './Kernel'
22
24
  // #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
23
25
  type Self = Kernel
@@ -301,6 +303,12 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
301
303
  hooks: this.hookRegistry,
302
304
  zones: this.zoneRegistry,
303
305
  activeStack: this.activeStack,
306
+ // What the page on screen is doing — list, form or show (#2363)
307
+ pageState: { current: currentPageState },
308
+ // Why a permission is granted or denied, told the way isGranted decides it (#2363)
309
+ security: this.securityChecker
310
+ ? { explain: (attribute: string, subject: unknown = null) => explainGrant(this.securityChecker!, attribute, subject) }
311
+ : null,
304
312
  stackHydrator: this.stackHydrator,
305
313
  deferred: this.deferred,
306
314
  router: this.router,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Why a permission is granted or denied (#2363) — the same path as `SecurityChecker.isGranted`, told instead of
3
+ * answered: the app's own judge first, the role hierarchy for a `ROLE_*` attribute, then the nearest role whose
4
+ * grant covers the key (its exact key before a wildcard), then the user's own permissions.
5
+ *
6
+ * For the debug tools and the MCP's `page_snapshot`; it grants nothing and never changes a verdict.
7
+ */
8
+ import { PermissionMatcher } from './PermissionMatcher'
9
+ import type { SecurityChecker } from '../entity/auth/SecurityChecker'
10
+
11
+ export type GrantExplanation =
12
+ /** No current user: everything is denied. */
13
+ | { granted: false; decidedBy: 'no-user' }
14
+ /** The app's `security.grant` function answered (a judge that throws denies). */
15
+ | { granted: boolean; decidedBy: 'app' }
16
+ /** A `ROLE_*` attribute, answered by the role hierarchy. */
17
+ | { granted: boolean; decidedBy: 'role-hierarchy'; roles: string[] }
18
+ /** A grant covers the key: `role` holds `grant` (null: the user's own permissions). */
19
+ | { granted: true; decidedBy: 'grant'; role: string | null; grant: string; roles: string[] }
20
+ /** No grant of the user's roles, nor of the user, covers the key. */
21
+ | { granted: false; decidedBy: 'grant'; roles: string[] }
22
+
23
+ const covering = (grants: readonly string[], attribute: string): string | undefined =>
24
+ grants.includes(attribute) ? attribute : grants.find((g) => PermissionMatcher.matches(g, attribute))
25
+
26
+ export function explainGrant(checker: SecurityChecker, attribute: string, subject: unknown = null): GrantExplanation {
27
+ const user = checker.getCurrentUser()
28
+ if (!user) return { granted: false, decidedBy: 'no-user' }
29
+
30
+ const judge = checker.grant
31
+ if (judge) {
32
+ try {
33
+ const answer: unknown = judge.isGranted(attribute, subject, user)
34
+ if (typeof answer === 'boolean') return { granted: answer, decidedBy: 'app' }
35
+ } catch {
36
+ return { granted: false, decidedBy: 'app' }
37
+ }
38
+ }
39
+
40
+ const roles = (user.roles || [user.role || '']).filter((role): role is string => Boolean(role))
41
+ if (attribute.startsWith('ROLE_')) {
42
+ return { granted: checker.roleHierarchy.isGrantedRole(roles, attribute), decidedBy: 'role-hierarchy', roles }
43
+ }
44
+
45
+ for (const role of roles) {
46
+ for (const reachable of checker.roleHierarchy.getReachableRoles(role)) {
47
+ const grant = covering(checker.rolesProvider.getPermissions(reachable) ?? [], attribute)
48
+ if (grant) return { granted: true, decidedBy: 'grant', role: reachable, grant, roles }
49
+ }
50
+ }
51
+ const own = covering(Array.isArray(user.permissions) ? (user.permissions as string[]) : [], attribute)
52
+ if (own) return { granted: true, decidedBy: 'grant', role: null, grant: own, roles }
53
+
54
+ return { granted: false, decidedBy: 'grant', roles }
55
+ }
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  export { PermissionMatcher } from './PermissionMatcher'
28
+ export { explainGrant, type GrantExplanation } from './explainGrant'
28
29
  export {
29
30
  PermissionRegistry,
30
31
  type PermissionDefinition,