@idp.global/catalog 8.0.0 → 8.1.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": "@idp.global/catalog",
3
- "version": "8.0.0",
3
+ "version": "8.1.0",
4
4
  "private": false,
5
5
  "description": "Web component catalog for idp.global, based on the v5 Apple-system design language.",
6
6
  "exports": {
@@ -20,11 +20,9 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@design.estate/dees-wcctools": "^4.0.0",
23
- "@git.zone/tsbuild": "^5.0.0",
24
- "@git.zone/tsbundle": "^3.0.0",
25
- "@git.zone/tswatch": "^4.0.2",
23
+ "@git.zone/tsrepo": "^2.1.0",
26
24
  "@push.rocks/projectinfo": "^5.1.0",
27
- "@types/node": "^26.6.2"
25
+ "@types/node": "^26.6.6"
28
26
  },
29
27
  "files": [
30
28
  "ts_web/**/*",
@@ -55,8 +53,8 @@
55
53
  ],
56
54
  "scripts": {
57
55
  "test": "pnpm run build",
58
- "build": "tsbuild tsfolders --allowimplicitany && tsbundle",
59
- "watch": "tswatch",
60
- "buildDocs": "tsdoc"
56
+ "build": "tsrepo build tsfolders --allowimplicitany && tsrepo bundle",
57
+ "watch": "tsrepo watch",
58
+ "buildDocs": "tsrepo docs"
61
59
  }
62
60
  }
package/readme.md CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  IIdpSelectOption,
39
39
  IIdpAdminNavigateEventDetail,
40
40
  IIdpAdminGaAppConfigureEventDetail,
41
+ IIdpAdminGaAppCreateEventDetail,
41
42
  IIdpAdminGaAppRotateSecretEventDetail,
42
43
  IIdpAdminGaAppSecretDismissEventDetail,
43
44
  IIdpAdminClientSecret,
@@ -46,6 +47,8 @@ import type {
46
47
 
47
48
  `idp-admin-shell` dispatches `idp-admin-ga-app-configure` for safe OIDC client configuration and `idp-admin-ga-app-rotate-secret` for confidential-client secret rotation. After the host supplies the one-time result through `.oidcClientSecret`, the shell displays the plaintext secret until the user confirms secure storage through `idp-admin-ga-app-secret-dismiss`. The host must clear the property on dismissal and must not persist the plaintext secret.
48
49
 
50
+ In the global-admin console (`superadmin` and `global-admin`), the Platform Apps page offers "New app", which dispatches `idp-admin-ga-app-create` with an `IIdpAdminGaAppCreateEventDetail` for one confidential client. The first submit freezes the payload: every retry dispatches the same values, so the host can keep one idempotent operation for it. "Discard and edit" drops the frozen draft and dispatches `idp-admin-ga-app-create-discard`, after which the host drops its pending operation. Once the app exists, the host supplies the secret through `.oidcClientSecret` and calls `completeGaAppCreate()` to clear the draft. The shell refuses a create while a secret is displayed.
51
+
49
52
  ## Element Surface
50
53
 
51
54
  | Element | Purpose |
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/catalog',
6
- version: '8.0.0',
6
+ version: '8.1.0',
7
7
  description: 'Web component catalog for idp.global, based on the v5 Apple-system design language.'
8
8
  }
@@ -151,6 +151,29 @@ export interface IIdpAdminGaAppConfigureEventDetail {
151
151
  };
152
152
  }
153
153
 
154
+ /**
155
+ * Detail of `idp-admin-ga-app-create`: one confidential global OIDC client to create.
156
+ * The shell freezes these fields at the first submit; every retry dispatches the same
157
+ * values until the host calls `completeGaAppCreate()` or the admin discards the draft,
158
+ * which dispatches `idp-admin-ga-app-create-discard`.
159
+ */
160
+ export interface IIdpAdminGaAppCreateEventDetail {
161
+ name: string;
162
+ description: string;
163
+ logoUrl: string;
164
+ appUrl: string;
165
+ category: string;
166
+ redirectUris: string[];
167
+ postLogoutRedirectUris: string[];
168
+ allowedScopes: string[];
169
+ grantTypes: Array<'authorization_code' | 'refresh_token'>;
170
+ oidcSettings: {
171
+ accessTokenLifetime: number;
172
+ idTokenLifetime: number;
173
+ refreshTokenLifetime: number;
174
+ };
175
+ }
176
+
154
177
  export interface IIdpAdminGaAppRotateSecretEventDetail {
155
178
  appId: string;
156
179
  gracePeriodSeconds: number;
@@ -407,7 +430,7 @@ export interface IIdpAdminPasskeyEventDetail {
407
430
 
408
431
  @customElement('idp-admin-shell')
409
432
  export class IdpAdminShell extends DeesElement {
410
- public static demo = () => {
433
+ public static demo = [() => {
411
434
  const now = Date.now();
412
435
  const hours = Array.from({ length: 24 }, (unused, indexArg) => indexArg);
413
436
  return html`
@@ -490,7 +513,21 @@ export class IdpAdminShell extends DeesElement {
490
513
  ]}
491
514
  ></idp-admin-shell>
492
515
  `;
493
- };
516
+ }, () => html`
517
+ <idp-admin-shell
518
+ superadmin
519
+ global-admin
520
+ page="ga-apps"
521
+ .user=${{ name: 'Alex Mercer', email: 'alex@lossless.com' }}
522
+ .adminApps=${[
523
+ { id: 'app_foss', name: 'foss.global', type: 'global', category: 'Development', status: 'active', clientType: 'confidential' },
524
+ { id: 'app_task', name: 'task.vc', type: 'global', category: 'Productivity', status: 'active', clientType: 'confidential' },
525
+ ]}
526
+ @idp-admin-ga-app-create=${(eventArg: CustomEvent<IIdpAdminGaAppCreateEventDetail>) => {
527
+ (eventArg.target as IdpAdminShell).showToast(`Demo: create requested for ${eventArg.detail.name}.`);
528
+ }}
529
+ ></idp-admin-shell>
530
+ `];
494
531
  public static demoGroups = ['idp.global v5 full pages'];
495
532
 
496
533
  public static styles = [
@@ -1176,7 +1213,7 @@ export class IdpAdminShell extends DeesElement {
1176
1213
  private accessor orgSettingsError = '';
1177
1214
 
1178
1215
  @state()
1179
- private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' | 'oidc-app-config' | 'oidc-secret-rotate' | 'create-org' | 'logout-confirm' = 'none';
1216
+ private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' | 'oidc-app-config' | 'oidc-app-create' | 'oidc-secret-rotate' | 'create-org' | 'logout-confirm' = 'none';
1180
1217
 
1181
1218
  @state()
1182
1219
  private accessor dialogError = '';
@@ -1238,6 +1275,25 @@ export class IdpAdminShell extends DeesElement {
1238
1275
  @state()
1239
1276
  private accessor dialogSecretGracePeriod = 86400;
1240
1277
 
1278
+ @state()
1279
+ private accessor dialogAppName = '';
1280
+
1281
+ @state()
1282
+ private accessor dialogAppDescription = '';
1283
+
1284
+ @state()
1285
+ private accessor dialogAppLogoUrl = '';
1286
+
1287
+ @state()
1288
+ private accessor dialogAppUrl = '';
1289
+
1290
+ @state()
1291
+ private accessor dialogAppCategory = '';
1292
+
1293
+ /** the create payload frozen at its first submit; it outlives the dialog until resolved or discarded */
1294
+ @state()
1295
+ private accessor gaAppCreateSubmission: IIdpAdminGaAppCreateEventDetail | null = null;
1296
+
1241
1297
  private get safeOrgs(): IIdpAdminOrg[] {
1242
1298
  return Array.isArray(this.orgs) ? this.orgs.filter(Boolean) : [];
1243
1299
  }
@@ -1651,6 +1707,11 @@ export class IdpAdminShell extends DeesElement {
1651
1707
  this.dialogIdTokenLifetime = 3600;
1652
1708
  this.dialogRefreshTokenLifetime = 2592000;
1653
1709
  this.dialogSecretGracePeriod = 86400;
1710
+ this.dialogAppName = '';
1711
+ this.dialogAppDescription = '';
1712
+ this.dialogAppLogoUrl = '';
1713
+ this.dialogAppUrl = '';
1714
+ this.dialogAppCategory = '';
1654
1715
  }
1655
1716
 
1656
1717
  private openRoleUpsertDialog(roleArg?: IIdpAdminOrgRoleDefinition) {
@@ -1708,6 +1769,30 @@ export class IdpAdminShell extends DeesElement {
1708
1769
  this.dialogRefreshTokenLifetime = appArg.refreshTokenLifetime || 2592000;
1709
1770
  }
1710
1771
 
1772
+ private openOidcAppCreateDialog() {
1773
+ this.closeDialog();
1774
+ this.dialogMode = 'oidc-app-create';
1775
+ const submission = this.gaAppCreateSubmission;
1776
+ if (submission) {
1777
+ // a submitted draft reopens exactly as it was sent, locked
1778
+ this.dialogAppName = submission.name;
1779
+ this.dialogAppDescription = submission.description;
1780
+ this.dialogAppLogoUrl = submission.logoUrl;
1781
+ this.dialogAppUrl = submission.appUrl;
1782
+ this.dialogAppCategory = submission.category;
1783
+ this.dialogRedirectUris = submission.redirectUris.join('\n');
1784
+ this.dialogPostLogoutRedirectUris = submission.postLogoutRedirectUris.join('\n');
1785
+ this.dialogAllowedScopes = submission.allowedScopes.join(', ');
1786
+ this.dialogGrantTypes = submission.grantTypes.join(', ');
1787
+ this.dialogAccessTokenLifetime = submission.oidcSettings.accessTokenLifetime;
1788
+ this.dialogIdTokenLifetime = submission.oidcSettings.idTokenLifetime;
1789
+ this.dialogRefreshTokenLifetime = submission.oidcSettings.refreshTokenLifetime;
1790
+ return;
1791
+ }
1792
+ this.dialogAllowedScopes = 'openid, profile, email';
1793
+ this.dialogGrantTypes = 'authorization_code, refresh_token';
1794
+ }
1795
+
1711
1796
  private openOidcSecretRotateDialog(appArg: IIdpAdminApp) {
1712
1797
  this.dialogMode = 'oidc-secret-rotate';
1713
1798
  this.dialogError = '';
@@ -1743,8 +1828,11 @@ export class IdpAdminShell extends DeesElement {
1743
1828
  this.dialogError = '';
1744
1829
  }
1745
1830
 
1746
- private submitOidcAppConfigDialog() {
1747
- if (!this.dialogApp) return;
1831
+ /** validates the OIDC client fields the configure and create dialogs share; sets dialogError and returns null when invalid */
1832
+ private validateOidcClientDialogFields(): Pick<
1833
+ IIdpAdminGaAppConfigureEventDetail,
1834
+ 'redirectUris' | 'postLogoutRedirectUris' | 'allowedScopes' | 'grantTypes' | 'oidcSettings'
1835
+ > | null {
1748
1836
  const redirectUris = this.parseLines(this.dialogRedirectUris);
1749
1837
  const postLogoutRedirectUris = this.parseLines(this.dialogPostLogoutRedirectUris);
1750
1838
  const allowedScopes = this.parseCsv(this.dialogAllowedScopes);
@@ -1761,32 +1849,29 @@ export class IdpAdminShell extends DeesElement {
1761
1849
  && this.dialogRefreshTokenLifetime <= 7776000;
1762
1850
  if (invalidGrantTypes.length) {
1763
1851
  this.dialogError = `Unsupported grant types: ${invalidGrantTypes.join(', ')}.`;
1764
- return;
1852
+ return null;
1765
1853
  }
1766
1854
  if (invalidUris.length) {
1767
1855
  this.dialogError = `Invalid exact redirect URIs: ${invalidUris.join(', ')}.`;
1768
- return;
1856
+ return null;
1769
1857
  }
1770
1858
  if (!grantTypes.length) {
1771
1859
  this.dialogError = 'Add at least one supported grant type.';
1772
- return;
1860
+ return null;
1773
1861
  }
1774
1862
  if (grantTypes.includes('authorization_code') && (!redirectUris.length || !allowedScopes.includes('openid'))) {
1775
1863
  this.dialogError = 'The authorization_code grant requires a callback URI and the openid scope.';
1776
- return;
1864
+ return null;
1777
1865
  }
1778
1866
  if (grantTypes.includes('refresh_token') && !grantTypes.includes('authorization_code')) {
1779
1867
  this.dialogError = 'The refresh_token grant requires the authorization_code grant.';
1780
- return;
1868
+ return null;
1781
1869
  }
1782
1870
  if (!lifetimesAreValid) {
1783
1871
  this.dialogError = 'Access and ID token lifetimes must be 300 to 86400 seconds; refresh family lifetime must be 3600 to 7776000 seconds.';
1784
- return;
1872
+ return null;
1785
1873
  }
1786
- this.dispatchShellEvent<IIdpAdminGaAppConfigureEventDetail>('idp-admin-ga-app-configure', {
1787
- appId: this.dialogApp.id,
1788
- clientType: this.dialogClientType,
1789
- isActive: this.dialogAppActive,
1874
+ return {
1790
1875
  redirectUris,
1791
1876
  postLogoutRedirectUris,
1792
1877
  allowedScopes,
@@ -1796,10 +1881,111 @@ export class IdpAdminShell extends DeesElement {
1796
1881
  idTokenLifetime: this.dialogIdTokenLifetime,
1797
1882
  refreshTokenLifetime: this.dialogRefreshTokenLifetime,
1798
1883
  },
1884
+ };
1885
+ }
1886
+
1887
+ private submitOidcAppConfigDialog() {
1888
+ if (!this.dialogApp) return;
1889
+ const oidcClientFields = this.validateOidcClientDialogFields();
1890
+ if (!oidcClientFields) return;
1891
+ this.dispatchShellEvent<IIdpAdminGaAppConfigureEventDetail>('idp-admin-ga-app-configure', {
1892
+ appId: this.dialogApp.id,
1893
+ clientType: this.dialogClientType,
1894
+ isActive: this.dialogAppActive,
1895
+ ...oidcClientFields,
1799
1896
  });
1800
1897
  this.closeDialog();
1801
1898
  }
1802
1899
 
1900
+ /** an HTTPS URL without credentials, as the app stores registered app and logo URLs */
1901
+ private isValidHttpsUrl(valueArg: string): boolean {
1902
+ try {
1903
+ const url = new URL(valueArg);
1904
+ return url.protocol === 'https:' && !url.username && !url.password;
1905
+ } catch {
1906
+ return false;
1907
+ }
1908
+ }
1909
+
1910
+ /** the app metadata limits idp.global stores: lengths in UTF-16 code units, no control characters */
1911
+ private validateGaAppCreateMetadata(): Pick<
1912
+ IIdpAdminGaAppCreateEventDetail,
1913
+ 'name' | 'description' | 'logoUrl' | 'appUrl' | 'category'
1914
+ > | null {
1915
+ const controlCharacters = /[\u0000-\u001f\u007f]/;
1916
+ const name = this.dialogAppName.trim();
1917
+ const description = this.dialogAppDescription.trim();
1918
+ const logoUrl = this.dialogAppLogoUrl.trim();
1919
+ const appUrl = this.dialogAppUrl.trim();
1920
+ const category = this.dialogAppCategory.trim();
1921
+ if (!name || name.length > 512 || controlCharacters.test(name)) {
1922
+ this.dialogError = 'Enter an app name of at most 512 characters without control characters.';
1923
+ return null;
1924
+ }
1925
+ if (description.length > 8192 || controlCharacters.test(description)) {
1926
+ this.dialogError = 'The description must be at most 8192 characters without control characters.';
1927
+ return null;
1928
+ }
1929
+ if (!appUrl || appUrl.length > 2048 || !this.isValidHttpsUrl(appUrl)) {
1930
+ this.dialogError = 'Enter the app URL as an absolute HTTPS URL without credentials.';
1931
+ return null;
1932
+ }
1933
+ if (logoUrl && (logoUrl.length > 2048 || !this.isValidHttpsUrl(logoUrl))) {
1934
+ this.dialogError = 'The logo URL must be an absolute HTTPS URL without credentials, or empty.';
1935
+ return null;
1936
+ }
1937
+ if (!category || category.length > 256 || controlCharacters.test(category)) {
1938
+ this.dialogError = 'Enter a category of at most 256 characters without control characters.';
1939
+ return null;
1940
+ }
1941
+ return { name, description, logoUrl, appUrl, category };
1942
+ }
1943
+
1944
+ /** only one one-time secret is on screen at a time */
1945
+ private ensureNoDisplayedClientSecret(): boolean {
1946
+ if (this.oidcClientSecret) {
1947
+ this.dialogError = 'Store and dismiss the displayed client secret before creating another app.';
1948
+ return false;
1949
+ }
1950
+ return true;
1951
+ }
1952
+
1953
+ private submitOidcAppCreateDialog() {
1954
+ if (!this.ensureNoDisplayedClientSecret()) return;
1955
+ // the first submit freezes the payload; every retry resends exactly it
1956
+ let submission = this.gaAppCreateSubmission;
1957
+ if (!submission) {
1958
+ const metadata = this.validateGaAppCreateMetadata();
1959
+ if (!metadata) return;
1960
+ const oidcClientFields = this.validateOidcClientDialogFields();
1961
+ if (!oidcClientFields) return;
1962
+ submission = { ...metadata, ...oidcClientFields };
1963
+ this.gaAppCreateSubmission = submission;
1964
+ }
1965
+ // a fresh copy per dispatch, so a listener can never alter the frozen draft
1966
+ this.dispatchShellEvent<IIdpAdminGaAppCreateEventDetail>('idp-admin-ga-app-create', structuredClone(submission));
1967
+ this.closeDialog();
1968
+ }
1969
+
1970
+ /** drops the frozen draft and unlocks its values for editing; the next submit is a new creation */
1971
+ private discardOidcAppCreate() {
1972
+ if (!this.gaAppCreateSubmission) return;
1973
+ this.gaAppCreateSubmission = null;
1974
+ this.dialogError = '';
1975
+ this.dispatchShellEvent('idp-admin-ga-app-create-discard', {});
1976
+ }
1977
+
1978
+ /**
1979
+ * The host calls this once `idp-admin-ga-app-create` has created the app (after it set
1980
+ * `oidcClientSecret`): the frozen draft is cleared and an open create dialog closes.
1981
+ */
1982
+ public completeGaAppCreate() {
1983
+ this.gaAppCreateSubmission = null;
1984
+ if (this.dialogMode === 'oidc-app-create') {
1985
+ this.closeDialog();
1986
+ }
1987
+ }
1988
+
1803
1989
  private submitOidcSecretRotateDialog() {
1804
1990
  if (!this.dialogApp) return;
1805
1991
  if (!Number.isInteger(this.dialogSecretGracePeriod) || this.dialogSecretGracePeriod < 0 || this.dialogSecretGracePeriod > 604800) {
@@ -2068,15 +2254,7 @@ export class IdpAdminShell extends DeesElement {
2068
2254
  </select>
2069
2255
  `, true)}
2070
2256
  ${this.renderFormRow('Active', 'Deactivation rejects authorization, exchange, refresh, and UserInfo.', html`<input type="checkbox" .checked=${this.dialogAppActive} @change=${(eventArg: Event) => { this.dialogAppActive = (eventArg.target as HTMLInputElement).checked; }} />`)}
2071
- ${this.renderFormRow('Callback URIs', 'One exact absolute URI per line; required for authorization_code.', html`<textarea class="textarea" .value=${this.dialogRedirectUris} @input=${(eventArg: Event) => { this.dialogRedirectUris = (eventArg.target as HTMLTextAreaElement).value; }}></textarea>`)}
2072
- ${this.renderFormRow('Post-logout URIs', 'One exact RP-Initiated Logout destination per line.', html`<textarea class="textarea" .value=${this.dialogPostLogoutRedirectUris} @input=${(eventArg: Event) => { this.dialogPostLogoutRedirectUris = (eventArg.target as HTMLTextAreaElement).value; }}></textarea>`)}
2073
- ${this.renderFormRow('Allowed scopes', 'Comma-separated scopes; authorization_code requires openid.', html`<input class="input" .value=${this.dialogAllowedScopes} @input=${(eventArg: Event) => { this.dialogAllowedScopes = (eventArg.target as HTMLInputElement).value; }} />`, true)}
2074
- ${this.renderFormRow('Grant types', 'authorization_code and refresh_token are supported.', html`<input class="input" .value=${this.dialogGrantTypes} @input=${(eventArg: Event) => { this.dialogGrantTypes = (eventArg.target as HTMLInputElement).value; }} />`, true)}
2075
- <div class="mapping-row">
2076
- <div>${this.renderFormRow('Access token seconds', '300 to 86400.', html`<input class="input" type="number" .value=${String(this.dialogAccessTokenLifetime)} @input=${(eventArg: Event) => this.setDialogNumber('access', eventArg)} />`, true)}</div>
2077
- <div>${this.renderFormRow('ID token seconds', '300 to 86400.', html`<input class="input" type="number" .value=${String(this.dialogIdTokenLifetime)} @input=${(eventArg: Event) => this.setDialogNumber('id', eventArg)} />`, true)}</div>
2078
- <div>${this.renderFormRow('Refresh family seconds', '3600 to 7776000; absolute lifetime.', html`<input class="input" type="number" .value=${String(this.dialogRefreshTokenLifetime)} @input=${(eventArg: Event) => this.setDialogNumber('refresh', eventArg)} />`, true)}</div>
2079
- </div>
2257
+ ${this.renderOidcClientFieldRows(false)}
2080
2258
  </div>
2081
2259
  <div class="dialog-actions">
2082
2260
  <button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
@@ -2086,6 +2264,67 @@ export class IdpAdminShell extends DeesElement {
2086
2264
  `;
2087
2265
  }
2088
2266
 
2267
+ /** the OIDC client rows the configure and create dialogs share */
2268
+ private renderOidcClientFieldRows(lockedArg: boolean): TemplateResult {
2269
+ return html`
2270
+ ${this.renderFormRow('Callback URIs', 'One exact absolute URI per line; required for authorization_code.', html`<textarea class="textarea" .value=${this.dialogRedirectUris} ?disabled=${lockedArg} @input=${(eventArg: Event) => { this.dialogRedirectUris = (eventArg.target as HTMLTextAreaElement).value; }}></textarea>`)}
2271
+ ${this.renderFormRow('Post-logout URIs', 'One exact RP-Initiated Logout destination per line.', html`<textarea class="textarea" .value=${this.dialogPostLogoutRedirectUris} ?disabled=${lockedArg} @input=${(eventArg: Event) => { this.dialogPostLogoutRedirectUris = (eventArg.target as HTMLTextAreaElement).value; }}></textarea>`)}
2272
+ ${this.renderFormRow('Allowed scopes', 'Comma-separated scopes; authorization_code requires openid.', html`<input class="input" .value=${this.dialogAllowedScopes} ?disabled=${lockedArg} @input=${(eventArg: Event) => { this.dialogAllowedScopes = (eventArg.target as HTMLInputElement).value; }} />`, true)}
2273
+ ${this.renderFormRow('Grant types', 'authorization_code and refresh_token are supported.', html`<input class="input" .value=${this.dialogGrantTypes} ?disabled=${lockedArg} @input=${(eventArg: Event) => { this.dialogGrantTypes = (eventArg.target as HTMLInputElement).value; }} />`, true)}
2274
+ <div class="mapping-row">
2275
+ <div>${this.renderFormRow('Access token seconds', '300 to 86400.', html`<input class="input" type="number" .value=${String(this.dialogAccessTokenLifetime)} ?disabled=${lockedArg} @input=${(eventArg: Event) => this.setDialogNumber('access', eventArg)} />`, true)}</div>
2276
+ <div>${this.renderFormRow('ID token seconds', '300 to 86400.', html`<input class="input" type="number" .value=${String(this.dialogIdTokenLifetime)} ?disabled=${lockedArg} @input=${(eventArg: Event) => this.setDialogNumber('id', eventArg)} />`, true)}</div>
2277
+ <div>${this.renderFormRow('Refresh family seconds', '3600 to 7776000; absolute lifetime.', html`<input class="input" type="number" .value=${String(this.dialogRefreshTokenLifetime)} ?disabled=${lockedArg} @input=${(eventArg: Event) => this.setDialogNumber('refresh', eventArg)} />`, true)}</div>
2278
+ </div>
2279
+ `;
2280
+ }
2281
+
2282
+ private setDialogAppField(fieldArg: 'name' | 'description' | 'logoUrl' | 'appUrl' | 'category', eventArg: Event) {
2283
+ const value = (eventArg.target as HTMLInputElement).value;
2284
+ if (fieldArg === 'name') this.dialogAppName = value;
2285
+ if (fieldArg === 'description') this.dialogAppDescription = value;
2286
+ if (fieldArg === 'logoUrl') this.dialogAppLogoUrl = value;
2287
+ if (fieldArg === 'appUrl') this.dialogAppUrl = value;
2288
+ if (fieldArg === 'category') this.dialogAppCategory = value;
2289
+ this.dialogError = '';
2290
+ }
2291
+
2292
+ private renderOidcAppCreateDialog(): TemplateResult {
2293
+ const locked = this.gaAppCreateSubmission !== null;
2294
+ return html`
2295
+ <div class="dialog-card wide" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
2296
+ <div class="dialog-head">
2297
+ <div><div class="dialog-title">Create global app</div><div class="muted">Registers a confidential OIDC client. Its client secret is shown once, after creation.</div></div>
2298
+ <button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
2299
+ </div>
2300
+ <div class="dialog-body">
2301
+ ${this.renderDialogError()}
2302
+ <div class="notice-card"><idp-icon name="key" size="16"></idp-icon><div><div class="section-title">Passkey required</div><div class="muted">Creating a confidential client requires a passkey sign-in within the last 5 minutes.</div></div></div>
2303
+ ${locked
2304
+ ? html`<div class="notice-card" style="border-color:var(--idp-warn-border);background:var(--idp-warn-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-warn)"></idp-icon><div><div class="section-title" style="color:var(--idp-warn)">Submitted values are locked</div><div class="muted">Retry sends exactly these values again. To change them, discard this draft.</div></div></div>`
2305
+ : html``}
2306
+ ${this.renderFormRow('App name', 'Shown on consent screens and in the app directory.', html`<input class="input" maxlength="512" .value=${this.dialogAppName} ?disabled=${locked} @input=${(eventArg: Event) => this.setDialogAppField('name', eventArg)} />`, true)}
2307
+ ${this.renderFormRow('Description', 'Optional one-line summary.', html`<input class="input" maxlength="8192" .value=${this.dialogAppDescription} ?disabled=${locked} @input=${(eventArg: Event) => this.setDialogAppField('description', eventArg)} />`)}
2308
+ ${this.renderFormRow('App URL', 'Absolute HTTPS URL of the app.', html`<input class="input" type="url" maxlength="2048" placeholder="https://app.example.com" .value=${this.dialogAppUrl} ?disabled=${locked} @input=${(eventArg: Event) => this.setDialogAppField('appUrl', eventArg)} />`, true)}
2309
+ ${this.renderFormRow('Logo URL', 'Optional absolute HTTPS URL of the app logo.', html`<input class="input" type="url" maxlength="2048" .value=${this.dialogAppLogoUrl} ?disabled=${locked} @input=${(eventArg: Event) => this.setDialogAppField('logoUrl', eventArg)} />`)}
2310
+ ${this.renderFormRow('Category', 'Free text, at most 256 characters, for example Development.', html`<input class="input" maxlength="256" .value=${this.dialogAppCategory} ?disabled=${locked} @input=${(eventArg: Event) => this.setDialogAppField('category', eventArg)} />`, true)}
2311
+ ${this.renderOidcClientFieldRows(locked)}
2312
+ </div>
2313
+ <div class="dialog-actions">
2314
+ ${locked
2315
+ ? html`
2316
+ <button class="plain-button outline" @click=${() => this.discardOidcAppCreate()}>Discard and edit</button>
2317
+ <button class="plain-button primary" @click=${() => this.submitOidcAppCreateDialog()}>Retry create</button>
2318
+ `
2319
+ : html`
2320
+ <button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
2321
+ <button class="plain-button primary" @click=${() => this.submitOidcAppCreateDialog()}>Create app</button>
2322
+ `}
2323
+ </div>
2324
+ </div>
2325
+ `;
2326
+ }
2327
+
2089
2328
  private renderOidcSecretRotateDialog(): TemplateResult {
2090
2329
  return html`
2091
2330
  <div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
@@ -2166,9 +2405,11 @@ export class IdpAdminShell extends DeesElement {
2166
2405
  ? this.renderLogoutConfirmDialog()
2167
2406
  : this.dialogMode === 'oidc-app-config'
2168
2407
  ? this.renderOidcAppConfigDialog()
2169
- : this.dialogMode === 'oidc-secret-rotate'
2170
- ? this.renderOidcSecretRotateDialog()
2171
- : this.renderAppRoleMappingsDialog();
2408
+ : this.dialogMode === 'oidc-app-create'
2409
+ ? this.renderOidcAppCreateDialog()
2410
+ : this.dialogMode === 'oidc-secret-rotate'
2411
+ ? this.renderOidcSecretRotateDialog()
2412
+ : this.renderAppRoleMappingsDialog();
2172
2413
  return html`<div class="dialog-backdrop" @click=${() => this.closeDialog()}>${dialog}</div>`;
2173
2414
  }
2174
2415
 
@@ -2924,8 +3165,12 @@ export class IdpAdminShell extends DeesElement {
2924
3165
  ],
2925
3166
  }));
2926
3167
 
3168
+ const createAction = this.superadmin && this.globalAdmin
3169
+ ? html`<button class="plain-button primary" @click=${() => this.openOidcAppCreateDialog()}><idp-icon name="plus" size="12"></idp-icon>New app</button>`
3170
+ : undefined;
3171
+
2927
3172
  return html`
2928
- ${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}
3173
+ ${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.', createAction)}
2929
3174
  <div class="body wide-body">
2930
3175
  ${this.oidcClientSecret
2931
3176
  ? html`