@idp.global/catalog 3.0.0 → 3.2.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.
@@ -94,6 +94,12 @@ export interface IIdpAdminGaUserActionEventDetail {
94
94
  action: 'suspend' | 'unsuspend';
95
95
  }
96
96
 
97
+ export interface IIdpAdminProfileUpdateEventDetail {
98
+ name: string;
99
+ username: string;
100
+ mobileNumber: string;
101
+ }
102
+
97
103
  export interface IIdpAdminGaOrgActionEventDetail {
98
104
  orgId: string;
99
105
  action: 'suspend' | 'unsuspend';
@@ -831,13 +837,88 @@ export class IdpAdminShell extends DeesElement {
831
837
  .mapping-row .form-label {
832
838
  margin-bottom: 4px;
833
839
  }
840
+ .mobile-topbar {
841
+ display: none;
842
+ align-items: center;
843
+ gap: 10px;
844
+ padding: 10px 14px;
845
+ border-bottom: 1px solid var(--sh-border);
846
+ background: var(--sh-card);
847
+ position: sticky;
848
+ top: 0;
849
+ z-index: 40;
850
+ }
851
+ .mobile-topbar-title {
852
+ font-size: 13px;
853
+ font-weight: 650;
854
+ letter-spacing: -0.01em;
855
+ }
856
+ .mobile-nav-toggle,
857
+ .mobile-drawer-close {
858
+ display: inline-flex;
859
+ align-items: center;
860
+ justify-content: center;
861
+ width: 34px;
862
+ height: 34px;
863
+ border: 1px solid var(--sh-border);
864
+ border-radius: 9px;
865
+ background: var(--sh-card);
866
+ color: var(--sh-fg);
867
+ cursor: pointer;
868
+ }
869
+ .mobile-nav-toggle:focus-visible,
870
+ .mobile-drawer-close:focus-visible {
871
+ outline: 2px solid var(--sh-accent);
872
+ outline-offset: 2px;
873
+ }
874
+ .mobile-drawer-backdrop {
875
+ position: fixed;
876
+ inset: 0;
877
+ background: rgba(0, 0, 0, 0.42);
878
+ z-index: 60;
879
+ }
880
+ .mobile-drawer {
881
+ position: fixed;
882
+ top: 0;
883
+ left: 0;
884
+ bottom: 0;
885
+ width: min(300px, 86vw);
886
+ background: var(--sh-card);
887
+ border-right: 1px solid var(--sh-border);
888
+ z-index: 61;
889
+ overflow-y: auto;
890
+ animation: drawer-in 0.18s ease-out;
891
+ }
892
+ .mobile-drawer idp-admin-sidebar {
893
+ display: block;
894
+ }
895
+ .mobile-drawer-close {
896
+ position: absolute;
897
+ top: 10px;
898
+ right: 10px;
899
+ z-index: 62;
900
+ }
901
+ @keyframes drawer-in {
902
+ from {
903
+ transform: translateX(-24px);
904
+ opacity: 0;
905
+ }
906
+ to {
907
+ transform: translateX(0);
908
+ opacity: 1;
909
+ }
910
+ }
834
911
  @media (max-width: 1120px) {
835
912
  .shell {
836
913
  grid-template-columns: 1fr;
914
+ grid-template-rows: auto 1fr;
837
915
  }
838
- idp-admin-sidebar {
916
+ .shell > idp-admin-sidebar {
839
917
  display: none;
840
918
  }
919
+ .mobile-topbar {
920
+ display: flex;
921
+ }
841
922
  }
842
923
  @media (max-width: 720px) {
843
924
  .mapping-row {
@@ -922,6 +1003,10 @@ export class IdpAdminShell extends DeesElement {
922
1003
  @property({ type: Array })
923
1004
  public accessor gaUsers: IIdpAdminGaUser[] = [];
924
1005
 
1006
+ /** platform-wide org directory for the ga-orgs page; falls back to orgs when empty */
1007
+ @property({ type: Array })
1008
+ public accessor gaOrgs: IIdpAdminOrg[] = [];
1009
+
925
1010
  @property({ type: Array })
926
1011
  public accessor ssoConnections: IIdpSsoConnectionItem[] = [];
927
1012
 
@@ -943,6 +1028,20 @@ export class IdpAdminShell extends DeesElement {
943
1028
  @property({ type: Array })
944
1029
  public accessor delegations: IIdpDelegation[] = [];
945
1030
 
1031
+ /** enables editing of name/username/mobile on the profile page */
1032
+ @property({ type: Boolean })
1033
+ public accessor profileEditable = false;
1034
+
1035
+ /** host sets this while a profile update request is in flight */
1036
+ @property({ type: Boolean })
1037
+ public accessor profileSaving = false;
1038
+
1039
+ @state()
1040
+ private accessor profileDraft: { name: string; username: string; mobileNumber: string } | null = null;
1041
+
1042
+ @state()
1043
+ private accessor mobileNavOpen = false;
1044
+
946
1045
  @state()
947
1046
  private accessor logoutEverywhere = false;
948
1047
 
@@ -1336,6 +1435,22 @@ export class IdpAdminShell extends DeesElement {
1336
1435
  await super.disconnectedCallback();
1337
1436
  }
1338
1437
 
1438
+ protected override willUpdate(changedProperties: Map<PropertyKey, unknown>) {
1439
+ super.willUpdate(changedProperties);
1440
+ // a refreshed user means the host persisted (or reloaded) the profile
1441
+ if (changedProperties.has('user')) {
1442
+ this.profileDraft = null;
1443
+ }
1444
+ }
1445
+
1446
+ protected override updated(changedProperties: Map<PropertyKey, unknown>) {
1447
+ super.updated(changedProperties);
1448
+ if (changedProperties.has('mobileNavOpen') && this.mobileNavOpen) {
1449
+ const closeButton = this.shadowRoot?.querySelector('.mobile-drawer-close') as HTMLElement | null;
1450
+ closeButton?.focus();
1451
+ }
1452
+ }
1453
+
1339
1454
  private dispatchShellEvent<TDetail>(eventNameArg: string, detailArg: TDetail) {
1340
1455
  this.dispatchEvent(new CustomEvent<TDetail>(eventNameArg, {
1341
1456
  detail: detailArg,
@@ -1778,18 +1893,54 @@ export class IdpAdminShell extends DeesElement {
1778
1893
  `;
1779
1894
  }
1780
1895
 
1896
+ private getProfileDraft() {
1897
+ return (
1898
+ this.profileDraft || {
1899
+ name: this.user?.name || '',
1900
+ username: this.user?.username || this.user?.email?.split('@')[0] || '',
1901
+ mobileNumber: this.user?.mobileNumber || '',
1902
+ }
1903
+ );
1904
+ }
1905
+
1906
+ private setProfileDraftField(fieldArg: 'name' | 'username' | 'mobileNumber', eventArg: Event) {
1907
+ const value = (eventArg.target as HTMLInputElement).value;
1908
+ this.profileDraft = { ...this.getProfileDraft(), [fieldArg]: value };
1909
+ }
1910
+
1911
+ private handleProfileSave() {
1912
+ const draft = this.getProfileDraft();
1913
+ this.dispatchShellEvent<IIdpAdminProfileUpdateEventDetail>('idp-admin-profile-update', {
1914
+ name: draft.name.trim(),
1915
+ username: draft.username.trim(),
1916
+ mobileNumber: draft.mobileNumber.trim(),
1917
+ });
1918
+ }
1919
+
1781
1920
  private renderProfile(): TemplateResult {
1782
- const username = this.user?.username || this.user?.email?.split('@')[0] || '';
1783
1921
  const status = this.user?.status || 'active';
1922
+ const draft = this.getProfileDraft();
1923
+ const editable = this.profileEditable;
1924
+ const dirty =
1925
+ this.profileDraft !== null &&
1926
+ (draft.name !== (this.user?.name || '') ||
1927
+ draft.username !== (this.user?.username || this.user?.email?.split('@')[0] || '') ||
1928
+ draft.mobileNumber !== (this.user?.mobileNumber || ''));
1784
1929
  return html`
1785
1930
  ${this.renderPageHeader('Profile', 'Your personal identity details visible to connected apps.')}
1786
1931
  <div class="body narrow-body">
1787
- ${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`<div class="split-row" style="justify-content:flex-start"><span class="avatar" style="width:56px;height:56px;font-size:20px">${this.userInitials}</span><div><div class="section-title">${this.user?.name || 'Unknown User'}</div><div class="muted" style="margin-top:5px">Profile update endpoints are not exposed in this console yet.</div></div></div>`)}
1932
+ ${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`<div class="split-row" style="justify-content:flex-start"><span class="avatar" style="width:56px;height:56px;font-size:20px">${this.userInitials}</span><div><div class="section-title">${this.user?.name || 'Unknown User'}</div><div class="muted" style="margin-top:5px">${this.user?.email || ''}</div></div></div>`)}
1788
1933
  ${this.renderSectionCard('Personal information', '', html`
1789
- ${this.renderFormRow('Full name', '', html`<input class="input" .value=${this.user?.name || ''} disabled />`, true)}
1790
- ${this.renderFormRow('Username', 'Used in your public profile URL', html`<div class="input-group"><span class="input-prefix">idp.global/user/</span><input class="input" .value=${username} disabled /></div>`)}
1791
- ${this.renderFormRow('Email', 'Primary address for login and notifications', html`<input class="input" .value=${this.user?.email || ''} disabled />`)}
1792
- ${this.renderFormRow('Mobile number', 'Used for SMS verification', html`<input class="input" .value=${this.user?.mobileNumber || ''} disabled />`)}
1934
+ ${this.renderFormRow('Full name', '', html`<input class="input" .value=${draft.name} ?disabled=${!editable || this.profileSaving} @input=${(eventArg: Event) => this.setProfileDraftField('name', eventArg)} />`, true)}
1935
+ ${this.renderFormRow('Username', 'Used in your public profile URL', html`<div class="input-group"><span class="input-prefix">idp.global/user/</span><input class="input" .value=${draft.username} ?disabled=${!editable || this.profileSaving} @input=${(eventArg: Event) => this.setProfileDraftField('username', eventArg)} /></div>`)}
1936
+ ${this.renderFormRow('Email', 'Primary address for login and notifications - contact support to change it', html`<input class="input" .value=${this.user?.email || ''} disabled />`)}
1937
+ ${this.renderFormRow('Mobile number', 'Optional contact number shown to connected apps', html`<input class="input" .value=${draft.mobileNumber} ?disabled=${!editable || this.profileSaving} @input=${(eventArg: Event) => this.setProfileDraftField('mobileNumber', eventArg)} />`)}
1938
+ ${editable
1939
+ ? html`<div class="split-row" style="justify-content:flex-end;gap:10px;margin-top:14px">
1940
+ <idp-button variant="outline" shape="rect" ?disabled=${!dirty || this.profileSaving} @click=${() => { this.profileDraft = null; }}>Reset</idp-button>
1941
+ <idp-button variant="accent" shape="rect" ?loading=${this.profileSaving} ?disabled=${!dirty} @click=${this.handleProfileSave}>Save changes</idp-button>
1942
+ </div>`
1943
+ : html``}
1793
1944
  `)}
1794
1945
  ${this.renderSectionCard('Account status', '', html`<div class="split-row"><div><div class="section-title">Status</div><div class="muted">Your account is currently ${status}.</div></div><idp-badge variant=${status === 'active' ? 'ok' : status === 'suspended' ? 'error' : 'warn'}>${status}</idp-badge></div>${this.globalAdmin ? html`<div class="divider"></div><div class="split-row"><div><div class="section-title">Global admin</div><div class="muted">You have platform-wide administrative access.</div></div><idp-badge variant="accent">Admin</idp-badge></div>` : html``}`)}
1795
1946
  ${this.delegationApps.length
@@ -2328,8 +2479,10 @@ export class IdpAdminShell extends DeesElement {
2328
2479
  private renderGAOrgs(): TemplateResult {
2329
2480
  const planVariant = (planArg: string): 'accent' | 'ok' | 'warn' | 'default' =>
2330
2481
  planArg === 'Pro' ? 'accent' : planArg === 'Enterprise' ? 'ok' : planArg === 'Internal' ? 'warn' : 'default';
2331
- const hasAdminFields = this.orgs.some((orgArg) => orgArg.status || orgArg.plan);
2332
- const orgRows = this.orgs.map((orgArg) => ({
2482
+ const directoryOrgs = this.gaOrgs.length ? this.gaOrgs : this.orgs;
2483
+ const hasAdminFields = directoryOrgs.some((orgArg) => orgArg.status || orgArg.plan);
2484
+ const hasMemberCounts = directoryOrgs.some((orgArg) => orgArg.memberCount !== undefined);
2485
+ const orgRows = directoryOrgs.map((orgArg) => ({
2333
2486
  cells: [
2334
2487
  html`
2335
2488
  <div class="identity-cell">
@@ -2341,9 +2494,9 @@ export class IdpAdminShell extends DeesElement {
2341
2494
  </div>
2342
2495
  `,
2343
2496
  orgArg.slug || '-',
2497
+ ...(hasMemberCounts ? [orgArg.memberCount !== undefined ? String(orgArg.memberCount) : '-'] : []),
2344
2498
  ...(hasAdminFields
2345
2499
  ? [
2346
- orgArg.memberCount !== undefined ? String(orgArg.memberCount) : '-',
2347
2500
  orgArg.plan ? html`<idp-badge variant=${planVariant(orgArg.plan)}>${orgArg.plan}</idp-badge>` : '-',
2348
2501
  html`<idp-badge variant=${orgArg.status === 'suspended' ? 'error' : 'ok'}>${orgArg.status || 'active'}</idp-badge>`,
2349
2502
  html`<button class="plain-button ghost" style="font-size:11px" @click=${() => this.dispatchShellEvent<IIdpAdminGaOrgActionEventDetail>('idp-admin-ga-org-action', { orgId: orgArg.id, action: orgArg.status === 'suspended' ? 'unsuspend' : 'suspend' })}>${orgArg.status === 'suspended' ? 'Unsuspend' : 'Suspend'}</button>`,
@@ -2356,28 +2509,24 @@ export class IdpAdminShell extends DeesElement {
2356
2509
  }));
2357
2510
 
2358
2511
  return html`
2359
- ${this.renderPageHeader('All Organisations', `${this.orgs.length} organisations visible to this admin session`)}
2512
+ ${this.renderPageHeader('All Organisations', `${directoryOrgs.length} organisations on the platform`)}
2360
2513
  <div class="body wide-body">
2361
2514
  <idp-data-table
2362
2515
  title="Organisations"
2363
- badge=${`${this.orgs.length} total`}
2516
+ badge=${`${directoryOrgs.length} total`}
2364
2517
  empty-title="No organisations"
2365
2518
  empty-description="No organisations are visible to this admin session."
2366
- .columns=${hasAdminFields
2367
- ? [
2368
- { label: 'Organisation' },
2369
- { label: 'Slug', mono: true, hideBelow: 'mobile' },
2370
- { label: 'Members', mono: true, hideBelow: 'mobile' },
2371
- { label: 'Plan' },
2372
- { label: 'Status' },
2373
- { label: '' },
2374
- ]
2375
- : [
2376
- { label: 'Organisation' },
2377
- { label: 'Slug', mono: true, hideBelow: 'mobile' },
2378
- { label: 'Role' },
2379
- { label: 'Identifier', mono: true, hideBelow: 'tablet' },
2380
- ]}
2519
+ .columns=${[
2520
+ { label: 'Organisation' },
2521
+ { label: 'Slug', mono: true, hideBelow: 'mobile' },
2522
+ ...(hasMemberCounts ? [{ label: 'Members', mono: true, hideBelow: 'mobile' }] : []),
2523
+ ...(hasAdminFields
2524
+ ? [{ label: 'Plan' }, { label: 'Status' }, { label: '' }]
2525
+ : [
2526
+ { label: 'Role' },
2527
+ { label: 'Identifier', mono: true, hideBelow: 'tablet' },
2528
+ ]),
2529
+ ]}
2381
2530
  .rows=${orgRows}
2382
2531
  ></idp-data-table>
2383
2532
  </div>
@@ -2454,15 +2603,27 @@ export class IdpAdminShell extends DeesElement {
2454
2603
  return (renderers[this.page] || renderers.overview)();
2455
2604
  }
2456
2605
 
2606
+ private handleMobileNavKeydown(eventArg: KeyboardEvent) {
2607
+ if (eventArg.key === 'Escape') {
2608
+ this.mobileNavOpen = false;
2609
+ }
2610
+ }
2611
+
2457
2612
  public render(): TemplateResult {
2458
2613
  return html`
2459
2614
  <div
2460
2615
  class="shell"
2461
- @idp-admin-navigate=${this.handleSidebarNavigate}
2616
+ @idp-admin-navigate=${(eventArg: CustomEvent) => { this.mobileNavOpen = false; this.handleSidebarNavigate(eventArg as CustomEvent<IIdpAdminNavigateEventDetail>); }}
2462
2617
  @idp-admin-org-select=${this.handleSidebarOrgSelect}
2463
2618
  @idp-admin-org-create-request=${this.requestOrgCreate}
2464
2619
  @idp-admin-logout-request=${this.requestLogout}
2465
2620
  >
2621
+ <div class="mobile-topbar">
2622
+ <button class="mobile-nav-toggle" aria-label="Open navigation" aria-haspopup="dialog" aria-expanded=${this.mobileNavOpen} @click=${() => { this.mobileNavOpen = true; }}>
2623
+ <idp-icon name="lucide:Menu" size="17"></idp-icon>
2624
+ </button>
2625
+ <span class="mobile-topbar-title">idp.global</span>
2626
+ </div>
2466
2627
  <idp-admin-sidebar
2467
2628
  .page=${this.page}
2468
2629
  .user=${this.user}
@@ -2472,12 +2633,31 @@ export class IdpAdminShell extends DeesElement {
2472
2633
  .globalAdmin=${this.globalAdmin}
2473
2634
  .sessionCount=${this.sessions.length}
2474
2635
  ></idp-admin-sidebar>
2636
+ ${this.mobileNavOpen
2637
+ ? html`
2638
+ <div class="mobile-drawer-backdrop" @click=${() => { this.mobileNavOpen = false; }}></div>
2639
+ <div class="mobile-drawer" role="dialog" aria-label="Navigation" @keydown=${this.handleMobileNavKeydown}>
2640
+ <button class="mobile-drawer-close" aria-label="Close navigation" @click=${() => { this.mobileNavOpen = false; }}>
2641
+ <idp-icon name="x" size="16"></idp-icon>
2642
+ </button>
2643
+ <idp-admin-sidebar
2644
+ .page=${this.page}
2645
+ .user=${this.user}
2646
+ .accounts=${this.accounts}
2647
+ .orgs=${this.orgs}
2648
+ .selectedOrgId=${this.selectedOrgId}
2649
+ .globalAdmin=${this.globalAdmin}
2650
+ .sessionCount=${this.sessions.length}
2651
+ ></idp-admin-sidebar>
2652
+ </div>
2653
+ `
2654
+ : html``}
2475
2655
  <main>${this.renderMainContent()}</main>
2476
2656
  </div>
2477
2657
  ${this.renderDialog()}
2478
2658
  ${this.toast
2479
2659
  ? html`
2480
- <div class="toast ${this.toast.tone}">
2660
+ <div class="toast ${this.toast.tone}" role="status" aria-live="polite">
2481
2661
  <idp-icon name=${this.toast.tone === 'ok' ? 'check' : 'alert'} size="14"></idp-icon>
2482
2662
  <span style="flex:1">${this.toast.message}</span>
2483
2663
  </div>
@@ -535,7 +535,7 @@ export class IdpAdminSidebar extends DeesElement {
535
535
  return html`
536
536
  <div class="nav-list">
537
537
  ${items.map((item) => html`
538
- <button class="nav-item ${item.id === active ? 'active' : ''}" @click=${() => this.setPage(item.id)}>
538
+ <button class="nav-item ${item.id === active ? 'active' : ''}" aria-current=${item.id === active ? 'page' : 'false'} @click=${() => this.setPage(item.id)}>
539
539
  <idp-icon name=${item.icon as any} size="14"></idp-icon>
540
540
  ${item.label}
541
541
  ${item.badge ? html`<idp-badge variant="accent" style="margin-left:auto">${item.badge}</idp-badge>` : html``}
@@ -604,13 +604,13 @@ export class IdpAdminSidebar extends DeesElement {
604
604
  <span class="logo-text">idp.global</span>
605
605
  </div>
606
606
  </div>
607
- <div class="nav-wrap">
607
+ <nav class="nav-wrap" aria-label="Console navigation">
608
608
  <div class="nav-section"><div class="nav-title">Workspace</div>${this.renderNavGroup(this.workspaceNav, this.page)}</div>
609
609
  <div class="nav-section"><div class="nav-title">My Account</div>${this.renderNavGroup(this.accountNav, this.page)}</div>
610
610
  <div class="nav-section">
611
611
  <div class="nav-title">Organisation</div>
612
612
  <div class="org-switch-wrap">
613
- <button class="org-switch ${this.orgMenuOpen ? 'open' : ''}" @click=${() => { this.orgMenuOpen = !this.orgMenuOpen; }}>
613
+ <button class="org-switch ${this.orgMenuOpen ? 'open' : ''}" aria-haspopup="menu" aria-expanded=${this.orgMenuOpen} @click=${() => { this.orgMenuOpen = !this.orgMenuOpen; }}>
614
614
  <span class="org-avatar">${currentOrg.name.slice(0, 2).toUpperCase()}</span>
615
615
  <span class="org-name">${currentOrg.name}</span>
616
616
  <idp-icon name="chevron-down" size="10"></idp-icon>
@@ -634,7 +634,7 @@ export class IdpAdminSidebar extends DeesElement {
634
634
  </div>
635
635
  <div class="nav-section"><div class="nav-title">Support</div>${this.renderNavGroup(this.supportNav, this.page)}</div>
636
636
  ${this.globalAdmin ? html`<div class="nav-section"><div class="nav-title">Global Admin</div>${this.renderNavGroup(this.adminNav, this.page)}</div>` : html``}
637
- </div>
637
+ </nav>
638
638
  <div class="user-footer">
639
639
  <button class="user-trigger ${this.userMenuOpen ? 'open' : ''}" aria-haspopup="menu" aria-expanded=${this.userMenuOpen} @click=${() => { this.userMenuOpen = !this.userMenuOpen; }}>
640
640
  <span class="user-avatar">${this.userInitials}</span>
@@ -0,0 +1,229 @@
1
+ import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element';
2
+ import { idpElementStyles } from './tokens.js';
3
+ import './idp-auth-card.js';
4
+ import './idp-option-row.js';
5
+ import './idp-status-icon.js';
6
+ import './idp-button.js';
7
+ import './idp-spinner.js';
8
+
9
+ export type TIdpInviteState =
10
+ | 'loading'
11
+ | 'valid'
12
+ | 'accepted'
13
+ | 'expired'
14
+ | 'cancelled'
15
+ | 'invalid'
16
+ | 'email-mismatch';
17
+
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ 'idp-invite-card': IdpInviteCard;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Invitation landing card. Purely presentational: the host loads the
26
+ * invitation, decides the state, and reacts to the events
27
+ * idp-invite-accept, idp-invite-signin, idp-invite-register,
28
+ * idp-invite-switch-account and idp-invite-continue.
29
+ */
30
+ @customElement('idp-invite-card')
31
+ export class IdpInviteCard extends DeesElement {
32
+ public static demo = () => html`
33
+ <div style="background: var(--sh-bg, #f5f5f7); padding: 40px; display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
34
+ <idp-invite-card state="valid" email="alex@lossless.com" .orgNames=${['Lossless GmbH']} .roles=${['member']} signedIn></idp-invite-card>
35
+ <idp-invite-card state="valid" email="alex@lossless.com" .orgNames=${['Lossless GmbH']}></idp-invite-card>
36
+ <idp-invite-card state="email-mismatch" email="alex@lossless.com" signedIn signedInEmail="sam@other.io"></idp-invite-card>
37
+ <idp-invite-card state="expired" .orgNames=${['Lossless GmbH']}></idp-invite-card>
38
+ <idp-invite-card state="accepted" .orgNames=${['Lossless GmbH']}></idp-invite-card>
39
+ <idp-invite-card state="invalid"></idp-invite-card>
40
+ </div>
41
+ `;
42
+ public static demoGroups = ['idp.global v5 auth'];
43
+
44
+ @property({ type: String, reflect: true })
45
+ public accessor state: TIdpInviteState = 'loading';
46
+
47
+ /** email address the invitation was issued to */
48
+ @property({ type: String })
49
+ public accessor email = '';
50
+
51
+ /** names of the organizations the invitation grants membership in */
52
+ @property({ type: Array })
53
+ public accessor orgNames: string[] = [];
54
+
55
+ /** roles granted on acceptance */
56
+ @property({ type: Array })
57
+ public accessor roles: string[] = [];
58
+
59
+ /** whether a user session exists */
60
+ @property({ type: Boolean })
61
+ public accessor signedIn = false;
62
+
63
+ /** email of the currently signed-in user (shown on email-mismatch) */
64
+ @property({ type: String })
65
+ public accessor signedInEmail = '';
66
+
67
+ /** disables actions while the host performs a request */
68
+ @property({ type: Boolean })
69
+ public accessor busy = false;
70
+
71
+ public static styles = [
72
+ ...idpElementStyles,
73
+ css`
74
+ :host {
75
+ display: block;
76
+ font-family: var(--idp-font);
77
+ }
78
+ .status-block {
79
+ display: flex;
80
+ flex-direction: column;
81
+ align-items: center;
82
+ gap: 14px;
83
+ padding: 18px 0 6px;
84
+ text-align: center;
85
+ }
86
+ .status-text {
87
+ font-size: 13px;
88
+ color: var(--idp-fg-2);
89
+ line-height: 1.5;
90
+ max-width: 280px;
91
+ }
92
+ .detail-rows {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 10px;
96
+ margin-top: 16px;
97
+ }
98
+ .actions {
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: 10px;
102
+ margin-top: 18px;
103
+ }
104
+ .loading-block {
105
+ display: flex;
106
+ justify-content: center;
107
+ padding: 34px 0;
108
+ }
109
+ `,
110
+ ];
111
+
112
+ private get orgLabel(): string {
113
+ if (!this.orgNames.length) return 'an organisation';
114
+ if (this.orgNames.length === 1) return this.orgNames[0];
115
+ return `${this.orgNames[0]} and ${this.orgNames.length - 1} more`;
116
+ }
117
+
118
+ public render(): TemplateResult {
119
+ const renderers: Record<TIdpInviteState, () => { heading: string; description: string; body: TemplateResult }> = {
120
+ loading: () => ({
121
+ heading: 'Checking your invitation…',
122
+ description: '',
123
+ body: html`<div class="loading-block" role="status" aria-label="Loading invitation"><idp-spinner size="30"></idp-spinner></div>`,
124
+ }),
125
+ valid: () => ({
126
+ heading: `Join ${this.orgLabel}`,
127
+ description: `You've been invited to join ${this.orgLabel} on idp.global.`,
128
+ body: html`
129
+ <div class="detail-rows">
130
+ <idp-option-row interactive="false" tileTone="accent" tileSize="30" icon="mail" label="Invitation for" description=${this.email}></idp-option-row>
131
+ ${this.roles.length
132
+ ? html`<idp-option-row interactive="false" tileTone="accent" tileSize="30" icon="shield" label="You'll join as" description=${this.roles.join(', ')}></idp-option-row>`
133
+ : html``}
134
+ </div>
135
+ <div class="actions">
136
+ ${this.signedIn
137
+ ? html`<idp-button variant="accent" shape="rect" block ?loading=${this.busy} @click=${() => this.emit('idp-invite-accept')}>Accept invitation</idp-button>`
138
+ : html`
139
+ <idp-button variant="accent" shape="rect" block ?disabled=${this.busy} @click=${() => this.emit('idp-invite-signin')}>Sign in to accept</idp-button>
140
+ <idp-button variant="outline" shape="rect" block ?disabled=${this.busy} @click=${() => this.emit('idp-invite-register')}>Create an account</idp-button>
141
+ `}
142
+ </div>
143
+ `,
144
+ }),
145
+ accepted: () => ({
146
+ heading: `Welcome to ${this.orgLabel}`,
147
+ description: '',
148
+ body: html`
149
+ <div class="status-block">
150
+ <idp-status-icon tone="ok" icon="check"></idp-status-icon>
151
+ <div class="status-text">Your invitation has been accepted. You're now a member of ${this.orgLabel}.</div>
152
+ </div>
153
+ <div class="actions">
154
+ <idp-button variant="accent" shape="rect" block @click=${() => this.emit('idp-invite-continue')}>Continue to dashboard</idp-button>
155
+ </div>
156
+ `,
157
+ }),
158
+ expired: () => ({
159
+ heading: 'Invitation expired',
160
+ description: '',
161
+ body: html`
162
+ <div class="status-block">
163
+ <idp-status-icon tone="warn" icon="clock"></idp-status-icon>
164
+ <div class="status-text">This invitation to ${this.orgLabel} is no longer valid. Ask an organisation admin to send you a new one.</div>
165
+ </div>
166
+ <div class="actions">
167
+ <idp-button variant="outline" shape="rect" block @click=${() => this.emit('idp-invite-continue')}>Go to idp.global</idp-button>
168
+ </div>
169
+ `,
170
+ }),
171
+ cancelled: () => ({
172
+ heading: 'Invitation withdrawn',
173
+ description: '',
174
+ body: html`
175
+ <div class="status-block">
176
+ <idp-status-icon tone="warn" icon="ban"></idp-status-icon>
177
+ <div class="status-text">This invitation was withdrawn by the organisation. Ask an organisation admin to send you a new one if you believe this is a mistake.</div>
178
+ </div>
179
+ <div class="actions">
180
+ <idp-button variant="outline" shape="rect" block @click=${() => this.emit('idp-invite-continue')}>Go to idp.global</idp-button>
181
+ </div>
182
+ `,
183
+ }),
184
+ invalid: () => ({
185
+ heading: 'Invitation not found',
186
+ description: '',
187
+ body: html`
188
+ <div class="status-block">
189
+ <idp-status-icon tone="error" icon="x"></idp-status-icon>
190
+ <div class="status-text">This invitation link is invalid or was already used. Check that you opened the full link from your email.</div>
191
+ </div>
192
+ <div class="actions">
193
+ <idp-button variant="outline" shape="rect" block @click=${() => this.emit('idp-invite-continue')}>Go to idp.global</idp-button>
194
+ </div>
195
+ `,
196
+ }),
197
+ 'email-mismatch': () => ({
198
+ heading: 'Different account signed in',
199
+ description: '',
200
+ body: html`
201
+ <div class="status-block">
202
+ <idp-status-icon tone="warn" icon="user-x"></idp-status-icon>
203
+ <div class="status-text">
204
+ This invitation was sent to <strong>${this.email}</strong>, but you're signed in as
205
+ <strong>${this.signedInEmail}</strong>. Switch accounts to accept it.
206
+ </div>
207
+ </div>
208
+ <div class="actions">
209
+ <idp-button variant="accent" shape="rect" block ?disabled=${this.busy} @click=${() => this.emit('idp-invite-switch-account')}>Switch account</idp-button>
210
+ <idp-button variant="outline" shape="rect" block ?disabled=${this.busy} @click=${() => this.emit('idp-invite-continue')}>Not now</idp-button>
211
+ </div>
212
+ `,
213
+ }),
214
+ };
215
+ const view = renderers[this.state]();
216
+ return html`
217
+ <idp-auth-card heading=${view.heading} description=${view.description}>
218
+ ${view.body}
219
+ ${this.signedIn && this.signedInEmail && this.state === 'valid'
220
+ ? html`<span slot="footer">Signed in as ${this.signedInEmail}</span>`
221
+ : html``}
222
+ </idp-auth-card>
223
+ `;
224
+ }
225
+
226
+ private emit(eventName: string) {
227
+ this.dispatchEvent(new CustomEvent(eventName, { bubbles: true, composed: true }));
228
+ }
229
+ }
@@ -22,6 +22,7 @@ export * from './idp-signed-in-pill.js';
22
22
  export * from './idp-app-pair.js';
23
23
  export * from './idp-auth-card.js';
24
24
  export * from './idp-consent-card.js';
25
+ export * from './idp-invite-card.js';
25
26
  export * from './idp-approval-card.js';
26
27
  export * from './idp-mobile-frame.js';
27
28
  export * from './idp-inbox-preview.js';