@mintplayer/ng-spark 22.30.0 → 22.31.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/README.md +2 -2
- package/auth/README.md +216 -0
- package/fesm2022/mintplayer-ng-spark-auth-account.mjs +2023 -0
- package/fesm2022/mintplayer-ng-spark-auth-account.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-auth-bar.mjs +96 -0
- package/fesm2022/mintplayer-ng-spark-auth-auth-bar.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-confirm-email.mjs +200 -0
- package/fesm2022/mintplayer-ng-spark-auth-confirm-email.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-core.mjs +452 -0
- package/fesm2022/mintplayer-ng-spark-auth-core.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-external-login.mjs +201 -0
- package/fesm2022/mintplayer-ng-spark-auth-external-login.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-forgot-password.mjs +196 -0
- package/fesm2022/mintplayer-ng-spark-auth-forgot-password.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-guards.mjs +16 -0
- package/fesm2022/mintplayer-ng-spark-auth-guards.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-interceptors.mjs +18 -0
- package/fesm2022/mintplayer-ng-spark-auth-interceptors.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-login.mjs +282 -0
- package/fesm2022/mintplayer-ng-spark-auth-login.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-models.mjs +114 -0
- package/fesm2022/mintplayer-ng-spark-auth-models.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-pipes.mjs +43 -0
- package/fesm2022/mintplayer-ng-spark-auth-pipes.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-register.mjs +216 -0
- package/fesm2022/mintplayer-ng-spark-auth-register.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-reset-password.mjs +223 -0
- package/fesm2022/mintplayer-ng-spark-auth-reset-password.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-routes.mjs +194 -0
- package/fesm2022/mintplayer-ng-spark-auth-routes.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-sign-in.mjs +210 -0
- package/fesm2022/mintplayer-ng-spark-auth-sign-in.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth-two-factor.mjs +218 -0
- package/fesm2022/mintplayer-ng-spark-auth-two-factor.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-auth.mjs +48 -0
- package/fesm2022/mintplayer-ng-spark-auth.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-client-operations.mjs +294 -2
- package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-identity-provider-connected-applications.mjs +389 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-connected-applications.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-core.mjs +204 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-core.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-developers.mjs +486 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-developers.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-management.mjs +350 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider-management.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider.mjs +66 -0
- package/fesm2022/mintplayer-ng-spark-identity-provider.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-pwa.mjs +162 -0
- package/fesm2022/mintplayer-ng-spark-pwa.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-services.mjs +11 -1
- package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
- package/package.json +99 -1
- package/types/mintplayer-ng-spark-auth-account.d.ts +255 -0
- package/types/mintplayer-ng-spark-auth-auth-bar.d.ts +22 -0
- package/types/mintplayer-ng-spark-auth-confirm-email.d.ts +21 -0
- package/types/mintplayer-ng-spark-auth-core.d.ts +197 -0
- package/types/mintplayer-ng-spark-auth-external-login.d.ts +132 -0
- package/types/mintplayer-ng-spark-auth-forgot-password.d.ts +18 -0
- package/types/mintplayer-ng-spark-auth-guards.d.ts +15 -0
- package/types/mintplayer-ng-spark-auth-interceptors.d.ts +2 -0
- package/types/mintplayer-ng-spark-auth-login.d.ts +46 -0
- package/types/mintplayer-ng-spark-auth-models.d.ts +489 -0
- package/types/mintplayer-ng-spark-auth-pipes.d.ts +8 -0
- package/types/mintplayer-ng-spark-auth-register.d.ts +21 -0
- package/types/mintplayer-ng-spark-auth-reset-password.d.ts +25 -0
- package/types/mintplayer-ng-spark-auth-routes.d.ts +154 -0
- package/types/mintplayer-ng-spark-auth-sign-in.d.ts +84 -0
- package/types/mintplayer-ng-spark-auth-two-factor.d.ts +23 -0
- package/types/mintplayer-ng-spark-auth.d.ts +24 -0
- package/types/mintplayer-ng-spark-client-operations.d.ts +78 -3
- package/types/mintplayer-ng-spark-identity-provider-connected-applications.d.ts +35 -0
- package/types/mintplayer-ng-spark-identity-provider-core.d.ts +144 -0
- package/types/mintplayer-ng-spark-identity-provider-developers.d.ts +55 -0
- package/types/mintplayer-ng-spark-identity-provider-management.d.ts +33 -0
- package/types/mintplayer-ng-spark-identity-provider.d.ts +64 -0
- package/types/mintplayer-ng-spark-pwa.d.ts +89 -0
- package/types/mintplayer-ng-spark-services.d.ts +10 -0
- package/types/mintplayer-ng-spark-shell.d.ts +2 -2
- package/types/mintplayer-ng-spark.d.ts +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","names":[],"sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/client-methods.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject, TranslatedString } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n /** The text in the request's language (the browser's `Accept-Language`). */\n message: string;\n /**\n * The text in every language, when the server has it. Shown in the language the user picked in\n * the app (`SparkLanguageService`), which the server never learns; `message` is the fallback.\n */\n translatedMessage?: TranslatedString | null;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\n// `disableAction` is gone (#460, D13): the server no longer emits it. Disabled actions arrive on\n// `PersistentObject.disabledActions` / `QueryResult.disabledActions`, and a submitted disabled action\n// is refused with 403.\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n /** Whether the client offers its own translated Cancel, answered as `'Cancel'` (`IRetryAccessor.Action(..., cancellable)`). */\n cancellable?: boolean;\n /** A client method to run instead of a modal (`IRetryAccessor.Invoke`); see `provideSparkClientMethods`. */\n clientMethod?: string | null;\n /** Its argument, as the server serialized it. */\n arguments?: unknown;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | RetryOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark-auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import {\n EnvironmentInjector,\n type EnvironmentProviders,\n Injectable,\n InjectionToken,\n inject,\n makeEnvironmentProviders,\n runInInjectionContext,\n} from '@angular/core';\n\n/**\n * One step a server action needs from the browser (`IRetryAccessor.Invoke` on the server): WebAuthn,\n * a file picker, the clipboard. The answer travels back into the same server action as the retry's\n * `value`, so authorization, state and the follow-up all stay inside that one action.\n */\nexport interface SparkClientMethod {\n /**\n * Runs the step. Called in an injection context, so `inject()` works synchronously at the top.\n * A rejection or a thrown error answers the server with `Cancel`, exactly like an unknown name.\n */\n invoke(args: unknown): Promise<unknown>;\n /**\n * Whether this browser can run the method at all. An action whose `requiresClient` names a method\n * that is unsupported is shown disabled; an `Invoke` of it answers `Cancel` without running it.\n * Absent means always supported.\n */\n supported?(): boolean;\n /** The translation key explaining why the method is unsupported; defaults to `common.clientUnsupported`. */\n unsupportedReason?: string;\n}\n\n/** Client methods by the name the server invokes them with, e.g. `{ 'webauthn.create': { invoke: … } }`. */\nexport type SparkClientMethodMap = Readonly<Record<string, SparkClientMethod>>;\n\n/** Multi-provider token; use {@link provideSparkClientMethods}. A later registration of a name wins. */\nexport const SPARK_CLIENT_METHODS = new InjectionToken<readonly SparkClientMethodMap[]>('SPARK_CLIENT_METHODS');\n\n/** The reason shown on an action whose client method is missing or unsupported, when the method names none. */\nexport const SPARK_CLIENT_UNSUPPORTED_REASON = 'common.clientUnsupported';\n\n/**\n * Registers client methods a server action may invoke through a retry (`IRetryAccessor.Invoke`).\n *\n * The set is closed on purpose: the server can only name a method the app registered here, and an\n * unknown name answers `Cancel`. Nothing the server sends is evaluated.\n *\n * @example\n * provideSparkClientMethods({\n * 'clipboard.read': { invoke: () => navigator.clipboard.readText(), supported: () => !!navigator.clipboard },\n * })\n */\nexport function provideSparkClientMethods(methods: SparkClientMethodMap): EnvironmentProviders {\n return makeEnvironmentProviders([{ provide: SPARK_CLIENT_METHODS, useValue: methods, multi: true }]);\n}\n\n/** How a client method ended: its value, or a failure the server sees as `Cancel`. */\nexport type SparkClientMethodOutcome = { ok: true; value: unknown } | { ok: false };\n\n/** The registered client methods: whether one can run here, and running it. */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientMethodRegistry {\n private readonly injector = inject(EnvironmentInjector);\n private readonly methods: ReadonlyMap<string, SparkClientMethod>;\n\n constructor() {\n const methods = new Map<string, SparkClientMethod>();\n for (const registration of inject(SPARK_CLIENT_METHODS, { optional: true }) ?? []) {\n for (const [name, method] of Object.entries(registration))\n methods.set(name, method);\n }\n this.methods = methods;\n }\n\n /**\n * Null when the method is registered and supported here; otherwise the translation key saying why\n * it is not. Null for no name at all, so an action without `requiresClient` is never affected.\n */\n unavailableReason(name: string | null | undefined): string | null {\n if (!name) return null;\n const method = this.methods.get(name);\n if (!method) return SPARK_CLIENT_UNSUPPORTED_REASON;\n return this.isSupported(method) ? null : (method.unsupportedReason ?? SPARK_CLIENT_UNSUPPORTED_REASON);\n }\n\n /**\n * Runs the method registered under `name`. Fails closed: an unknown name, an unsupported method, a\n * rejection or a thrown error all end as `{ ok: false }`, which the retry loop answers as `Cancel`.\n */\n async invoke(name: string, args: unknown): Promise<SparkClientMethodOutcome> {\n const method = this.methods.get(name);\n if (!method) {\n // A server naming a method this app never registered is a wiring mistake worth a line in\n // the console; a user cancelling a ceremony is not, so rejections stay quiet.\n console.warn(`[spark] the server invoked the client method '${name}', which is not registered; answered Cancel.`);\n return { ok: false };\n }\n if (!this.isSupported(method)) return { ok: false };\n\n try {\n const value = await runInInjectionContext(this.injector, () => method.invoke(args));\n return { ok: true, value };\n } catch {\n return { ok: false };\n }\n }\n\n private isSupported(method: SparkClientMethod): boolean {\n if (!method.supported) return true;\n try {\n return runInInjectionContext(this.injector, () => method.supported!()) === true;\n } catch {\n return false;\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: var(--bs-white);\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: var(--bs-primary); }\n .spark-toast--success { background: var(--bs-success); }\n .spark-toast--warning { background: var(--bs-warning); color: var(--bs-dark); }\n .spark-toast--error { background: var(--bs-danger); }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /**\n * Bumped every time a refresh is asked for this key.\n *\n * ⚠️ Reading it makes the caller depend on EVERY key, not just this one: the counters share one\n * signal. A grid must therefore compare the value with the one it last saw, never react to the\n * read alone (#319) — otherwise it re-fetches for every query refreshed anywhere in the app.\n */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[normalize(queryId)] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * `queryId` is the query's id or its alias. A grid answers to both, whichever its own `queryId`\n * input holds, because it learns the other from the query it resolved. Keys are matched\n * case-insensitively, as the server resolves them (`QueryLoader.ResolveQuery`).\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each (#319).\n */\n request(queryId: string): void {\n const key = normalize(queryId);\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n\nfunction normalize(queryId: string): string {\n return queryId.toLowerCase();\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { resolveTranslation } from '@mintplayer/ng-spark/models';\nimport type {\n ClientOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n} from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute` and `navigate`. Apps add this once in their bootstrap providers.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The last gap,\n * `disableAction`, was closed by deleting it (#460, D13): disabled actions travel on the object's\n * or result's `disabledActions` and are enforced by the server at submit.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n // The app-chosen language, not the browser's that the server resolved `message` in.\n const message = (notify.translatedMessage && resolveTranslation(notify.translatedMessage)) || notify.message;\n notifications.show(message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n ]);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","names":[],"sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/client-methods.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/secret-dialog.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject, TranslatedString } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n /** The text in the request's language (the browser's `Accept-Language`). */\n message: string;\n /**\n * The text in every language, when the server has it. Shown in the language the user picked in\n * the app (`SparkLanguageService`), which the server never learns; `message` is the fallback.\n */\n translatedMessage?: TranslatedString | null;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\n// `disableAction` is gone (#460, D13): the server no longer emits it. Disabled actions arrive on\n// `PersistentObject.disabledActions` / `QueryResult.disabledActions`, and a submitted disabled action\n// is refused with 403.\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n /** Whether the client offers its own translated Cancel, answered as `'Cancel'` (`IRetryAccessor.Action(..., cancellable)`). */\n cancellable?: boolean;\n /** A client method to run instead of a modal (`IRetryAccessor.Invoke`); see `provideSparkClientMethods`. */\n clientMethod?: string | null;\n /** Its argument, as the server serialized it. */\n arguments?: unknown;\n}\n\n/**\n * A value the server shows exactly once (a generated client secret, an API token), in a dialog with\n * a copy button (`IClientAccessor.ShowSecret`, `docs/identity_provider_platform_PRD.md` D5). The server\n * keeps only a hash. The value is never written to the page's state, the URL or browser storage, and\n * closing the dialog discards it.\n */\nexport interface ShowSecretOperation {\n type: 'showSecret';\n /** The dialog's title, in the request's language. */\n title: string;\n /** The explanation shown above the value, in the request's language. */\n message: string;\n /** The value itself. */\n value: string;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | RetryOperation\n | ShowSecretOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark/auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import {\n EnvironmentInjector,\n type EnvironmentProviders,\n Injectable,\n InjectionToken,\n inject,\n makeEnvironmentProviders,\n runInInjectionContext,\n} from '@angular/core';\n\n/**\n * One step a server action needs from the browser (`IRetryAccessor.Invoke` on the server): WebAuthn,\n * a file picker, the clipboard. The answer travels back into the same server action as the retry's\n * `value`, so authorization, state and the follow-up all stay inside that one action.\n */\nexport interface SparkClientMethod {\n /**\n * Runs the step. Called in an injection context, so `inject()` works synchronously at the top.\n * A rejection or a thrown error answers the server with `Cancel`, exactly like an unknown name.\n */\n invoke(args: unknown): Promise<unknown>;\n /**\n * Whether this browser can run the method at all. An action whose `requiresClient` names a method\n * that is unsupported is shown disabled; an `Invoke` of it answers `Cancel` without running it.\n * Absent means always supported.\n */\n supported?(): boolean;\n /** The translation key explaining why the method is unsupported; defaults to `common.clientUnsupported`. */\n unsupportedReason?: string;\n}\n\n/** Client methods by the name the server invokes them with, e.g. `{ 'webauthn.create': { invoke: … } }`. */\nexport type SparkClientMethodMap = Readonly<Record<string, SparkClientMethod>>;\n\n/** Multi-provider token; use {@link provideSparkClientMethods}. A later registration of a name wins. */\nexport const SPARK_CLIENT_METHODS = new InjectionToken<readonly SparkClientMethodMap[]>('SPARK_CLIENT_METHODS');\n\n/** The reason shown on an action whose client method is missing or unsupported, when the method names none. */\nexport const SPARK_CLIENT_UNSUPPORTED_REASON = 'common.clientUnsupported';\n\n/**\n * Registers client methods a server action may invoke through a retry (`IRetryAccessor.Invoke`).\n *\n * The set is closed on purpose: the server can only name a method the app registered here, and an\n * unknown name answers `Cancel`. Nothing the server sends is evaluated.\n *\n * @example\n * provideSparkClientMethods({\n * 'clipboard.read': { invoke: () => navigator.clipboard.readText(), supported: () => !!navigator.clipboard },\n * })\n */\nexport function provideSparkClientMethods(methods: SparkClientMethodMap): EnvironmentProviders {\n return makeEnvironmentProviders([{ provide: SPARK_CLIENT_METHODS, useValue: methods, multi: true }]);\n}\n\n/** How a client method ended: its value, or a failure the server sees as `Cancel`. */\nexport type SparkClientMethodOutcome = { ok: true; value: unknown } | { ok: false };\n\n/** The registered client methods: whether one can run here, and running it. */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientMethodRegistry {\n private readonly injector = inject(EnvironmentInjector);\n private readonly methods: ReadonlyMap<string, SparkClientMethod>;\n\n constructor() {\n const methods = new Map<string, SparkClientMethod>();\n for (const registration of inject(SPARK_CLIENT_METHODS, { optional: true }) ?? []) {\n for (const [name, method] of Object.entries(registration))\n methods.set(name, method);\n }\n this.methods = methods;\n }\n\n /**\n * Null when the method is registered and supported here; otherwise the translation key saying why\n * it is not. Null for no name at all, so an action without `requiresClient` is never affected.\n */\n unavailableReason(name: string | null | undefined): string | null {\n if (!name) return null;\n const method = this.methods.get(name);\n if (!method) return SPARK_CLIENT_UNSUPPORTED_REASON;\n return this.isSupported(method) ? null : (method.unsupportedReason ?? SPARK_CLIENT_UNSUPPORTED_REASON);\n }\n\n /**\n * Runs the method registered under `name`. Fails closed: an unknown name, an unsupported method, a\n * rejection or a thrown error all end as `{ ok: false }`, which the retry loop answers as `Cancel`.\n */\n async invoke(name: string, args: unknown): Promise<SparkClientMethodOutcome> {\n const method = this.methods.get(name);\n if (!method) {\n // A server naming a method this app never registered is a wiring mistake worth a line in\n // the console; a user cancelling a ceremony is not, so rejections stay quiet.\n console.warn(`[spark] the server invoked the client method '${name}', which is not registered; answered Cancel.`);\n return { ok: false };\n }\n if (!this.isSupported(method)) return { ok: false };\n\n try {\n const value = await runInInjectionContext(this.injector, () => method.invoke(args));\n return { ok: true, value };\n } catch {\n return { ok: false };\n }\n }\n\n private isSupported(method: SparkClientMethod): boolean {\n if (!method.supported) return true;\n try {\n return runInInjectionContext(this.injector, () => method.supported!()) === true;\n } catch {\n return false;\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: var(--bs-white);\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: var(--bs-primary); }\n .spark-toast--success { background: var(--bs-success); }\n .spark-toast--warning { background: var(--bs-warning); color: var(--bs-dark); }\n .spark-toast--error { background: var(--bs-danger); }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import {\n ApplicationRef,\n ChangeDetectionStrategy,\n Component,\n type ComponentRef,\n DestroyRef,\n EnvironmentInjector,\n Injectable,\n PLATFORM_ID,\n computed,\n createComponent,\n inject,\n signal,\n} from '@angular/core';\nimport { DOCUMENT, isPlatformBrowser } from '@angular/common';\nimport { Color } from '@mintplayer/ng-bootstrap';\nimport { BsButtonTypeDirective } from '@mintplayer/ng-bootstrap/button-type';\nimport { BsFormComponent, BsFormControlDirective } from '@mintplayer/ng-bootstrap/form';\nimport {\n BsModalBodyDirective,\n BsModalDirective,\n BsModalFooterDirective,\n BsModalHeaderDirective,\n BsModalHostComponent,\n} from '@mintplayer/ng-bootstrap/modal';\nimport { resolveTranslation, type TranslatedString } from '@mintplayer/ng-spark/models';\n\n/** A value shown once: the `showSecret` operation's payload, or a value a page received itself. */\nexport interface SparkSecret {\n title: string;\n message: string;\n value: string;\n}\n\n/**\n * Shows a value the server will never show again, in a modal with a copy button (`showSecret`,\n * `docs/identity_provider_platform_PRD.md` D5).\n *\n * The dialog mounts itself on `document.body` the first time it is needed, so an application does not\n * have to place a component in its root template for `IClientAccessor.ShowSecret` to work: a handler\n * whose dialog nobody placed would lose the value as surely as a missing handler.\n *\n * The value lives only in this service's signal while the dialog is open. Closing the dialog clears\n * it; it is never written to the URL, the router state or browser storage.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkSecretDialogService {\n private readonly appRef = inject(ApplicationRef);\n private readonly injector = inject(EnvironmentInjector);\n private readonly document = inject(DOCUMENT);\n private readonly browser = isPlatformBrowser(inject(PLATFORM_ID));\n private readonly current = signal<SparkSecret | null>(null);\n private dialog: ComponentRef<SparkShowSecretModalComponent> | null = null;\n\n /** The secret on display, or null when the dialog is closed. */\n readonly secret = this.current.asReadonly();\n\n constructor() {\n inject(DestroyRef).onDestroy(() => {\n this.dialog?.destroy();\n this.dialog = null;\n });\n }\n\n show(secret: SparkSecret): void {\n if (!this.browser) return;\n this.mount();\n this.current.set({ ...secret });\n }\n\n /** Closes the dialog and discards the value. */\n close(): void {\n this.current.set(null);\n }\n\n private mount(): void {\n if (this.dialog) return;\n const ref = createComponent(SparkShowSecretModalComponent, { environmentInjector: this.injector });\n this.appRef.attachView(ref.hostView);\n this.document.body.appendChild(ref.location.nativeElement);\n this.dialog = ref;\n }\n}\n\nconst TEXT: Record<'copy' | 'copied' | 'copyFailed' | 'close' | 'value', TranslatedString> = {\n copy: { en: 'Copy', fr: 'Copier', nl: 'Kopiëren' },\n copied: { en: 'Copied', fr: 'Copié', nl: 'Gekopieerd' },\n copyFailed: {\n en: 'Copying failed. Select the value and copy it yourself.',\n fr: 'La copie a échoué. Sélectionnez la valeur et copiez-la vous-même.',\n nl: 'Kopiëren mislukt. Selecteer de waarde en kopieer ze zelf.',\n },\n close: { en: 'Close', fr: 'Fermer', nl: 'Sluiten' },\n value: { en: 'Value', fr: 'Valeur', nl: 'Waarde' },\n};\n\n/** The dialog behind {@link SparkSecretDialogService}; mounted by the service, not by applications. */\n@Component({\n selector: 'spark-show-secret-modal',\n imports: [\n BsModalHostComponent, BsModalDirective, BsModalHeaderDirective, BsModalBodyDirective, BsModalFooterDirective,\n BsButtonTypeDirective, BsFormComponent, BsFormControlDirective,\n ],\n template: `\n <bs-modal [isOpen]=\"isOpen()\" (isOpenChange)=\"!$event && close()\">\n <div *bsModal>\n <div bsModalHeader>\n <h5 class=\"modal-title\">{{ secret()?.title }}</h5>\n </div>\n <div bsModalBody>\n @if (secret(); as s) {\n <p class=\"spark-secret-message\">{{ s.message }}</p>\n <bs-form>\n <input type=\"text\" readonly class=\"font-monospace spark-secret-value\" spellcheck=\"false\"\n autocomplete=\"off\" [attr.aria-label]=\"text().value\" [value]=\"s.value\"\n (focus)=\"selectAll($event)\" />\n </bs-form>\n @if (copyState() === 'copied') {\n <small class=\"text-success d-block mt-2 spark-secret-copied\" role=\"status\">{{ text().copied }}</small>\n } @else if (copyState() === 'failed') {\n <small class=\"text-danger d-block mt-2 spark-secret-copy-failed\" role=\"alert\">{{ text().copyFailed }}</small>\n }\n }\n </div>\n <div bsModalFooter>\n <button type=\"button\" class=\"spark-secret-copy\" [color]=\"colors.primary\" (click)=\"copy()\">{{ text().copy }}</button>\n <button type=\"button\" class=\"spark-secret-close\" [color]=\"colors.secondary\" (click)=\"close()\">{{ text().close }}</button>\n </div>\n </div>\n </bs-modal>\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkShowSecretModalComponent {\n private readonly dialog = inject(SparkSecretDialogService);\n private readonly document = inject(DOCUMENT);\n private copiedTimer: ReturnType<typeof setTimeout> | undefined;\n\n protected readonly colors = Color;\n protected readonly secret = this.dialog.secret;\n protected readonly isOpen = computed(() => this.secret() !== null);\n protected readonly copyState = signal<'idle' | 'copied' | 'failed'>('idle');\n protected readonly text = computed(() => ({\n copy: resolveTranslation(TEXT.copy),\n copied: resolveTranslation(TEXT.copied),\n copyFailed: resolveTranslation(TEXT.copyFailed),\n close: resolveTranslation(TEXT.close),\n value: resolveTranslation(TEXT.value),\n }));\n\n protected selectAll(event: Event): void {\n (event.target as HTMLInputElement | null)?.select();\n }\n\n protected async copy(): Promise<void> {\n const value = this.secret()?.value;\n if (!value) return;\n try {\n const clipboard = this.document.defaultView?.navigator.clipboard;\n if (!clipboard) throw new Error('Clipboard API unavailable');\n await clipboard.writeText(value);\n this.copyState.set('copied');\n clearTimeout(this.copiedTimer);\n this.copiedTimer = setTimeout(() => this.copyState.set('idle'), 2500);\n } catch {\n this.copyState.set('failed');\n }\n }\n\n protected close(): void {\n clearTimeout(this.copiedTimer);\n this.copyState.set('idle');\n this.dialog.close();\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /**\n * Bumped every time a refresh is asked for this key.\n *\n * ⚠️ Reading it makes the caller depend on EVERY key, not just this one: the counters share one\n * signal. A grid must therefore compare the value with the one it last saw, never react to the\n * read alone (#319) — otherwise it re-fetches for every query refreshed anywhere in the app.\n */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[normalize(queryId)] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * `queryId` is the query's id or its alias. A grid answers to both, whichever its own `queryId`\n * input holds, because it learns the other from the query it resolved. Keys are matched\n * case-insensitively, as the server resolves them (`QueryLoader.ResolveQuery`).\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each (#319).\n */\n request(queryId: string): void {\n const key = normalize(queryId);\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n\nfunction normalize(queryId: string): string {\n return queryId.toLowerCase();\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { resolveTranslation } from '@mintplayer/ng-spark/models';\nimport type {\n ClientOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n ShowSecretOperation,\n} from './operations';\nimport { SparkSecretDialogService } from './secret-dialog';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute`, `navigate` and `showSecret`. Apps add this once in their bootstrap providers.\n *\n * `showSecret` opens {@link SparkSecretDialogService}'s dialog, which mounts itself: without it the\n * value the server shows once (a client secret, a token) would be lost, since the server keeps a hash.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The last gap,\n * `disableAction`, was closed by deleting it (#460, D13): disabled actions travel on the object's\n * or result's `disabledActions` and are enforced by the server at submit.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n // The app-chosen language, not the browser's that the server resolved `message` in.\n const message = (notify.translatedMessage && resolveTranslation(notify.translatedMessage)) || notify.message;\n notifications.show(message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const secrets = inject(SparkSecretDialogService);\n return {\n type: 'showSecret',\n handler: (operation: ClientOperation) => {\n const secret = operation as ShowSecretOperation;\n secrets.show({ title: secret.title, message: secret.message, value: secret.value });\n },\n };\n },\n multi: true,\n },\n ]);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
|
|
3
|
+
import { Color } from "@mintplayer/ng-bootstrap";
|
|
4
|
+
import { BsAlertComponent } from "@mintplayer/ng-bootstrap/alert";
|
|
5
|
+
import { BsBadgeComponent } from "@mintplayer/ng-bootstrap/badge";
|
|
6
|
+
import { BsCardComponent, BsCardHeaderComponent } from "@mintplayer/ng-bootstrap/card";
|
|
7
|
+
import { BsCheckboxComponent } from "@mintplayer/ng-bootstrap/checkbox";
|
|
8
|
+
import { BsFormComponent } from "@mintplayer/ng-bootstrap/form";
|
|
9
|
+
import { BsSpinnerComponent } from "@mintplayer/ng-bootstrap/spinner";
|
|
10
|
+
import { SparkIconComponent } from "@mintplayer/ng-spark/icon";
|
|
11
|
+
import { SparkIdentityProviderDatePipe, SparkIdentityProviderPortalService, SparkIdentityProviderTextPipe, injectIdentityProviderText } from "@mintplayer/ng-spark/identity-provider/core";
|
|
12
|
+
var SparkConnectedApplicationsComponent = class SparkConnectedApplicationsComponent {
|
|
13
|
+
text = injectIdentityProviderText();
|
|
14
|
+
portal = inject(SparkIdentityProviderPortalService);
|
|
15
|
+
colors = Color;
|
|
16
|
+
loading = signal(true, ...ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []);
|
|
17
|
+
busy = signal(false, ...ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []);
|
|
18
|
+
apps = signal([], ...ngDevMode ? [{ debugName: "apps" }] : /* istanbul ignore next */ []);
|
|
19
|
+
error = signal("", ...ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []);
|
|
20
|
+
notice = signal("", ...ngDevMode ? [{ debugName: "notice" }] : /* istanbul ignore next */ []);
|
|
21
|
+
unticked = signal({}, ...ngDevMode ? [{ debugName: "unticked" }] : /* istanbul ignore next */ []);
|
|
22
|
+
constructor() {
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
25
|
+
async load() {
|
|
26
|
+
this.loading.set(true);
|
|
27
|
+
const result = await this.portal.connectedApplications();
|
|
28
|
+
if (result.ok) {
|
|
29
|
+
this.apps.set(result.value);
|
|
30
|
+
this.unticked.set({});
|
|
31
|
+
} else {
|
|
32
|
+
this.apps.set([]);
|
|
33
|
+
this.error.set(this.text("loadFailed"));
|
|
34
|
+
}
|
|
35
|
+
this.loading.set(false);
|
|
36
|
+
}
|
|
37
|
+
hasOptionalScopes(app) {
|
|
38
|
+
return app.scopes.some((scope) => !scope.required);
|
|
39
|
+
}
|
|
40
|
+
isUnticked(app, scope) {
|
|
41
|
+
return this.untickedOf(app).includes(scope.name);
|
|
42
|
+
}
|
|
43
|
+
untickedOf(app) {
|
|
44
|
+
return this.unticked()[app.applicationId] ?? [];
|
|
45
|
+
}
|
|
46
|
+
setTicked(app, scope, ticked) {
|
|
47
|
+
if (scope.required) return;
|
|
48
|
+
this.unticked.update((map) => {
|
|
49
|
+
const current = map[app.applicationId] ?? [];
|
|
50
|
+
const next = ticked ? current.filter((name) => name !== scope.name) : [.../* @__PURE__ */ new Set([...current, scope.name])];
|
|
51
|
+
return {
|
|
52
|
+
...map,
|
|
53
|
+
[app.applicationId]: next
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async withdrawUnticked(app) {
|
|
58
|
+
const scopes = this.untickedOf(app).filter((name) => app.scopes.some((s) => s.name === name && !s.required));
|
|
59
|
+
if (!scopes.length) return;
|
|
60
|
+
await this.withdraw(app, scopes);
|
|
61
|
+
}
|
|
62
|
+
async withdrawAll(app) {
|
|
63
|
+
if (!confirm(this.text("apps.confirmWithdrawAll", app.displayName))) return;
|
|
64
|
+
await this.withdraw(app, void 0);
|
|
65
|
+
}
|
|
66
|
+
async withdraw(app, scopes) {
|
|
67
|
+
this.busy.set(true);
|
|
68
|
+
this.error.set("");
|
|
69
|
+
this.notice.set("");
|
|
70
|
+
if ((await this.portal.withdraw({
|
|
71
|
+
applicationId: app.applicationId,
|
|
72
|
+
scopes
|
|
73
|
+
})).ok) {
|
|
74
|
+
this.notice.set(this.text("apps.withdrawn"));
|
|
75
|
+
await this.load();
|
|
76
|
+
} else this.error.set(this.text("apps.withdrawFailed"));
|
|
77
|
+
this.busy.set(false);
|
|
78
|
+
}
|
|
79
|
+
static ɵfac = i0.ɵɵngDeclareFactory({
|
|
80
|
+
minVersion: "12.0.0",
|
|
81
|
+
version: "22.2.0",
|
|
82
|
+
ngImport: i0,
|
|
83
|
+
type: SparkConnectedApplicationsComponent,
|
|
84
|
+
deps: [],
|
|
85
|
+
target: i0.ɵɵFactoryTarget.Component
|
|
86
|
+
});
|
|
87
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({
|
|
88
|
+
minVersion: "17.0.0",
|
|
89
|
+
version: "22.2.0",
|
|
90
|
+
type: SparkConnectedApplicationsComponent,
|
|
91
|
+
isStandalone: true,
|
|
92
|
+
selector: "spark-connected-applications",
|
|
93
|
+
ngImport: i0,
|
|
94
|
+
template: `
|
|
95
|
+
<div class="d-flex justify-content-center">
|
|
96
|
+
<bs-card style="width: 100%; max-width: 760px;">
|
|
97
|
+
<bs-card-header><h3 class="mb-0">{{ 'apps.title' | idp }}</h3></bs-card-header>
|
|
98
|
+
<div class="p-4">
|
|
99
|
+
<p class="text-muted">{{ 'apps.intro' | idp }}</p>
|
|
100
|
+
@if (notice()) {
|
|
101
|
+
<bs-alert [type]="colors.success" class="mb-3 d-block spark-idp-notice">{{ notice() }}</bs-alert>
|
|
102
|
+
}
|
|
103
|
+
@if (error()) {
|
|
104
|
+
<bs-alert [type]="colors.danger" class="mb-3 d-block spark-idp-error">{{ error() }}</bs-alert>
|
|
105
|
+
}
|
|
106
|
+
@if (loading()) {
|
|
107
|
+
<div class="text-center"><bs-spinner /></div>
|
|
108
|
+
} @else {
|
|
109
|
+
@for (app of apps(); track app.applicationId) {
|
|
110
|
+
<section class="border rounded p-3 mb-3 spark-connected-app">
|
|
111
|
+
<div class="d-flex align-items-center gap-3 mb-2">
|
|
112
|
+
@if (app.logoUrl) {
|
|
113
|
+
<img [src]="app.logoUrl" alt="" width="48" height="48" class="rounded spark-connected-app-logo"
|
|
114
|
+
style="object-fit: contain;" referrerpolicy="no-referrer" />
|
|
115
|
+
}
|
|
116
|
+
<div class="text-break">
|
|
117
|
+
<h5 class="mb-0 spark-connected-app-name">{{ app.displayName }}</h5>
|
|
118
|
+
@if (app.publisher) {
|
|
119
|
+
<small class="text-muted spark-connected-app-publisher">{{ 'apps.by' | idp: app.publisher }}</small>
|
|
120
|
+
}
|
|
121
|
+
@if (app.homepageUrl) {
|
|
122
|
+
<a [href]="app.homepageUrl" target="_blank" rel="noopener noreferrer" class="ms-2 small">{{ 'apps.website' | idp }}</a>
|
|
123
|
+
}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<small class="text-muted d-block spark-connected-app-dates">
|
|
128
|
+
{{ 'apps.firstGranted' | idp: (app.firstGrantedAt | idpDate) }} ·
|
|
129
|
+
@if (app.lastUsedAt) { {{ 'apps.lastUsed' | idp: (app.lastUsedAt | idpDate) }} } @else { {{ 'apps.neverUsed' | idp }} }
|
|
130
|
+
</small>
|
|
131
|
+
@if (app.remembered) {
|
|
132
|
+
<small class="text-muted d-block">
|
|
133
|
+
@if (app.rememberedUntil) { {{ 'apps.rememberedUntil' | idp: (app.rememberedUntil | idpDate) }} } @else { {{ 'apps.remembered' | idp }} }
|
|
134
|
+
</small>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
<h6 class="mt-3">{{ 'apps.scopes' | idp }}</h6>
|
|
138
|
+
@if (hasOptionalScopes(app)) {
|
|
139
|
+
<small class="text-muted d-block mb-2">{{ 'apps.scopesHint' | idp }}</small>
|
|
140
|
+
}
|
|
141
|
+
<bs-form>
|
|
142
|
+
@for (scope of app.scopes; track scope.name) {
|
|
143
|
+
<div class="mb-2 spark-connected-scope" [attr.data-scope]="scope.name">
|
|
144
|
+
@if (scope.required) {
|
|
145
|
+
<div class="d-flex align-items-start gap-2" [attr.title]="'apps.requiredHint' | idp">
|
|
146
|
+
<spark-icon name="lock" class="mt-1" />
|
|
147
|
+
<div>
|
|
148
|
+
<span>{{ scope.displayName }}</span>
|
|
149
|
+
<bs-badge [type]="colors.secondary" class="ms-2 spark-scope-required">{{ 'apps.required' | idp }}</bs-badge>
|
|
150
|
+
@if (scope.description) { <small class="text-muted d-block">{{ scope.description }}</small> }
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
} @else {
|
|
154
|
+
<bs-checkbox [type]="'checkbox'" [name]="app.applicationId + '|' + scope.name"
|
|
155
|
+
[isToggled]="!isUnticked(app, scope)" (isToggledChange)="setTicked(app, scope, $event === true)">
|
|
156
|
+
{{ scope.displayName }}
|
|
157
|
+
</bs-checkbox>
|
|
158
|
+
@if (scope.description) { <small class="text-muted d-block ms-4">{{ scope.description }}</small> }
|
|
159
|
+
}
|
|
160
|
+
</div>
|
|
161
|
+
}
|
|
162
|
+
</bs-form>
|
|
163
|
+
|
|
164
|
+
<div class="d-flex flex-wrap gap-2 mt-3">
|
|
165
|
+
@if (hasOptionalScopes(app)) {
|
|
166
|
+
<button type="button" class="btn btn-outline-danger btn-sm spark-withdraw-unticked"
|
|
167
|
+
[disabled]="busy() || untickedOf(app).length === 0" (click)="withdrawUnticked(app)">
|
|
168
|
+
{{ 'apps.withdrawUnticked' | idp }}
|
|
169
|
+
</button>
|
|
170
|
+
}
|
|
171
|
+
<button type="button" class="btn btn-danger btn-sm spark-withdraw-all" [disabled]="busy()" (click)="withdrawAll(app)">
|
|
172
|
+
{{ 'apps.withdrawAll' | idp }}
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</section>
|
|
176
|
+
} @empty {
|
|
177
|
+
@if (!error()) {
|
|
178
|
+
<p class="text-muted spark-connected-apps-empty">{{ 'apps.none' | idp }}</p>
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
</div>
|
|
183
|
+
</bs-card>
|
|
184
|
+
</div>
|
|
185
|
+
`,
|
|
186
|
+
isInline: true,
|
|
187
|
+
dependencies: [
|
|
188
|
+
{
|
|
189
|
+
kind: "component",
|
|
190
|
+
type: BsAlertComponent,
|
|
191
|
+
selector: "bs-alert",
|
|
192
|
+
inputs: [
|
|
193
|
+
"type",
|
|
194
|
+
"announce",
|
|
195
|
+
"isVisible"
|
|
196
|
+
],
|
|
197
|
+
outputs: ["isVisibleChange", "afterOpenedOrClosed"]
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
kind: "component",
|
|
201
|
+
type: BsBadgeComponent,
|
|
202
|
+
selector: "bs-badge",
|
|
203
|
+
inputs: [
|
|
204
|
+
"type",
|
|
205
|
+
"unit",
|
|
206
|
+
"decorative"
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
kind: "component",
|
|
211
|
+
type: BsCardComponent,
|
|
212
|
+
selector: "bs-card",
|
|
213
|
+
inputs: ["color", "outline"]
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
kind: "component",
|
|
217
|
+
type: BsCardHeaderComponent,
|
|
218
|
+
selector: "bs-card-header",
|
|
219
|
+
inputs: ["color", "navStyle"]
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
kind: "component",
|
|
223
|
+
type: BsCheckboxComponent,
|
|
224
|
+
selector: "bs-checkbox",
|
|
225
|
+
inputs: [
|
|
226
|
+
"type",
|
|
227
|
+
"isToggled",
|
|
228
|
+
"indeterminate",
|
|
229
|
+
"name",
|
|
230
|
+
"value",
|
|
231
|
+
"group"
|
|
232
|
+
],
|
|
233
|
+
outputs: ["isToggledChange", "indeterminateChange"]
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
kind: "component",
|
|
237
|
+
type: BsFormComponent,
|
|
238
|
+
selector: "bs-form",
|
|
239
|
+
inputs: ["action", "method"],
|
|
240
|
+
outputs: ["submitted"]
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
kind: "component",
|
|
244
|
+
type: BsSpinnerComponent,
|
|
245
|
+
selector: "bs-spinner",
|
|
246
|
+
inputs: ["type", "color"]
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
kind: "component",
|
|
250
|
+
type: SparkIconComponent,
|
|
251
|
+
selector: "spark-icon",
|
|
252
|
+
inputs: ["name"]
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
kind: "pipe",
|
|
256
|
+
type: SparkIdentityProviderTextPipe,
|
|
257
|
+
name: "idp"
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
kind: "pipe",
|
|
261
|
+
type: SparkIdentityProviderDatePipe,
|
|
262
|
+
name: "idpDate"
|
|
263
|
+
}
|
|
264
|
+
],
|
|
265
|
+
changeDetection: i0.ChangeDetectionStrategy.OnPush
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
i0.ɵɵngDeclareClassMetadata({
|
|
269
|
+
minVersion: "12.0.0",
|
|
270
|
+
version: "22.2.0",
|
|
271
|
+
ngImport: i0,
|
|
272
|
+
type: SparkConnectedApplicationsComponent,
|
|
273
|
+
decorators: [{
|
|
274
|
+
type: Component,
|
|
275
|
+
args: [{
|
|
276
|
+
selector: "spark-connected-applications",
|
|
277
|
+
standalone: true,
|
|
278
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
279
|
+
imports: [
|
|
280
|
+
BsAlertComponent,
|
|
281
|
+
BsBadgeComponent,
|
|
282
|
+
BsCardComponent,
|
|
283
|
+
BsCardHeaderComponent,
|
|
284
|
+
BsCheckboxComponent,
|
|
285
|
+
BsFormComponent,
|
|
286
|
+
BsSpinnerComponent,
|
|
287
|
+
SparkIconComponent,
|
|
288
|
+
SparkIdentityProviderTextPipe,
|
|
289
|
+
SparkIdentityProviderDatePipe
|
|
290
|
+
],
|
|
291
|
+
template: `
|
|
292
|
+
<div class="d-flex justify-content-center">
|
|
293
|
+
<bs-card style="width: 100%; max-width: 760px;">
|
|
294
|
+
<bs-card-header><h3 class="mb-0">{{ 'apps.title' | idp }}</h3></bs-card-header>
|
|
295
|
+
<div class="p-4">
|
|
296
|
+
<p class="text-muted">{{ 'apps.intro' | idp }}</p>
|
|
297
|
+
@if (notice()) {
|
|
298
|
+
<bs-alert [type]="colors.success" class="mb-3 d-block spark-idp-notice">{{ notice() }}</bs-alert>
|
|
299
|
+
}
|
|
300
|
+
@if (error()) {
|
|
301
|
+
<bs-alert [type]="colors.danger" class="mb-3 d-block spark-idp-error">{{ error() }}</bs-alert>
|
|
302
|
+
}
|
|
303
|
+
@if (loading()) {
|
|
304
|
+
<div class="text-center"><bs-spinner /></div>
|
|
305
|
+
} @else {
|
|
306
|
+
@for (app of apps(); track app.applicationId) {
|
|
307
|
+
<section class="border rounded p-3 mb-3 spark-connected-app">
|
|
308
|
+
<div class="d-flex align-items-center gap-3 mb-2">
|
|
309
|
+
@if (app.logoUrl) {
|
|
310
|
+
<img [src]="app.logoUrl" alt="" width="48" height="48" class="rounded spark-connected-app-logo"
|
|
311
|
+
style="object-fit: contain;" referrerpolicy="no-referrer" />
|
|
312
|
+
}
|
|
313
|
+
<div class="text-break">
|
|
314
|
+
<h5 class="mb-0 spark-connected-app-name">{{ app.displayName }}</h5>
|
|
315
|
+
@if (app.publisher) {
|
|
316
|
+
<small class="text-muted spark-connected-app-publisher">{{ 'apps.by' | idp: app.publisher }}</small>
|
|
317
|
+
}
|
|
318
|
+
@if (app.homepageUrl) {
|
|
319
|
+
<a [href]="app.homepageUrl" target="_blank" rel="noopener noreferrer" class="ms-2 small">{{ 'apps.website' | idp }}</a>
|
|
320
|
+
}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<small class="text-muted d-block spark-connected-app-dates">
|
|
325
|
+
{{ 'apps.firstGranted' | idp: (app.firstGrantedAt | idpDate) }} ·
|
|
326
|
+
@if (app.lastUsedAt) { {{ 'apps.lastUsed' | idp: (app.lastUsedAt | idpDate) }} } @else { {{ 'apps.neverUsed' | idp }} }
|
|
327
|
+
</small>
|
|
328
|
+
@if (app.remembered) {
|
|
329
|
+
<small class="text-muted d-block">
|
|
330
|
+
@if (app.rememberedUntil) { {{ 'apps.rememberedUntil' | idp: (app.rememberedUntil | idpDate) }} } @else { {{ 'apps.remembered' | idp }} }
|
|
331
|
+
</small>
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
<h6 class="mt-3">{{ 'apps.scopes' | idp }}</h6>
|
|
335
|
+
@if (hasOptionalScopes(app)) {
|
|
336
|
+
<small class="text-muted d-block mb-2">{{ 'apps.scopesHint' | idp }}</small>
|
|
337
|
+
}
|
|
338
|
+
<bs-form>
|
|
339
|
+
@for (scope of app.scopes; track scope.name) {
|
|
340
|
+
<div class="mb-2 spark-connected-scope" [attr.data-scope]="scope.name">
|
|
341
|
+
@if (scope.required) {
|
|
342
|
+
<div class="d-flex align-items-start gap-2" [attr.title]="'apps.requiredHint' | idp">
|
|
343
|
+
<spark-icon name="lock" class="mt-1" />
|
|
344
|
+
<div>
|
|
345
|
+
<span>{{ scope.displayName }}</span>
|
|
346
|
+
<bs-badge [type]="colors.secondary" class="ms-2 spark-scope-required">{{ 'apps.required' | idp }}</bs-badge>
|
|
347
|
+
@if (scope.description) { <small class="text-muted d-block">{{ scope.description }}</small> }
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
} @else {
|
|
351
|
+
<bs-checkbox [type]="'checkbox'" [name]="app.applicationId + '|' + scope.name"
|
|
352
|
+
[isToggled]="!isUnticked(app, scope)" (isToggledChange)="setTicked(app, scope, $event === true)">
|
|
353
|
+
{{ scope.displayName }}
|
|
354
|
+
</bs-checkbox>
|
|
355
|
+
@if (scope.description) { <small class="text-muted d-block ms-4">{{ scope.description }}</small> }
|
|
356
|
+
}
|
|
357
|
+
</div>
|
|
358
|
+
}
|
|
359
|
+
</bs-form>
|
|
360
|
+
|
|
361
|
+
<div class="d-flex flex-wrap gap-2 mt-3">
|
|
362
|
+
@if (hasOptionalScopes(app)) {
|
|
363
|
+
<button type="button" class="btn btn-outline-danger btn-sm spark-withdraw-unticked"
|
|
364
|
+
[disabled]="busy() || untickedOf(app).length === 0" (click)="withdrawUnticked(app)">
|
|
365
|
+
{{ 'apps.withdrawUnticked' | idp }}
|
|
366
|
+
</button>
|
|
367
|
+
}
|
|
368
|
+
<button type="button" class="btn btn-danger btn-sm spark-withdraw-all" [disabled]="busy()" (click)="withdrawAll(app)">
|
|
369
|
+
{{ 'apps.withdrawAll' | idp }}
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
</section>
|
|
373
|
+
} @empty {
|
|
374
|
+
@if (!error()) {
|
|
375
|
+
<p class="text-muted spark-connected-apps-empty">{{ 'apps.none' | idp }}</p>
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
</div>
|
|
380
|
+
</bs-card>
|
|
381
|
+
</div>
|
|
382
|
+
`
|
|
383
|
+
}]
|
|
384
|
+
}],
|
|
385
|
+
ctorParameters: () => []
|
|
386
|
+
});
|
|
387
|
+
export { SparkConnectedApplicationsComponent };
|
|
388
|
+
|
|
389
|
+
//# sourceMappingURL=mintplayer-ng-spark-identity-provider-connected-applications.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mintplayer-ng-spark-identity-provider-connected-applications.mjs","names":[],"sources":["../../identity-provider/connected-applications/src/spark-connected-applications.component.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';\nimport { Color } from '@mintplayer/ng-bootstrap';\nimport { BsAlertComponent } from '@mintplayer/ng-bootstrap/alert';\nimport { BsBadgeComponent } from '@mintplayer/ng-bootstrap/badge';\nimport { BsCardComponent, BsCardHeaderComponent } from '@mintplayer/ng-bootstrap/card';\nimport { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';\nimport { BsFormComponent } from '@mintplayer/ng-bootstrap/form';\nimport { BsSpinnerComponent } from '@mintplayer/ng-bootstrap/spinner';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\nimport {\n SparkConnectedApplication,\n SparkConnectedScope,\n SparkIdentityProviderDatePipe,\n SparkIdentityProviderPortalService,\n SparkIdentityProviderTextPipe,\n injectIdentityProviderText,\n} from '@mintplayer/ng-spark/identity-provider/core';\n\n/**\n * The signed-in user's connected applications (`account/applications`, PRD D6/D7): each application\n * the user granted access to, with its logo, publisher, the granted scopes and the dates. The user\n * withdraws the whole grant, or unticks individual scopes and withdraws only those. Required scopes\n * (`openid`, and those the application or resource marks required) go only with the whole grant.\n *\n * The server resolves the scope texts in the request's language; the `spark-lang` cookie\n * `SparkLanguageService` writes is what makes that the language the user picked here.\n */\n@Component({\n selector: 'spark-connected-applications',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n BsAlertComponent, BsBadgeComponent, BsCardComponent, BsCardHeaderComponent, BsCheckboxComponent, BsFormComponent,\n BsSpinnerComponent, SparkIconComponent, SparkIdentityProviderTextPipe, SparkIdentityProviderDatePipe,\n ],\n template: `\n <div class=\"d-flex justify-content-center\">\n <bs-card style=\"width: 100%; max-width: 760px;\">\n <bs-card-header><h3 class=\"mb-0\">{{ 'apps.title' | idp }}</h3></bs-card-header>\n <div class=\"p-4\">\n <p class=\"text-muted\">{{ 'apps.intro' | idp }}</p>\n @if (notice()) {\n <bs-alert [type]=\"colors.success\" class=\"mb-3 d-block spark-idp-notice\">{{ notice() }}</bs-alert>\n }\n @if (error()) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3 d-block spark-idp-error\">{{ error() }}</bs-alert>\n }\n @if (loading()) {\n <div class=\"text-center\"><bs-spinner /></div>\n } @else {\n @for (app of apps(); track app.applicationId) {\n <section class=\"border rounded p-3 mb-3 spark-connected-app\">\n <div class=\"d-flex align-items-center gap-3 mb-2\">\n @if (app.logoUrl) {\n <img [src]=\"app.logoUrl\" alt=\"\" width=\"48\" height=\"48\" class=\"rounded spark-connected-app-logo\"\n style=\"object-fit: contain;\" referrerpolicy=\"no-referrer\" />\n }\n <div class=\"text-break\">\n <h5 class=\"mb-0 spark-connected-app-name\">{{ app.displayName }}</h5>\n @if (app.publisher) {\n <small class=\"text-muted spark-connected-app-publisher\">{{ 'apps.by' | idp: app.publisher }}</small>\n }\n @if (app.homepageUrl) {\n <a [href]=\"app.homepageUrl\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"ms-2 small\">{{ 'apps.website' | idp }}</a>\n }\n </div>\n </div>\n\n <small class=\"text-muted d-block spark-connected-app-dates\">\n {{ 'apps.firstGranted' | idp: (app.firstGrantedAt | idpDate) }} ·\n @if (app.lastUsedAt) { {{ 'apps.lastUsed' | idp: (app.lastUsedAt | idpDate) }} } @else { {{ 'apps.neverUsed' | idp }} }\n </small>\n @if (app.remembered) {\n <small class=\"text-muted d-block\">\n @if (app.rememberedUntil) { {{ 'apps.rememberedUntil' | idp: (app.rememberedUntil | idpDate) }} } @else { {{ 'apps.remembered' | idp }} }\n </small>\n }\n\n <h6 class=\"mt-3\">{{ 'apps.scopes' | idp }}</h6>\n @if (hasOptionalScopes(app)) {\n <small class=\"text-muted d-block mb-2\">{{ 'apps.scopesHint' | idp }}</small>\n }\n <bs-form>\n @for (scope of app.scopes; track scope.name) {\n <div class=\"mb-2 spark-connected-scope\" [attr.data-scope]=\"scope.name\">\n @if (scope.required) {\n <div class=\"d-flex align-items-start gap-2\" [attr.title]=\"'apps.requiredHint' | idp\">\n <spark-icon name=\"lock\" class=\"mt-1\" />\n <div>\n <span>{{ scope.displayName }}</span>\n <bs-badge [type]=\"colors.secondary\" class=\"ms-2 spark-scope-required\">{{ 'apps.required' | idp }}</bs-badge>\n @if (scope.description) { <small class=\"text-muted d-block\">{{ scope.description }}</small> }\n </div>\n </div>\n } @else {\n <bs-checkbox [type]=\"'checkbox'\" [name]=\"app.applicationId + '|' + scope.name\"\n [isToggled]=\"!isUnticked(app, scope)\" (isToggledChange)=\"setTicked(app, scope, $event === true)\">\n {{ scope.displayName }}\n </bs-checkbox>\n @if (scope.description) { <small class=\"text-muted d-block ms-4\">{{ scope.description }}</small> }\n }\n </div>\n }\n </bs-form>\n\n <div class=\"d-flex flex-wrap gap-2 mt-3\">\n @if (hasOptionalScopes(app)) {\n <button type=\"button\" class=\"btn btn-outline-danger btn-sm spark-withdraw-unticked\"\n [disabled]=\"busy() || untickedOf(app).length === 0\" (click)=\"withdrawUnticked(app)\">\n {{ 'apps.withdrawUnticked' | idp }}\n </button>\n }\n <button type=\"button\" class=\"btn btn-danger btn-sm spark-withdraw-all\" [disabled]=\"busy()\" (click)=\"withdrawAll(app)\">\n {{ 'apps.withdrawAll' | idp }}\n </button>\n </div>\n </section>\n } @empty {\n @if (!error()) {\n <p class=\"text-muted spark-connected-apps-empty\">{{ 'apps.none' | idp }}</p>\n }\n }\n }\n </div>\n </bs-card>\n </div>\n `,\n})\nexport class SparkConnectedApplicationsComponent {\n private readonly text = injectIdentityProviderText();\n private readonly portal = inject(SparkIdentityProviderPortalService);\n protected readonly colors = Color;\n\n readonly loading = signal(true);\n readonly busy = signal(false);\n readonly apps = signal<SparkConnectedApplication[]>([]);\n readonly error = signal('');\n readonly notice = signal('');\n /** Per application, the scopes the user unticked (to withdraw). */\n private readonly unticked = signal<Record<string, readonly string[]>>({});\n\n constructor() {\n void this.load();\n }\n\n async load(): Promise<void> {\n this.loading.set(true);\n const result = await this.portal.connectedApplications();\n if (result.ok) {\n this.apps.set(result.value);\n this.unticked.set({});\n } else {\n this.apps.set([]);\n this.error.set(this.text('loadFailed'));\n }\n this.loading.set(false);\n }\n\n protected hasOptionalScopes(app: SparkConnectedApplication): boolean {\n return app.scopes.some(scope => !scope.required);\n }\n\n protected isUnticked(app: SparkConnectedApplication, scope: SparkConnectedScope): boolean {\n return this.untickedOf(app).includes(scope.name);\n }\n\n protected untickedOf(app: SparkConnectedApplication): readonly string[] {\n return this.unticked()[app.applicationId] ?? [];\n }\n\n protected setTicked(app: SparkConnectedApplication, scope: SparkConnectedScope, ticked: boolean): void {\n if (scope.required) return;\n this.unticked.update(map => {\n const current = map[app.applicationId] ?? [];\n const next = ticked ? current.filter(name => name !== scope.name) : [...new Set([...current, scope.name])];\n return { ...map, [app.applicationId]: next };\n });\n }\n\n async withdrawUnticked(app: SparkConnectedApplication): Promise<void> {\n // Only optional scopes can be unticked; a required one is never sent on its own.\n const scopes = this.untickedOf(app).filter(name => app.scopes.some(s => s.name === name && !s.required));\n if (!scopes.length) return;\n await this.withdraw(app, scopes);\n }\n\n async withdrawAll(app: SparkConnectedApplication): Promise<void> {\n if (!confirm(this.text('apps.confirmWithdrawAll', app.displayName))) return;\n await this.withdraw(app, undefined);\n }\n\n private async withdraw(app: SparkConnectedApplication, scopes: string[] | undefined): Promise<void> {\n this.busy.set(true);\n this.error.set('');\n this.notice.set('');\n const result = await this.portal.withdraw({ applicationId: app.applicationId, scopes });\n if (result.ok) {\n this.notice.set(this.text('apps.withdrawn'));\n await this.load();\n } else {\n // The server's problem text is English only; the page says the same in the user's language.\n this.error.set(this.text('apps.withdrawFailed'));\n }\n this.busy.set(false);\n }\n}\n"],"mappings":"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"}
|