@idp.global/catalog 3.4.0 → 3.6.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.
@@ -123,6 +123,36 @@ export interface IIdpAdminGaAppActionEventDetail {
123
123
  action: 'approve' | 'reject' | 'suspend' | 'reinstate';
124
124
  }
125
125
 
126
+ export interface IIdpAdminGaAppConfigureEventDetail {
127
+ appId: string;
128
+ clientType: 'public' | 'confidential';
129
+ isActive: boolean;
130
+ redirectUris: string[];
131
+ postLogoutRedirectUris: string[];
132
+ allowedScopes: string[];
133
+ grantTypes: Array<'authorization_code' | 'client_credentials' | 'refresh_token'>;
134
+ oidcSettings: {
135
+ accessTokenLifetime: number;
136
+ idTokenLifetime: number;
137
+ refreshTokenLifetime: number;
138
+ };
139
+ }
140
+
141
+ export interface IIdpAdminGaAppRotateSecretEventDetail {
142
+ appId: string;
143
+ gracePeriodSeconds: number;
144
+ }
145
+
146
+ export interface IIdpAdminGaAppSecretDismissEventDetail {
147
+ appId: string;
148
+ }
149
+
150
+ export interface IIdpAdminClientSecret {
151
+ appId: string;
152
+ clientId: string;
153
+ clientSecret: string;
154
+ }
155
+
126
156
  export interface IIdpAdminOverviewSeriesPoint {
127
157
  /** epoch-ms timestamp or Date-parseable string; plain clock times like '14:00' are not chartable */
128
158
  x: string | number;
@@ -190,8 +220,16 @@ export interface IIdpAdminApp {
190
220
  isConnected?: boolean;
191
221
  connectionCount?: number;
192
222
  clientId?: string;
223
+ clientType?: 'public' | 'confidential';
224
+ isActive?: boolean;
225
+ redirectUris?: string[];
226
+ postLogoutRedirectUris?: string[];
193
227
  scopes?: string[];
194
228
  grants?: string[];
229
+ accessTokenLifetime?: number;
230
+ idTokenLifetime?: number;
231
+ refreshTokenLifetime?: number;
232
+ retiringClientSecretCount?: number;
195
233
  roleMappings?: IIdpAdminAppRoleMapping[];
196
234
  }
197
235
 
@@ -989,6 +1027,9 @@ export class IdpAdminShell extends DeesElement {
989
1027
  @property({ type: Array })
990
1028
  public accessor adminApps: IIdpAdminApp[] = [];
991
1029
 
1030
+ @property({ type: Object })
1031
+ public accessor oidcClientSecret: IIdpAdminClientSecret | null = null;
1032
+
992
1033
  @property({ type: Array })
993
1034
  public accessor passportDevices: IIdpAdminPassportDevice[] = [];
994
1035
 
@@ -1126,7 +1167,7 @@ export class IdpAdminShell extends DeesElement {
1126
1167
  private accessor orgSettingsError = '';
1127
1168
 
1128
1169
  @state()
1129
- private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' | 'create-org' | 'logout-confirm' = 'none';
1170
+ private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' | 'oidc-app-config' | 'oidc-secret-rotate' | 'create-org' | 'logout-confirm' = 'none';
1130
1171
 
1131
1172
  @state()
1132
1173
  private accessor dialogError = '';
@@ -1155,6 +1196,36 @@ export class IdpAdminShell extends DeesElement {
1155
1196
  @state()
1156
1197
  private accessor dialogAppMappings: IIdpAdminAppRoleMapping[] = [];
1157
1198
 
1199
+ @state()
1200
+ private accessor dialogClientType: 'public' | 'confidential' = 'confidential';
1201
+
1202
+ @state()
1203
+ private accessor dialogAppActive = true;
1204
+
1205
+ @state()
1206
+ private accessor dialogRedirectUris = '';
1207
+
1208
+ @state()
1209
+ private accessor dialogPostLogoutRedirectUris = '';
1210
+
1211
+ @state()
1212
+ private accessor dialogAllowedScopes = '';
1213
+
1214
+ @state()
1215
+ private accessor dialogGrantTypes = '';
1216
+
1217
+ @state()
1218
+ private accessor dialogAccessTokenLifetime = 3600;
1219
+
1220
+ @state()
1221
+ private accessor dialogIdTokenLifetime = 3600;
1222
+
1223
+ @state()
1224
+ private accessor dialogRefreshTokenLifetime = 2592000;
1225
+
1226
+ @state()
1227
+ private accessor dialogSecretGracePeriod = 86400;
1228
+
1158
1229
  private get safeOrgs(): IIdpAdminOrg[] {
1159
1230
  return Array.isArray(this.orgs) ? this.orgs.filter(Boolean) : [];
1160
1231
  }
@@ -1556,6 +1627,16 @@ export class IdpAdminShell extends DeesElement {
1556
1627
  this.dialogMemberRoles = [];
1557
1628
  this.dialogApp = null;
1558
1629
  this.dialogAppMappings = [];
1630
+ this.dialogClientType = 'confidential';
1631
+ this.dialogAppActive = true;
1632
+ this.dialogRedirectUris = '';
1633
+ this.dialogPostLogoutRedirectUris = '';
1634
+ this.dialogAllowedScopes = '';
1635
+ this.dialogGrantTypes = '';
1636
+ this.dialogAccessTokenLifetime = 3600;
1637
+ this.dialogIdTokenLifetime = 3600;
1638
+ this.dialogRefreshTokenLifetime = 2592000;
1639
+ this.dialogSecretGracePeriod = 86400;
1559
1640
  }
1560
1641
 
1561
1642
  private openRoleUpsertDialog(roleArg?: IIdpAdminOrgRoleDefinition) {
@@ -1597,6 +1678,130 @@ export class IdpAdminShell extends DeesElement {
1597
1678
  });
1598
1679
  }
1599
1680
 
1681
+ private openOidcAppConfigDialog(appArg: IIdpAdminApp) {
1682
+ this.dialogMode = 'oidc-app-config';
1683
+ this.dialogError = '';
1684
+ this.dialogApp = appArg;
1685
+ this.dialogClientType = appArg.clientType || 'confidential';
1686
+ this.dialogAppActive = appArg.isActive ?? appArg.status !== 'suspended';
1687
+ this.dialogRedirectUris = (appArg.redirectUris || []).join('\n');
1688
+ this.dialogPostLogoutRedirectUris = (appArg.postLogoutRedirectUris || []).join('\n');
1689
+ this.dialogAllowedScopes = (appArg.scopes || []).join(', ');
1690
+ this.dialogGrantTypes = (appArg.grants || []).join(', ');
1691
+ this.dialogAccessTokenLifetime = appArg.accessTokenLifetime || 3600;
1692
+ this.dialogIdTokenLifetime = appArg.idTokenLifetime || 3600;
1693
+ this.dialogRefreshTokenLifetime = appArg.refreshTokenLifetime || 2592000;
1694
+ }
1695
+
1696
+ private openOidcSecretRotateDialog(appArg: IIdpAdminApp) {
1697
+ this.dialogMode = 'oidc-secret-rotate';
1698
+ this.dialogError = '';
1699
+ this.dialogApp = appArg;
1700
+ this.dialogSecretGracePeriod = 86400;
1701
+ }
1702
+
1703
+ private parseLines(valueArg: string): string[] {
1704
+ return [...new Set(valueArg.split(/\r?\n/).map((entryArg) => entryArg.trim()).filter(Boolean))];
1705
+ }
1706
+
1707
+ private isValidExactUri(valueArg: string): boolean {
1708
+ if (/\s|\*|#/.test(valueArg)) {
1709
+ return false;
1710
+ }
1711
+ try {
1712
+ const url = new URL(valueArg);
1713
+ return !['data:', 'file:', 'javascript:'].includes(url.protocol)
1714
+ && !url.username
1715
+ && !url.password
1716
+ && !url.hash;
1717
+ } catch {
1718
+ return false;
1719
+ }
1720
+ }
1721
+
1722
+ private setDialogNumber(fieldArg: 'access' | 'id' | 'refresh' | 'grace', eventArg: Event) {
1723
+ const value = Number((eventArg.target as HTMLInputElement).value);
1724
+ if (fieldArg === 'access') this.dialogAccessTokenLifetime = value;
1725
+ if (fieldArg === 'id') this.dialogIdTokenLifetime = value;
1726
+ if (fieldArg === 'refresh') this.dialogRefreshTokenLifetime = value;
1727
+ if (fieldArg === 'grace') this.dialogSecretGracePeriod = value;
1728
+ this.dialogError = '';
1729
+ }
1730
+
1731
+ private submitOidcAppConfigDialog() {
1732
+ if (!this.dialogApp) return;
1733
+ const redirectUris = this.parseLines(this.dialogRedirectUris);
1734
+ const postLogoutRedirectUris = this.parseLines(this.dialogPostLogoutRedirectUris);
1735
+ const allowedScopes = this.parseCsv(this.dialogAllowedScopes);
1736
+ const configuredGrantTypes = this.parseCsv(this.dialogGrantTypes);
1737
+ const supportedGrantTypes = ['authorization_code', 'client_credentials', 'refresh_token'];
1738
+ const invalidGrantTypes = configuredGrantTypes.filter((grantArg) => !supportedGrantTypes.includes(grantArg));
1739
+ const grantTypes = configuredGrantTypes
1740
+ .filter((grantArg): grantArg is 'authorization_code' | 'client_credentials' | 'refresh_token' => supportedGrantTypes.includes(grantArg));
1741
+ const invalidUris = [...redirectUris, ...postLogoutRedirectUris].filter((uriArg) => !this.isValidExactUri(uriArg));
1742
+ const lifetimesAreValid = [this.dialogAccessTokenLifetime, this.dialogIdTokenLifetime]
1743
+ .every((lifetimeArg) => Number.isInteger(lifetimeArg) && lifetimeArg >= 300 && lifetimeArg <= 86400)
1744
+ && Number.isInteger(this.dialogRefreshTokenLifetime)
1745
+ && this.dialogRefreshTokenLifetime >= 3600
1746
+ && this.dialogRefreshTokenLifetime <= 7776000;
1747
+ if (invalidGrantTypes.length) {
1748
+ this.dialogError = `Unsupported grant types: ${invalidGrantTypes.join(', ')}.`;
1749
+ return;
1750
+ }
1751
+ if (invalidUris.length) {
1752
+ this.dialogError = `Invalid exact redirect URIs: ${invalidUris.join(', ')}.`;
1753
+ return;
1754
+ }
1755
+ if (!grantTypes.length) {
1756
+ this.dialogError = 'Add at least one supported grant type.';
1757
+ return;
1758
+ }
1759
+ if (grantTypes.includes('authorization_code') && (!redirectUris.length || !allowedScopes.includes('openid'))) {
1760
+ this.dialogError = 'The authorization_code grant requires a callback URI and the openid scope.';
1761
+ return;
1762
+ }
1763
+ if (grantTypes.includes('refresh_token') && !grantTypes.includes('authorization_code')) {
1764
+ this.dialogError = 'The refresh_token grant requires the authorization_code grant.';
1765
+ return;
1766
+ }
1767
+ if (this.dialogClientType === 'public' && grantTypes.includes('client_credentials')) {
1768
+ this.dialogError = 'Public clients cannot use the client_credentials grant.';
1769
+ return;
1770
+ }
1771
+ if (!lifetimesAreValid) {
1772
+ this.dialogError = 'Access and ID token lifetimes must be 300 to 86400 seconds; refresh family lifetime must be 3600 to 7776000 seconds.';
1773
+ return;
1774
+ }
1775
+ this.dispatchShellEvent<IIdpAdminGaAppConfigureEventDetail>('idp-admin-ga-app-configure', {
1776
+ appId: this.dialogApp.id,
1777
+ clientType: this.dialogClientType,
1778
+ isActive: this.dialogAppActive,
1779
+ redirectUris,
1780
+ postLogoutRedirectUris,
1781
+ allowedScopes,
1782
+ grantTypes,
1783
+ oidcSettings: {
1784
+ accessTokenLifetime: this.dialogAccessTokenLifetime,
1785
+ idTokenLifetime: this.dialogIdTokenLifetime,
1786
+ refreshTokenLifetime: this.dialogRefreshTokenLifetime,
1787
+ },
1788
+ });
1789
+ this.closeDialog();
1790
+ }
1791
+
1792
+ private submitOidcSecretRotateDialog() {
1793
+ if (!this.dialogApp) return;
1794
+ if (!Number.isInteger(this.dialogSecretGracePeriod) || this.dialogSecretGracePeriod < 0 || this.dialogSecretGracePeriod > 604800) {
1795
+ this.dialogError = 'Grace period must be a whole number from 0 to 604800 seconds.';
1796
+ return;
1797
+ }
1798
+ this.dispatchShellEvent<IIdpAdminGaAppRotateSecretEventDetail>('idp-admin-ga-app-rotate-secret', {
1799
+ appId: this.dialogApp.id,
1800
+ gracePeriodSeconds: this.dialogSecretGracePeriod,
1801
+ });
1802
+ this.closeDialog();
1803
+ }
1804
+
1600
1805
  private setDialogRoleField(fieldArg: 'key' | 'name' | 'description' | 'deleteConfirmation', eventArg: Event) {
1601
1806
  const value = (eventArg.target as HTMLInputElement).value;
1602
1807
  if (fieldArg === 'key') this.dialogRoleKey = this.normalizeRoleKey(value);
@@ -1810,6 +2015,60 @@ export class IdpAdminShell extends DeesElement {
1810
2015
  `;
1811
2016
  }
1812
2017
 
2018
+ private renderOidcAppConfigDialog(): TemplateResult {
2019
+ return html`
2020
+ <div class="dialog-card wide" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
2021
+ <div class="dialog-head">
2022
+ <div><div class="dialog-title">Configure OIDC client</div><div class="muted">${this.dialogApp?.name || 'Platform app'} uses exact callback and logout URI allowlists.</div></div>
2023
+ <button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
2024
+ </div>
2025
+ <div class="dialog-body">
2026
+ ${this.renderDialogError()}
2027
+ ${this.renderFormRow('Client type', 'Public clients use PKCE without a client secret.', html`
2028
+ <select class="input" .value=${this.dialogClientType} @change=${(eventArg: Event) => { this.dialogClientType = (eventArg.target as HTMLSelectElement).value as 'public' | 'confidential'; }}>
2029
+ <option value="confidential">Confidential</option>
2030
+ <option value="public">Public</option>
2031
+ </select>
2032
+ `, true)}
2033
+ ${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; }} />`)}
2034
+ ${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>`)}
2035
+ ${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>`)}
2036
+ ${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)}
2037
+ ${this.renderFormRow('Grant types', 'authorization_code, client_credentials, and refresh_token are supported.', html`<input class="input" .value=${this.dialogGrantTypes} @input=${(eventArg: Event) => { this.dialogGrantTypes = (eventArg.target as HTMLInputElement).value; }} />`, true)}
2038
+ <div class="mapping-row">
2039
+ <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>
2040
+ <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>
2041
+ <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>
2042
+ </div>
2043
+ </div>
2044
+ <div class="dialog-actions">
2045
+ <button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
2046
+ <button class="plain-button primary" @click=${() => this.submitOidcAppConfigDialog()}>Save OIDC configuration</button>
2047
+ </div>
2048
+ </div>
2049
+ `;
2050
+ }
2051
+
2052
+ private renderOidcSecretRotateDialog(): TemplateResult {
2053
+ return html`
2054
+ <div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
2055
+ <div class="dialog-head">
2056
+ <div><div class="dialog-title">Rotate client secret</div><div class="muted">The client ID remains stable. The old secret stays valid only during the overlap window.</div></div>
2057
+ <button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
2058
+ </div>
2059
+ <div class="dialog-body">
2060
+ ${this.renderDialogError()}
2061
+ ${this.renderFormRow('Overlap seconds', 'Default 86400 (24 hours), maximum 604800 (7 days).', html`<input class="input" type="number" min="0" max="604800" .value=${String(this.dialogSecretGracePeriod)} @input=${(eventArg: Event) => this.setDialogNumber('grace', eventArg)} />`, true)}
2062
+ <div class="notice-card"><idp-icon name="key" size="16"></idp-icon><div><div class="section-title">Shown once</div><div class="muted">The new plaintext secret appears once after rotation. Store it before dismissing the notice.</div></div></div>
2063
+ </div>
2064
+ <div class="dialog-actions">
2065
+ <button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
2066
+ <button class="plain-button destructive" @click=${() => this.submitOidcSecretRotateDialog()}>Rotate secret</button>
2067
+ </div>
2068
+ </div>
2069
+ `;
2070
+ }
2071
+
1813
2072
  private renderCreateOrgDialog(): TemplateResult {
1814
2073
  return html`
1815
2074
  <div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
@@ -1868,7 +2127,11 @@ export class IdpAdminShell extends DeesElement {
1868
2127
  ? this.renderCreateOrgDialog()
1869
2128
  : this.dialogMode === 'logout-confirm'
1870
2129
  ? this.renderLogoutConfirmDialog()
1871
- : this.renderAppRoleMappingsDialog();
2130
+ : this.dialogMode === 'oidc-app-config'
2131
+ ? this.renderOidcAppConfigDialog()
2132
+ : this.dialogMode === 'oidc-secret-rotate'
2133
+ ? this.renderOidcSecretRotateDialog()
2134
+ : this.renderAppRoleMappingsDialog();
1872
2135
  return html`<div class="dialog-backdrop" @click=${() => this.closeDialog()}>${dialog}</div>`;
1873
2136
  }
1874
2137
 
@@ -1904,8 +2167,17 @@ export class IdpAdminShell extends DeesElement {
1904
2167
  `;
1905
2168
  }
1906
2169
 
2170
+ private async copyCodeValue(valueArg: string) {
2171
+ try {
2172
+ await navigator.clipboard.writeText(valueArg);
2173
+ this.showToast('Copied to clipboard.');
2174
+ } catch {
2175
+ this.showToast('Clipboard access was denied. Select and copy the value manually.', 'warn');
2176
+ }
2177
+ }
2178
+
1907
2179
  private renderCodeBlock(valueArg: string): TemplateResult {
1908
- return html`<div class="code-block"><code>${valueArg}</code><idp-icon name="copy" size="13"></idp-icon></div>`;
2180
+ return html`<div class="code-block"><code>${valueArg}</code><button class="plain-button ghost" aria-label="Copy value" @click=${() => this.copyCodeValue(valueArg)}><idp-icon name="copy" size="13"></idp-icon></button></div>`;
1909
2181
  }
1910
2182
 
1911
2183
  private renderOverview(): TemplateResult {
@@ -2600,20 +2872,41 @@ export class IdpAdminShell extends DeesElement {
2600
2872
  appArg.category || '-',
2601
2873
  appArg.connectionCount ?? '-',
2602
2874
  html`<idp-badge variant=${appArg.status === 'suspended' || appArg.status === 'rejected' ? 'error' : appArg.status === 'pending_review' ? 'warn' : 'ok'}>${(appArg.status || 'active').replace(/_/g, ' ')}</idp-badge>`,
2603
- appArg.status === 'pending_review'
2604
- ? html`
2605
- <div style="display:flex;gap:4px">
2875
+ html`
2876
+ <div class="cell-actions">
2877
+ ${appArg.status === 'pending_review'
2878
+ ? html`
2606
2879
  <button class="plain-button outline" style="font-size:11px;color:var(--idp-ok);border-color:var(--idp-ok-border)" @click=${() => this.dispatchShellEvent<IIdpAdminGaAppActionEventDetail>('idp-admin-ga-app-action', { appId: appArg.id, action: 'approve' })}>Approve</button>
2607
2880
  <button class="plain-button ghost" style="font-size:11px;color:var(--idp-destructive)" @click=${() => this.dispatchShellEvent<IIdpAdminGaAppActionEventDetail>('idp-admin-ga-app-action', { appId: appArg.id, action: 'reject' })}>Reject</button>
2608
- </div>
2609
- `
2610
- : html`<button class="plain-button ghost" style="font-size:11px" @click=${() => this.dispatchShellEvent<IIdpAdminGaAppActionEventDetail>('idp-admin-ga-app-action', { appId: appArg.id, action: appArg.status === 'suspended' ? 'reinstate' : 'suspend' })}>${appArg.status === 'suspended' ? 'Reinstate' : 'Suspend'}</button>`,
2881
+ `
2882
+ : html`<button class="plain-button ghost" style="font-size:11px" @click=${() => this.dispatchShellEvent<IIdpAdminGaAppActionEventDetail>('idp-admin-ga-app-action', { appId: appArg.id, action: appArg.status === 'suspended' ? 'reinstate' : 'suspend' })}>${appArg.status === 'suspended' ? 'Reinstate' : 'Suspend'}</button>`}
2883
+ <button class="plain-button outline" style="font-size:11px" @click=${() => this.openOidcAppConfigDialog(appArg)}>Configure</button>
2884
+ ${appArg.clientType === 'public'
2885
+ ? html``
2886
+ : html`<button class="plain-button outline" style="font-size:11px" @click=${() => this.openOidcSecretRotateDialog(appArg)}>Rotate secret</button>`}
2887
+ </div>
2888
+ `,
2611
2889
  ],
2612
2890
  }));
2613
2891
 
2614
2892
  return html`
2615
2893
  ${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}
2616
2894
  <div class="body wide-body">
2895
+ ${this.oidcClientSecret
2896
+ ? html`
2897
+ <div class="notice-card" style="margin-bottom:16px" role="status" aria-live="polite">
2898
+ <idp-icon name="key" size="16"></idp-icon>
2899
+ <div style="min-width:0;flex:1">
2900
+ <div class="section-title">New client secret for ${this.oidcClientSecret.clientId}</div>
2901
+ <div class="muted" style="margin:3px 0 10px">Copy this secret now. It cannot be retrieved after this notice is dismissed.</div>
2902
+ ${this.renderCodeBlock(this.oidcClientSecret.clientSecret)}
2903
+ <div class="cell-actions" style="justify-content:flex-end;margin-top:10px">
2904
+ <button class="plain-button outline" @click=${() => this.dispatchShellEvent<IIdpAdminGaAppSecretDismissEventDetail>('idp-admin-ga-app-secret-dismiss', { appId: this.oidcClientSecret!.appId })}>I have stored the secret</button>
2905
+ </div>
2906
+ </div>
2907
+ </div>
2908
+ `
2909
+ : html``}
2617
2910
  <idp-data-table
2618
2911
  title="Platform apps"
2619
2912
  badge=${`${apps.length} total`}
@@ -10,6 +10,7 @@ export interface IIdpSsoConnectionItem {
10
10
  displayName: string;
11
11
  status: 'active' | 'disabled';
12
12
  emailDomains: string[];
13
+ domainVerifications?: IIdpSsoDomainVerificationItem[];
13
14
  entityId: string;
14
15
  ssoUrl: string;
15
16
  certificateCount: number;
@@ -18,6 +19,15 @@ export interface IIdpSsoConnectionItem {
18
19
  groupMappings: { idpGroup: string; orgRoles: string[] }[];
19
20
  }
20
21
 
22
+ export interface IIdpSsoDomainVerificationItem {
23
+ domain: string;
24
+ status: 'pending' | 'verified';
25
+ dnsTxtName: string;
26
+ dnsTxtValue: string;
27
+ lastCheckedAt?: number;
28
+ verifiedAt?: number;
29
+ }
30
+
21
31
  export interface IIdpSsoSpDetails {
22
32
  spEntityId: string;
23
33
  acsUrl: string;
@@ -44,6 +54,11 @@ export interface IIdpSsoDeleteEventDetail {
44
54
  connectionId: string;
45
55
  }
46
56
 
57
+ export interface IIdpSsoVerifyDomainEventDetail {
58
+ connectionId: string;
59
+ domain: string;
60
+ }
61
+
47
62
  declare global {
48
63
  interface HTMLElementTagNameMap {
49
64
  'idp-sso-connection-form': IdpSsoConnectionForm;
@@ -67,6 +82,21 @@ export class IdpSsoConnectionForm extends DeesElement {
67
82
  displayName: 'Okta (acme.com)',
68
83
  status: 'active',
69
84
  emailDomains: ['acme.com', 'acme.io'],
85
+ domainVerifications: [
86
+ {
87
+ domain: 'acme.com',
88
+ status: 'verified',
89
+ dnsTxtName: '_idp-global-verification.acme.com',
90
+ dnsTxtValue: 'idp-global-verification=verified-example',
91
+ verifiedAt: Date.now(),
92
+ },
93
+ {
94
+ domain: 'acme.io',
95
+ status: 'pending',
96
+ dnsTxtName: '_idp-global-verification.acme.io',
97
+ dnsTxtValue: 'idp-global-verification=pending-example',
98
+ },
99
+ ],
70
100
  entityId: 'http://www.okta.com/exk9examples',
71
101
  ssoUrl: 'https://acme.okta.com/app/xyz/sso/saml',
72
102
  certificateCount: 1,
@@ -204,6 +234,40 @@ export class IdpSsoConnectionForm extends DeesElement {
204
234
  gap: 6px;
205
235
  margin-top: 8px;
206
236
  }
237
+ .domain-list {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 8px;
241
+ margin-top: 10px;
242
+ }
243
+ .domain-proof {
244
+ padding: 10px 12px;
245
+ border: 1px solid var(--idp-border);
246
+ border-radius: 9px;
247
+ background: var(--idp-muted);
248
+ }
249
+ .domain-proof-head {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 8px;
253
+ }
254
+ .domain-proof-head .table-action {
255
+ margin-left: auto;
256
+ }
257
+ .dns-record {
258
+ display: grid;
259
+ grid-template-columns: minmax(72px, auto) 1fr;
260
+ gap: 4px 10px;
261
+ margin-top: 8px;
262
+ font-size: 11px;
263
+ }
264
+ .dns-record-key {
265
+ color: var(--idp-muted-fg);
266
+ }
267
+ .dns-record-value {
268
+ font-family: var(--idp-mono, ui-monospace, monospace);
269
+ overflow-wrap: anywhere;
270
+ }
207
271
  .row-actions {
208
272
  display: flex;
209
273
  gap: 6px;
@@ -395,6 +459,7 @@ export class IdpSsoConnectionForm extends DeesElement {
395
459
  }
396
460
 
397
461
  private renderConnectionRow(connectionArg: IIdpSsoConnectionItem): TemplateResult {
462
+ const domainVerifications = connectionArg.domainVerifications;
398
463
  return html`
399
464
  <div class="row">
400
465
  <div class="row-head">
@@ -436,8 +501,53 @@ export class IdpSsoConnectionForm extends DeesElement {
436
501
  </div>
437
502
  </div>
438
503
  <div class="row-meta">${connectionArg.entityId} · ${connectionArg.ssoUrl} · ${connectionArg.certificateCount} cert${connectionArg.certificateCount === 1 ? '' : 's'}</div>
504
+ ${domainVerifications
505
+ ? html`
506
+ <div class="domain-list">
507
+ ${domainVerifications.map((verificationArg) => html`
508
+ <div class="domain-proof">
509
+ <div class="domain-proof-head">
510
+ <idp-badge variant=${verificationArg.status === 'verified' ? 'ok' : 'outline'}>
511
+ @${verificationArg.domain} ${verificationArg.status}
512
+ </idp-badge>
513
+ ${verificationArg.status === 'pending'
514
+ ? html`
515
+ <button
516
+ class="table-action"
517
+ aria-label=${`Verify DNS for ${verificationArg.domain}`}
518
+ @click=${() => this.dispatchEvent(
519
+ new CustomEvent<IIdpSsoVerifyDomainEventDetail>('idp-sso-verify-domain', {
520
+ detail: { connectionId: connectionArg.id, domain: verificationArg.domain },
521
+ bubbles: true,
522
+ composed: true,
523
+ }),
524
+ )}
525
+ >
526
+ Verify DNS
527
+ </button>
528
+ `
529
+ : html``}
530
+ </div>
531
+ ${verificationArg.status === 'pending' && verificationArg.dnsTxtName && verificationArg.dnsTxtValue
532
+ ? html`
533
+ <div class="dns-record">
534
+ <span class="dns-record-key">TXT name</span>
535
+ <span class="dns-record-value">${verificationArg.dnsTxtName}</span>
536
+ <span class="dns-record-key">TXT value</span>
537
+ <span class="dns-record-value">${verificationArg.dnsTxtValue}</span>
538
+ </div>
539
+ `
540
+ : html``}
541
+ </div>
542
+ `)}
543
+ </div>
544
+ `
545
+ : html`
546
+ <div class="chip-row">
547
+ ${connectionArg.emailDomains.map((domainArg) => html`<idp-badge variant="accent">@${domainArg}</idp-badge>`)}
548
+ </div>
549
+ `}
439
550
  <div class="chip-row">
440
- ${connectionArg.emailDomains.map((domainArg) => html`<idp-badge variant="accent">@${domainArg}</idp-badge>`)}
441
551
  ${connectionArg.groupMappings.map(
442
552
  (mappingArg) => html`<idp-badge variant="outline">${mappingArg.idpGroup} → ${mappingArg.orgRoles.join(', ')}</idp-badge>`,
443
553
  )}
@@ -548,7 +658,8 @@ export class IdpSsoConnectionForm extends DeesElement {
548
658
  <div class="title">SAML single sign-on</div>
549
659
  <div class="muted">
550
660
  Let members sign in through your organisation's identity provider. Users entering a
551
- matching email domain are routed to your IdP automatically.
661
+ verified email domain are routed to your IdP automatically. Add the shown DNS TXT
662
+ record and verify ownership before account linking or just-in-time provisioning starts.
552
663
  </div>
553
664
  </div>
554
665
  ${!this.formOpen