From 3992adbafa07e9fb299e1869bbc718da9efbb575 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 15:35:37 +0000 Subject: [PATCH] feat(catalog): add admin dashboard components --- package.json | 6 +- pnpm-lock.yaml | 14 +- readme.md | 75 +- ts_web/elements/idp-admin-shell.ts | 2107 +++++++++++++++++------ ts_web/elements/idp-button.ts | 29 +- ts_web/elements/idp-checkbox.ts | 143 ++ ts_web/elements/idp-dashboard-window.ts | 105 +- ts_web/elements/idp-data-table.ts | 407 +++++ ts_web/elements/idp-form-submit.ts | 65 + ts_web/elements/idp-form.ts | 159 ++ ts_web/elements/idp-input.ts | 56 +- ts_web/elements/idp-landing-hero.ts | 24 +- ts_web/elements/idp-landing-page.ts | 25 +- ts_web/elements/idp-mobile-showcase.ts | 54 +- ts_web/elements/idp-select.ts | 119 ++ ts_web/elements/index.ts | 5 + ts_web/elements/tokens.ts | 241 ++- 17 files changed, 2832 insertions(+), 802 deletions(-) create mode 100644 ts_web/elements/idp-checkbox.ts create mode 100644 ts_web/elements/idp-data-table.ts create mode 100644 ts_web/elements/idp-form-submit.ts create mode 100644 ts_web/elements/idp-form.ts create mode 100644 ts_web/elements/idp-select.ts diff --git a/package.json b/package.json index 2e4f7cb..714c432 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,15 @@ "license": "MIT", "dependencies": { "@design.estate/dees-catalog": "^3.81.0", - "@design.estate/dees-domtools": "^2.5.4", + "@design.estate/dees-domtools": "^2.5.6", "@design.estate/dees-element": "^2.2.4", "@design.estate/dees-wcctools": "^3.9.0", "lucide": "^1.14.0" }, "devDependencies": { "@git.zone/tsbuild": "^4.4.0", - "@git.zone/tsbundle": "^2.10.0", - "@git.zone/tswatch": "^3.3.2", + "@git.zone/tsbundle": "^2.10.1", + "@git.zone/tswatch": "^3.3.3", "@push.rocks/projectinfo": "^5.1.0", "@types/node": "^25.6.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ff9dc..d9be567 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ importers: specifier: ^3.81.0 version: 3.81.0(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': - specifier: ^2.5.4 + specifier: ^2.5.6 version: 2.5.6 '@design.estate/dees-element': specifier: ^2.2.4 @@ -28,10 +28,10 @@ importers: specifier: ^4.4.0 version: 4.4.0 '@git.zone/tsbundle': - specifier: ^2.10.0 + specifier: ^2.10.1 version: 2.10.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@git.zone/tswatch': - specifier: ^3.3.2 + specifier: ^3.3.3 version: 3.3.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@tiptap/pm@2.27.2) '@push.rocks/projectinfo': specifier: ^5.1.0 @@ -228,8 +228,8 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@cloudflare/workers-types@4.20260502.1': - resolution: {integrity: sha512-gttFwGL0pYBF5nA2GIazKTVjDqXLnqWa/Mstd5aGTZyzkhmPy0ej3L2sIn2h8kAbF6I+XGK0P4UXvlmnuxefYg==} + '@cloudflare/workers-types@4.20260503.1': + resolution: {integrity: sha512-8VKtafR4fNMtddutOnam3yq3AQvrl9bzuMio3B3AEAfrdx7xaaDV0Oyxz54P07lODwX0jydukGLC1rpDdYXAAA==} '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} @@ -2830,7 +2830,7 @@ snapshots: '@api.global/typedrequest': 3.3.0 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.4) - '@cloudflare/workers-types': 4.20260502.1 + '@cloudflare/workers-types': 4.20260503.1 '@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.4.1 @@ -3337,7 +3337,7 @@ snapshots: '@cfworker/json-schema@4.1.1': {} - '@cloudflare/workers-types@4.20260502.1': {} + '@cloudflare/workers-types@4.20260503.1': {} '@configvault.io/interfaces@1.0.17': dependencies: diff --git a/readme.md b/readme.md index 1d876d1..6aa27af 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,12 @@ # @idp.global/catalog -Web component catalog for idp.global, based on the v2 product design language. +Web component catalog for idp.global, based on the v3 product design language. -The catalog converts the `idp.global v2.zip` design studies into reusable custom elements for landing pages, approval surfaces, account/admin shells, and mobile-style identity flows. +This package turns the current identity product direction into reusable custom elements for landing pages, approval surfaces, account/admin shells, mobile passport previews, and full-page product showcases. + +## Issue Reporting and Security + +For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. ## Install @@ -12,32 +16,50 @@ pnpm add @idp.global/catalog ## Usage +Import the package once in your frontend entrypoint so the custom elements are registered. + ```ts import '@idp.global/catalog'; ``` +Then compose the elements in HTML, Lit, or any framework that can render custom elements. + ```html + ``` -## Elements +## Element Surface -- `idp-button` for v2 button variants. -- `idp-badge` for small status labels. -- `idp-card` for bordered shadcn-style content containers. -- `idp-input` for text/password/email inputs. -- `idp-toggle` for compact boolean controls. -- `idp-icon` for bundled monochrome identity icons. -- `idp-approval-card` for approve/deny request cards. -- `idp-inbox-preview` for mobile approval inbox previews. -- `idp-mobile-frame` for iOS-style product screenshots. -- `idp-admin-shell` for account/org admin layout previews. -- `idp-landing-hero` for the public landing hero section. +| Element | Purpose | +| --- | --- | +| `idp-button` | Product button variants | +| `idp-badge` | Small status labels and pills | +| `idp-card` | Bordered content containers | +| `idp-input` | Text, password, and email inputs | +| `idp-checkbox` | Required consent and boolean inputs | +| `idp-form` | Form composition with validation, status, and `idp-submit` events | +| `idp-form-submit` | Full-width catalog submit action for `idp-form` | +| `idp-select` | Select/dropdown controls with `idp-select` events | +| `idp-toggle` | Compact boolean controls | +| `idp-icon` | Bundled monochrome identity icons | +| `idp-approval-card` | Approve/deny identity request cards | +| `idp-inbox-preview` | Mobile approval inbox previews | +| `idp-mobile-frame` | iOS-style product screenshots | +| `idp-dashboard-window` | Desktop dashboard preview window | +| `idp-admin-shell` | Account and organization admin layout preview | +| `idp-landing-hero` | Public landing hero section | +| `idp-landing-page` | Full public landing page composition | +| `idp-mobile-showcase` | Mobile, watch, tablet, and desktop showcase composition | + +## Design Language + +The catalog uses shared tokens from `ts_web/elements/tokens.ts` for color, type, spacing, borders, and product chrome. The visual system is intentionally product-facing: dark identity surfaces, sharp admin panels, mobile passport cards, and high-contrast approval states. ## Scope -This package is component-only. It does not perform authentication, store sessions, or call the idp.global backend. +This package is component-only. It does not perform authentication, store sessions, call the idp.global backend, or implement business logic. Use it together with `@idp.global/client` and `@idp.global/interfaces` when you need live identity behavior. ## Development @@ -46,3 +68,26 @@ pnpm install pnpm run build pnpm test ``` + +The source entrypoint is `ts_web/index.ts`; it exports the element catalog from `ts_web/elements/index.ts`. + +## License and Legal Information + +This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file. + +**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. + +### Trademarks + +This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein. + +Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar. + +### Company Information + +Task Venture Capital GmbH +Registered at District Court Bremen HRB 35230 HB, Germany + +For any legal inquiries or further information, please contact us via email at hello@task.vc. + +By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. diff --git a/ts_web/elements/idp-admin-shell.ts b/ts_web/elements/idp-admin-shell.ts index 67a71f2..ce6c578 100644 --- a/ts_web/elements/idp-admin-shell.ts +++ b/ts_web/elements/idp-admin-shell.ts @@ -2,6 +2,7 @@ import { DeesElement, html, property, state, customElement, css, type TemplateRe import { idpElementStyles } from './tokens.js'; import './idp-badge.js'; import './idp-icon.js'; +import './idp-data-table.js'; declare global { interface HTMLElementTagNameMap { @@ -10,7 +11,7 @@ declare global { } type TNavItem = { - id: string; + id: TIdpAdminPage; label: string; icon: string; badge?: string; @@ -28,31 +29,14 @@ type TKpi = { sparkColor: string; }; -type TApproval = { - user: string; - email: string; - hue: string; - action: string; - device: string; - status: 'ok' | 'warn' | 'error' | 'accent'; - label: string; - when: string; -}; - -type TFeedItem = { - dot: 'bl' | 'ok' | 'wn'; - title: string; - detail: string; - meta: string; -}; - -type TAdminPage = +export type TIdpAdminPage = | 'overview' | 'profile' | 'security' | 'sessions' | 'apps' | 'org-general' + | 'org-settings' | 'org-members' | 'org-apps' | 'support' @@ -60,19 +44,202 @@ type TAdminPage = | 'ga-orgs' | 'ga-apps'; -type TOrg = { +export interface IIdpAdminUser { + name: string; + email: string; + initials?: string; + username?: string; + mobileNumber?: string; + status?: string; +} + +export interface IIdpAdminOrg { id: string; name: string; slug: string; - myRole: string; -}; + myRole?: string; +} -type TConnectedApp = [name: string, scopes: string[], meta: string]; -type TOAuthApp = [name: string, clientId: string, grants: string[], description: string]; +export interface IIdpAdminNavigateEventDetail { + page: TIdpAdminPage; +} + +export interface IIdpAdminOrgSelectEventDetail { + orgId: string; + org: IIdpAdminOrg | null; +} + +export interface IIdpAdminSession { + id: string; + deviceName: string; + browser: string; + os: string; + ip: string; + lastActive: number; + createdAt: number; + isCurrent: boolean; +} + +export interface IIdpAdminActivity { + id: string; + action: string; + description: string; + timestamp: number; + ip?: string; + targetType?: string; +} + +export interface IIdpAdminApp { + id: string; + name: string; + description?: string; + logoUrl?: string; + appUrl?: string; + category?: string; + type?: string; + status?: string; + isConnected?: boolean; + connectionCount?: number; + clientId?: string; + scopes?: string[]; + grants?: string[]; + roleMappings?: IIdpAdminAppRoleMapping[]; +} + +export interface IIdpAdminOrgRoleDefinition { + key: string; + name: string; + description?: string; + createdAt?: number; + updatedAt?: number; +} + +export interface IIdpAdminAppRoleMapping { + orgRoleKey: string; + appRoles: string[]; + permissions: string[]; + scopes: string[]; +} + +export interface IIdpAdminMember { + userId: string; + name: string; + email: string; + roles: string[]; + isCurrentUser?: boolean; +} + +export interface IIdpAdminInvitation { + id: string; + email: string; + roles: string[]; + invitedAt: number; + expiresAt: number; + status?: string; +} + +export interface IIdpAdminPassportDevice { + id: string; + label: string; + platform: string; + status: string; + capabilities?: { + gps?: boolean; + nfc?: boolean; + push?: boolean; + }; + appVersion?: string; + createdAt: number; + lastSeenAt?: number; + lastChallengeAt?: number; + pushRegistered?: boolean; +} + +export interface IIdpAdminPassportEnrollment { + challengeId: string; + pairingToken: string; + pairingPayload: string; + signingPayload: string; + expiresAt: number; +} + +export interface IIdpAdminSessionEventDetail { + sessionId: string; +} + +export interface IIdpAdminAppToggleEventDetail { + appId: string; + connected: boolean; +} + +export interface IIdpAdminMemberEventDetail { + userId: string; +} + +export interface IIdpAdminMemberRolesEventDetail { + userId: string; + roles: string[]; +} + +export interface IIdpAdminInvitationEventDetail { + invitationId: string; +} + +export interface IIdpAdminOrgUpdateEventDetail { + organizationId: string; + name: string; + slug: string; + confirmationText: string; +} + +export interface IIdpAdminOrgTransferEventDetail { + organizationId: string; + newOwnerId: string; + confirmationText: string; +} + +export interface IIdpAdminOrgDeleteEventDetail { + organizationId: string; + confirmationText: string; +} + +export interface IIdpAdminOrgRoleUpsertEventDetail { + organizationId: string; + roleDefinition: { + key: string; + name: string; + description?: string; + }; +} + +export interface IIdpAdminOrgRoleDeleteEventDetail { + organizationId: string; + roleKey: string; + confirmationText: string; +} + +export interface IIdpAdminAppRoleMappingsEventDetail { + organizationId: string; + appId: string; + roleMappings: IIdpAdminAppRoleMapping[]; +} + +export interface IIdpAdminPasswordChangeEventDetail { + currentPassword: string; + newPassword: string; +} + +export interface IIdpAdminPassportEnrollmentEventDetail { + deviceLabel: string; +} + +export interface IIdpAdminPassportDeviceEventDetail { + deviceId: string; +} @customElement('idp-admin-shell') export class IdpAdminShell extends DeesElement { - public static demo = () => html``; + public static demo = () => html``; public static demoGroups = ['idp.global v3 full pages']; public static styles = [ @@ -80,20 +247,27 @@ export class IdpAdminShell extends DeesElement { css` :host { display: block; + height: 100vh; + max-height: 100vh; + min-height: 0; + overflow: hidden; color: var(--idp-fg); } .shell { - min-height: 900px; + height: 100%; + min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); overflow: hidden; - border: 1px solid var(--idp-border); + border: 0; background: var(--idp-bg); } .sidebar { - min-height: 900px; + height: 100%; + min-height: 0; display: flex; flex-direction: column; + overflow: hidden; border-right: 1px solid var(--idp-border); background: var(--idp-bg); } @@ -125,8 +299,55 @@ export class IdpAdminShell extends DeesElement { } .nav-wrap { flex: 1; + min-height: 0; + overflow: auto; padding: 10px 8px; } + .state-card { + padding: 42px 24px; + border: 1px solid var(--idp-border); + border-radius: var(--idp-radius); + background: var(--idp-card); + color: var(--idp-muted-fg); + text-align: center; + } + .state-icon { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; + border: 1px solid var(--idp-border); + border-radius: 10px; + background: var(--idp-muted); + color: var(--idp-muted-fg); + } + .state-title { + margin-bottom: 4px; + color: var(--idp-fg); + font-size: 14px; + font-weight: 600; + } + .state-description { + max-width: 420px; + margin: 0 auto; + font-size: 12px; + line-height: 1.5; + } + .notice-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--idp-info-border); + border-radius: var(--idp-radius); + background: var(--idp-info-bg); + color: var(--idp-info); + } + .notice-card .muted { + color: var(--idp-muted-fg); + } .nav-section { margin-bottom: 20px; } @@ -290,9 +511,7 @@ export class IdpAdminShell extends DeesElement { white-space: nowrap; } .org-avatar, - .user-avatar, - .table-avatar, - .region-code { + .user-avatar { display: flex; align-items: center; justify-content: center; @@ -344,6 +563,8 @@ export class IdpAdminShell extends DeesElement { font-size: 10px; } main { + height: 100%; + min-height: 0; min-width: 0; overflow: auto; background: var(--idp-bg); @@ -467,14 +688,14 @@ export class IdpAdminShell extends DeesElement { } .plain-button.destructive { background: var(--idp-destructive); - color: #fff; + color: var(--idp-accent-fg); } .plain-button.ghost { color: var(--idp-fg); } - .plain-button.warn-outline { - border: 1px solid var(--idp-error-border); - color: var(--idp-error); + .plain-button:disabled { + opacity: 0.45; + cursor: not-allowed; } .body { display: flex; @@ -608,31 +829,6 @@ export class IdpAdminShell extends DeesElement { font-family: var(--idp-mono); font-size: 12px; } - .danger-zone { - overflow: hidden; - border: 1px solid var(--idp-error-border); - border-radius: var(--idp-radius); - } - .danger-head { - padding: 10px 16px; - border-bottom: 1px solid var(--idp-error-border); - background: var(--idp-error-bg); - color: var(--idp-error); - font-size: 13px; - font-weight: 600; - } - .danger-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 16px; - border-bottom: 1px solid var(--idp-error-border); - background: var(--idp-card); - } - .danger-item:last-child { - border-bottom: 0; - } .avatar, .app-avatar, .icon-tile { @@ -823,60 +1019,11 @@ export class IdpAdminShell extends DeesElement { padding: 12px 16px; border-bottom: 1px solid var(--idp-border-soft); } - .chart-card .card-head { - gap: 12px; - padding: 14px 18px; - } .card-title { color: var(--idp-fg); font-size: 13px; font-weight: 600; } - .card-subtitle { - margin-top: 2px; - color: var(--idp-muted-fg); - font-size: 11.5px; - } - .legend { - display: flex; - align-items: center; - gap: 14px; - margin-left: auto; - } - .legend span { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--idp-fg-2); - font-size: 11.5px; - } - .legend i { - width: 8px; - height: 2px; - border-radius: 1px; - } - .chart { - padding: 12px 14px 6px; - } - .chart svg { - width: 100%; - height: auto; - display: block; - } - .view-all { - margin-left: auto; - color: var(--idp-muted-fg); - font-size: 11.5px; - } - .attention { - display: grid; - grid-template-columns: 8px 1fr auto; - gap: 12px; - align-items: center; - padding: 13px 16px; - border-bottom: 1px solid var(--idp-border-soft); - } - .attention-dot, .feed-dot { display: inline-block; width: 8px; @@ -885,95 +1032,11 @@ export class IdpAdminShell extends DeesElement { background: var(--dot-color); box-shadow: var(--dot-glow, none); } - .attention-title { - color: var(--idp-fg); - font-size: 12.5px; - font-weight: 550; - } - .attention-meta { - margin-top: 2px; - color: var(--idp-muted-fg); - font-family: var(--idp-mono); - font-size: 10.5px; - } - .mini-action { - padding: 4px 8px; - border: 1px solid var(--idp-border); - border-radius: 6px; - background: transparent; - color: var(--idp-fg); - font-family: inherit; - font-size: 11px; - } - table { - width: 100%; - border-collapse: collapse; - } - th, - td { - border-bottom: 1px solid var(--idp-border-soft); - text-align: left; - } - th { - padding: 9px 16px; - background: var(--idp-muted); - color: var(--idp-fg-3); - font-family: var(--idp-mono); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - } - td { - padding: 10px 16px; - color: var(--idp-fg-2); - font-size: 12.5px; - } - tbody tr:hover td { - background: var(--idp-bg-2); - } - .row-user { - display: flex; - align-items: center; - gap: 8px; - } - .table-avatar { - width: 22px; - height: 22px; - border: 1px solid var(--idp-border); - border-radius: 50%; - background: var(--idp-muted); - color: var(--avatar-color); - font-size: 9.5px; - font-weight: 600; - } - .row-name { - color: var(--idp-fg); - font-size: 12.5px; - font-weight: 500; - } - .row-email, - .row-mono, .feed-meta { color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 11.5px; } - .side-tabs { - display: flex; - gap: 2px; - margin-left: auto; - } - .side-tab { - padding: 4px 10px; - border-radius: 4px; - color: var(--idp-muted-fg); - font-size: 11px; - } - .side-tab.active { - background: var(--idp-muted); - color: var(--idp-fg); - } .feed-item { display: grid; grid-template-columns: 14px 1fr auto; @@ -993,49 +1056,100 @@ export class IdpAdminShell extends DeesElement { color: var(--idp-fg); font-weight: 600; } - .geo-list { - padding: 8px 0; - } - .region { - display: grid; - grid-template-columns: 24px 1fr auto; - gap: 12px; - align-items: center; - padding: 8px 16px; - } - .region-code { - border: 1px solid var(--region-border, var(--idp-border)); - border-radius: 3px; - background: var(--region-bg, transparent); - color: var(--region-color, var(--idp-fg-3)); - font-size: 10px; - } - .region-title { + .dialog-backdrop { + position: fixed; + inset: 0; + z-index: 100; display: flex; align-items: center; - gap: 6px; - margin-bottom: 4px; - color: var(--idp-fg); - font-size: 12.5px; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.42); } - .bar-track { - height: 4px; + .dialog-card { + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 40px)); + display: flex; + flex-direction: column; overflow: hidden; - border-radius: 2px; - background: var(--idp-muted); + border: 1px solid var(--idp-border); + border-radius: 12px; + background: var(--idp-card); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); } - .bar-fill { - width: var(--share); - height: 100%; - background: var(--bar-color, var(--idp-accent)); - opacity: 0.85; + .dialog-card.wide { + width: min(980px, 100%); } - .region-count { - min-width: 44px; + .dialog-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 14px; + border-bottom: 1px solid var(--idp-border); + } + .dialog-title { color: var(--idp-fg); - font-family: var(--idp-mono); - font-size: 11.5px; - text-align: right; + font-size: 15px; + font-weight: 700; + } + .dialog-body { + display: grid; + gap: 14px; + overflow: auto; + padding: 18px 20px; + } + .dialog-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid var(--idp-border); + } + .role-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + } + .role-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px; + border: 1px solid var(--idp-border); + border-radius: 8px; + background: var(--idp-bg); + } + .role-option input { + margin-top: 2px; + } + .mapping-row { + display: grid; + grid-template-columns: 160px repeat(3, minmax(0, 1fr)); + gap: 8px; + align-items: start; + padding: 10px; + border: 1px solid var(--idp-border); + border-radius: 8px; + background: var(--idp-bg); + } + .mapping-role { + color: var(--idp-fg); + font-size: 12px; + font-weight: 600; + } + .mapping-role .muted { + display: block; + margin-top: 2px; + } + .mapping-row .form-row { + display: block; + padding: 0; + border-bottom: 0; + } + .mapping-row .form-label { + margin-bottom: 4px; } @media (max-width: 1120px) { .shell { @@ -1058,31 +1172,144 @@ export class IdpAdminShell extends DeesElement { padding-left: 20px; padding-right: 20px; } - .page-actions, - .legend { + .page-actions { flex-wrap: wrap; } .kpis { grid-template-columns: 1fr; } - th:nth-child(3), - td:nth-child(3), - th:nth-child(5), - td:nth-child(5) { - display: none; + .mapping-row { + grid-template-columns: 1fr; } } `, ]; @property({ type: String }) - public accessor page: TAdminPage = 'overview'; + public accessor page: TIdpAdminPage = 'overview'; + + @property({ type: Object }) + public accessor user: IIdpAdminUser = { + name: '', + email: '', + }; + + @property({ type: Array }) + public accessor orgs: IIdpAdminOrg[] = []; + + @property({ type: String, attribute: 'selected-org-id' }) + public accessor selectedOrgId = ''; + + @property({ type: Boolean, attribute: 'global-admin', reflect: true }) + public accessor globalAdmin = false; + + @property({ type: Boolean, attribute: 'data-loading', reflect: true }) + public accessor dataLoading = false; + + @property({ type: String, attribute: 'data-error' }) + public accessor dataError = ''; + + @property({ type: Array }) + public accessor sessions: IIdpAdminSession[] = []; + + @property({ type: Array }) + public accessor activities: IIdpAdminActivity[] = []; + + @property({ type: Array }) + public accessor orgMembers: IIdpAdminMember[] = []; + + @property({ type: Array }) + public accessor orgInvitations: IIdpAdminInvitation[] = []; + + @property({ type: Array }) + public accessor orgRoleDefinitions: IIdpAdminOrgRoleDefinition[] = []; + + @property({ type: Array }) + public accessor orgApps: IIdpAdminApp[] = []; + + @property({ type: Array }) + public accessor accountApps: IIdpAdminApp[] = []; + + @property({ type: Array }) + public accessor adminApps: IIdpAdminApp[] = []; + + @property({ type: Array }) + public accessor passportDevices: IIdpAdminPassportDevice[] = []; + + @property({ type: Object }) + public accessor passportEnrollment: IIdpAdminPassportEnrollment | null = null; + + @property({ type: String, attribute: 'credential-message' }) + public accessor credentialMessage = ''; @state() private accessor orgMenuOpen = false; @state() - private accessor selectedOrg = 'org_foss'; + private accessor currentPassword = ''; + + @state() + private accessor newPassword = ''; + + @state() + private accessor confirmPassword = ''; + + @state() + private accessor credentialError = ''; + + @state() + private accessor orgSettingsOrgId = ''; + + @state() + private accessor orgNameDraft = ''; + + @state() + private accessor orgSlugDraft = ''; + + @state() + private accessor orgSettingsConfirmation = ''; + + @state() + private accessor transferOwnerId = ''; + + @state() + private accessor transferConfirmation = ''; + + @state() + private accessor deleteConfirmation = ''; + + @state() + private accessor orgSettingsError = ''; + + @state() + private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' = 'none'; + + @state() + private accessor dialogError = ''; + + @state() + private accessor dialogRoleKey = ''; + + @state() + private accessor dialogRoleName = ''; + + @state() + private accessor dialogRoleDescription = ''; + + @state() + private accessor dialogRoleDeleteConfirmation = ''; + + @state() + private accessor dialogMember: IIdpAdminMember | null = null; + + @state() + private accessor dialogMemberRoles: string[] = []; + + @state() + private accessor dialogApp: IIdpAdminApp | null = null; + + @state() + private accessor dialogAppMappings: IIdpAdminAppRoleMapping[] = []; private workspaceNav: TNavItem[] = [ { id: 'overview', label: 'Overview', icon: 'grid' }, @@ -1097,6 +1324,7 @@ export class IdpAdminShell extends DeesElement { private orgNav: TNavItem[] = [ { id: 'org-general', label: 'General', icon: 'building' }, + { id: 'org-settings', label: 'Settings', icon: 'settings' }, { id: 'org-members', label: 'Members', icon: 'users' }, { id: 'org-apps', label: 'OAuth Apps', icon: 'box' }, ]; @@ -1111,102 +1339,590 @@ export class IdpAdminShell extends DeesElement { { id: 'ga-apps', label: 'Platform Apps', icon: 'globe' }, ]; - private orgs: TOrg[] = [ - { id: 'org_foss', name: 'Lossless GmbH', slug: 'lossless', myRole: 'owner' }, - { id: 'org_task', name: 'Task VC', slug: 'task', myRole: 'admin' }, - { id: 'org_demo', name: 'Demo Sandbox', slug: 'demo', myRole: 'viewer' }, - { id: 'org_oss', name: 'OpenSource Coop', slug: 'oss-coop', myRole: 'editor' }, - { id: 'org_ext', name: 'External Client', slug: 'ext-client', myRole: 'guest' }, - ]; - - private heroKpis: TKpi[] = [ - { - label: 'Identities', - value: '2,847', - delta: '↑ 12% wk', - deltaKind: 'up', - spark: [10, 12, 11, 14, 13, 16, 15, 18, 19], - sparkColor: 'var(--idp-spark-up)', - accent: 'var(--idp-chart-1)', - sub: '142 added this week', - }, - { - label: 'Active devices', - value: '9,140', - delta: '↑ 4.2%', - deltaKind: 'up', - spark: [12, 13, 11, 14, 13, 15, 14, 16, 17], - sparkColor: 'var(--idp-spark-up)', - accent: 'var(--idp-chart-2)', - sub: '3.2 avg / identity', - }, - { - label: 'Avg approval', - value: '0.8', - unit: 's', - delta: '↓ 60ms faster', - deltaKind: 'up', - spark: [16, 14, 17, 12, 15, 13, 11, 9, 7], - sparkColor: 'var(--idp-spark-info)', - accent: 'var(--idp-chart-5)', - sub: 'p95 - all regions', - }, - { - label: 'Cardano anchors', - value: '12,408', - delta: 'synced 4s ago', - deltaKind: 'live', - spark: [8, 9, 11, 10, 13, 12, 15, 16, 18], - sparkColor: 'var(--idp-spark-up)', - accent: 'var(--idp-info)', - sub: 'block #9 841 222', - }, - ]; - - private approvals: TApproval[] = [ - { user: 'Jane Doe', email: 'jane@lossless.com', hue: 'var(--idp-chart-1)', action: 'OAuth - GitHub', device: 'iPhone 15 Pro', status: 'ok', label: 'approved', when: '2 min ago' }, - { user: 'Alex Brown', email: 'alex@lossless.com', hue: 'var(--idp-chart-4)', action: 'CLI login', device: 'MacBook Pro', status: 'warn', label: 'pending', when: 'just now' }, - { user: 'Sam Chen', email: 'sam@lossless.com', hue: 'var(--idp-chart-5)', action: 'NFC tap - door 4F', device: 'iPhone 14', status: 'ok', label: 'approved', when: '12 min ago' }, - { user: 'Unknown device', email: 'Lagos - NG', hue: 'var(--idp-chart-3)', action: 'Web login', device: 'Chrome 132', status: 'error', label: 'denied', when: '1 hr ago' }, - { user: 'Maria K.', email: 'maria@lossless.com', hue: 'var(--idp-chart-5)', action: 'Key rotation', device: 'Apple Watch', status: 'accent', label: 'on-chain', when: '3 hr ago' }, - ]; - - private attention = [ - { sev: 'error', title: 'Repeated denied logins', meta: '5 attempts - Lagos, NG - last 1h', action: 'Block IP', color: 'var(--idp-error)' }, - { sev: 'warning', title: 'Stale device key', meta: 'sam@ - Apple Watch - 9 mo old', action: 'Rotate', color: 'var(--idp-warn)' }, - { sev: 'warning', title: 'OAuth scope expanding', meta: 'github - repo:write requested', action: 'Review', color: 'var(--idp-warn)' }, - { sev: 'info', title: 'Pending key rotations', meta: '3 identities - Cardano awaiting', action: 'Run', color: 'var(--idp-info)' }, - ]; - - private feed: TFeedItem[] = [ - { dot: 'bl', title: 'Identity created', detail: 'did:idp:0x9b12...f034', meta: 'block #9 841 222' }, - { dot: 'ok', title: 'Anchor confirmed', detail: '12 blocks deep', meta: '2m' }, - { dot: 'bl', title: 'Key rotation', detail: 'did:idp:0x4a3f...c819', meta: 'block #9 841 221' }, - { dot: 'ok', title: 'OAuth scope updated', detail: 'github - repo:read', meta: '5m' }, - { dot: 'wn', title: 'Device registered', detail: 'MacBook Pro - pending', meta: '7m' }, - { dot: 'bl', title: 'Anchor submitted', detail: 'awaiting confirmation', meta: '8m' }, - ]; - - private get currentOrg(): TOrg { - return this.orgs.find((orgArg) => orgArg.id === this.selectedOrg) || this.orgs[0]; + private get safeOrgs(): IIdpAdminOrg[] { + return Array.isArray(this.orgs) ? this.orgs.filter(Boolean) : []; } - private setPage(pageArg: TAdminPage) { + private get currentOrg(): IIdpAdminOrg { + const orgs = this.safeOrgs; + return orgs.find((orgArg) => orgArg.id === this.selectedOrgId) || orgs[0] || { + id: '', + name: 'No organisation', + slug: '', + myRole: '', + }; + } + + private get userInitials(): string { + if (this.user?.initials) { + return this.user.initials; + } + + const source = this.user?.name || this.user?.email || '?'; + return source + .split(/\s+|@/) + .filter(Boolean) + .map((partArg) => partArg[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + } + + private getInitials(valueArg?: string): string { + const source = valueArg || '?'; + return source + .split(/\s+|@|-|\./) + .filter(Boolean) + .map((partArg) => partArg[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + } + + private setPasswordField(fieldArg: 'current' | 'new' | 'confirm', eventArg: Event) { + const value = (eventArg.target as HTMLInputElement).value; + if (fieldArg === 'current') this.currentPassword = value; + if (fieldArg === 'new') this.newPassword = value; + if (fieldArg === 'confirm') this.confirmPassword = value; + this.credentialError = ''; + } + + private submitPasswordChange() { + if (!this.currentPassword || !this.newPassword || !this.confirmPassword) { + this.credentialError = 'Enter your current password and confirm the new password.'; + return; + } + + if (this.newPassword.length < 12) { + this.credentialError = 'Use at least 12 characters for the new password.'; + return; + } + + if (this.newPassword !== this.confirmPassword) { + this.credentialError = 'New password and confirmation do not match.'; + return; + } + + this.dispatchShellEvent('idp-admin-password-change', { + currentPassword: this.currentPassword, + newPassword: this.newPassword, + }); + } + + private syncOrgSettingsState() { + const org = this.currentOrg; + if (this.orgSettingsOrgId === org.id) { + return; + } + + this.orgSettingsOrgId = org.id; + this.orgNameDraft = org.name || ''; + this.orgSlugDraft = org.slug || ''; + this.orgSettingsConfirmation = ''; + this.transferOwnerId = ''; + this.transferConfirmation = ''; + this.deleteConfirmation = ''; + this.orgSettingsError = ''; + } + + private setOrgSettingsField( + fieldArg: 'name' | 'slug' | 'settingsConfirmation' | 'transferOwnerId' | 'transferConfirmation' | 'deleteConfirmation', + eventArg: Event + ) { + const value = (eventArg.target as HTMLInputElement | HTMLSelectElement).value; + if (fieldArg === 'name') this.orgNameDraft = value; + if (fieldArg === 'slug') this.orgSlugDraft = value.trim().toLowerCase(); + if (fieldArg === 'settingsConfirmation') this.orgSettingsConfirmation = value; + if (fieldArg === 'transferOwnerId') this.transferOwnerId = value; + if (fieldArg === 'transferConfirmation') this.transferConfirmation = value; + if (fieldArg === 'deleteConfirmation') this.deleteConfirmation = value; + this.orgSettingsError = ''; + } + + private submitOrgSettingsUpdate() { + const org = this.currentOrg; + const name = this.orgNameDraft.trim(); + const slug = this.orgSlugDraft.trim().toLowerCase(); + if (!org.id) { + this.orgSettingsError = 'Select an organisation before updating settings.'; + return; + } + if (!name || !slug) { + this.orgSettingsError = 'Organisation name and slug are required.'; + return; + } + if (name === org.name && slug === org.slug) { + this.orgSettingsError = 'Change the organisation name or slug before applying settings.'; + return; + } + if (this.orgSettingsConfirmation.trim() !== org.slug) { + this.orgSettingsError = `Type ${org.slug} to confirm organisation settings changes.`; + return; + } + + this.dispatchShellEvent('idp-admin-org-update', { + organizationId: org.id, + name, + slug, + confirmationText: this.orgSettingsConfirmation.trim(), + }); + } + + private submitOrgTransfer() { + const org = this.currentOrg; + const expectedText = `transfer ${org.slug}`; + if (!org.id) { + this.orgSettingsError = 'Select an organisation before transferring ownership.'; + return; + } + if (!this.transferOwnerId) { + this.orgSettingsError = 'Select the member who should become the new owner.'; + return; + } + if (this.transferConfirmation.trim() !== expectedText) { + this.orgSettingsError = `Type ${expectedText} to confirm ownership transfer.`; + return; + } + + this.dispatchShellEvent('idp-admin-org-transfer', { + organizationId: org.id, + newOwnerId: this.transferOwnerId, + confirmationText: this.transferConfirmation.trim(), + }); + } + + private submitOrgDelete() { + const org = this.currentOrg; + const expectedText = `delete ${org.slug}`; + if (!org.id) { + this.orgSettingsError = 'Select an organisation before deleting it.'; + return; + } + if (this.deleteConfirmation.trim() !== expectedText) { + this.orgSettingsError = `Type ${expectedText} to confirm organisation deletion.`; + return; + } + + this.dispatchShellEvent('idp-admin-org-delete', { + organizationId: org.id, + confirmationText: this.deleteConfirmation.trim(), + }); + } + + private requestPassportEnrollment() { + const fallbackLabel = typeof navigator !== 'undefined' + ? navigator.userAgent.includes('Mobile') ? 'Mobile passport device' : 'Desktop passport device' + : 'Passport device'; + const deviceLabel = globalThis.prompt?.('Device label', fallbackLabel)?.trim(); + if (!deviceLabel) { + return; + } + + this.dispatchShellEvent('idp-admin-passport-enroll', { + deviceLabel, + }); + } + + private setPage(pageArg: TIdpAdminPage) { this.page = pageArg; this.orgMenuOpen = false; + this.dispatchEvent(new CustomEvent('idp-admin-navigate', { + detail: { page: pageArg }, + bubbles: true, + composed: true, + })); } private selectOrg(orgIdArg: string) { - this.selectedOrg = orgIdArg; + this.selectedOrgId = orgIdArg; this.orgMenuOpen = false; this.page = 'org-general'; + this.dispatchEvent(new CustomEvent('idp-admin-org-select', { + detail: { + orgId: orgIdArg, + org: this.orgs.find((orgArg) => orgArg.id === orgIdArg) || null, + }, + bubbles: true, + composed: true, + })); + } + + private requestOrgCreate() { + this.orgMenuOpen = false; + this.dispatchEvent(new CustomEvent('idp-admin-org-create', { + bubbles: true, + composed: true, + })); + } + + private dispatchShellEvent(eventNameArg: string, detailArg: TDetail) { + this.dispatchEvent(new CustomEvent(eventNameArg, { + detail: detailArg, + bubbles: true, + composed: true, + })); + } + + private formatTimeAgo(timestampArg?: number): string { + if (!timestampArg) { + return 'unknown'; + } + + const diff = Date.now() - timestampArg; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(timestampArg).toLocaleDateString(); + } + + private renderStateCard(titleArg: string, descriptionArg: string, iconArg = 'box'): TemplateResult { + return html` +
+ +
${titleArg}
+
${descriptionArg}
+
+ `; + } + + private renderDataState(emptyTitleArg: string, emptyDescriptionArg: string, iconArg = 'box'): TemplateResult | null { + if (this.dataLoading) { + return this.renderStateCard('Loading data', 'Fetching the latest account and organisation data.', 'cloud'); + } + + if (this.dataError) { + return this.renderStateCard('Data unavailable', this.dataError, 'alert'); + } + + return this.renderStateCard(emptyTitleArg, emptyDescriptionArg, iconArg); + } + + private roleVariant(roleArg: string): 'default' | 'accent' | 'ok' | 'warn' | 'error' | 'outline' { + if (roleArg === 'owner') return 'accent'; + if (roleArg === 'admin') return 'warn'; + if (roleArg === 'editor') return 'ok'; + if (roleArg === 'outlaw') return 'error'; + return 'outline'; + } + + private get platformRoles(): IIdpAdminOrgRoleDefinition[] { + return [ + { key: 'owner', name: 'Owner', description: 'Protected idp.global owner role.' }, + { key: 'admin', name: 'Admin', description: 'Protected idp.global admin role.' }, + { key: 'editor', name: 'Editor', description: 'Standard organisation role.' }, + { key: 'viewer', name: 'Viewer', description: 'Standard organisation role.' }, + { key: 'guest', name: 'Guest', description: 'Standard organisation role.' }, + { key: 'outlaw', name: 'Outlaw', description: 'Restricted organisation role.' }, + ]; + } + + private get availableOrgRoles(): IIdpAdminOrgRoleDefinition[] { + const customRoles = this.orgRoleDefinitions || []; + const customRoleKeys = new Set(customRoles.map((roleArg) => roleArg.key)); + return [ + ...this.platformRoles.filter((roleArg) => !customRoleKeys.has(roleArg.key)), + ...customRoles, + ]; + } + + private parseCsv(valueArg: string): string[] { + return [...new Set(valueArg.split(',').map((entryArg) => entryArg.trim()).filter(Boolean))]; + } + + private normalizeRoleKey(valueArg: string): string { + return valueArg.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + } + + private closeDialog() { + this.dialogMode = 'none'; + this.dialogError = ''; + this.dialogRoleKey = ''; + this.dialogRoleName = ''; + this.dialogRoleDescription = ''; + this.dialogRoleDeleteConfirmation = ''; + this.dialogMember = null; + this.dialogMemberRoles = []; + this.dialogApp = null; + this.dialogAppMappings = []; + } + + private openRoleUpsertDialog(roleArg?: IIdpAdminOrgRoleDefinition) { + this.dialogMode = 'role-upsert'; + this.dialogError = ''; + this.dialogRoleKey = roleArg?.key || ''; + this.dialogRoleName = roleArg?.name || ''; + this.dialogRoleDescription = roleArg?.description || ''; + } + + private openRoleDeleteDialog(roleArg: IIdpAdminOrgRoleDefinition) { + this.dialogMode = 'role-delete'; + this.dialogError = ''; + this.dialogRoleKey = roleArg.key; + this.dialogRoleName = roleArg.name; + this.dialogRoleDescription = roleArg.description || ''; + this.dialogRoleDeleteConfirmation = ''; + } + + private openMemberRolesDialog(memberArg: IIdpAdminMember) { + this.dialogMode = 'member-roles'; + this.dialogError = ''; + this.dialogMember = memberArg; + this.dialogMemberRoles = [...memberArg.roles]; + } + + private openAppRoleMappingsDialog(appArg: IIdpAdminApp) { + this.dialogMode = 'app-role-mappings'; + this.dialogError = ''; + this.dialogApp = appArg; + this.dialogAppMappings = this.availableOrgRoles.map((roleArg) => { + const existingMapping = (appArg.roleMappings || []).find((mappingArg) => mappingArg.orgRoleKey === roleArg.key); + return { + orgRoleKey: roleArg.key, + appRoles: [...(existingMapping?.appRoles || [])], + permissions: [...(existingMapping?.permissions || [])], + scopes: [...(existingMapping?.scopes || [])], + }; + }); + } + + private setDialogRoleField(fieldArg: 'key' | 'name' | 'description' | 'deleteConfirmation', eventArg: Event) { + const value = (eventArg.target as HTMLInputElement).value; + if (fieldArg === 'key') this.dialogRoleKey = this.normalizeRoleKey(value); + if (fieldArg === 'name') { + this.dialogRoleName = value; + if (!this.dialogRoleKey) { + this.dialogRoleKey = this.normalizeRoleKey(value); + } + } + if (fieldArg === 'description') this.dialogRoleDescription = value; + if (fieldArg === 'deleteConfirmation') this.dialogRoleDeleteConfirmation = value; + this.dialogError = ''; + } + + private toggleDialogMemberRole(roleKeyArg: string, checkedArg: boolean) { + const nextRoles = new Set(this.dialogMemberRoles); + if (checkedArg) { + nextRoles.add(roleKeyArg); + } else { + nextRoles.delete(roleKeyArg); + } + this.dialogMemberRoles = [...nextRoles]; + this.dialogError = ''; + } + + private setDialogMappingField(roleKeyArg: string, fieldArg: 'appRoles' | 'permissions' | 'scopes', eventArg: Event) { + const value = (eventArg.target as HTMLInputElement).value; + this.dialogAppMappings = this.dialogAppMappings.map((mappingArg) => mappingArg.orgRoleKey === roleKeyArg + ? { ...mappingArg, [fieldArg]: this.parseCsv(value) } + : mappingArg + ); + this.dialogError = ''; + } + + private submitRoleUpsertDialog() { + const key = this.normalizeRoleKey(this.dialogRoleKey); + const name = this.dialogRoleName.trim(); + if (!key || !name) { + this.dialogError = 'Role key and name are required.'; + return; + } + this.dispatchShellEvent('idp-admin-org-role-upsert', { + organizationId: this.currentOrg.id, + roleDefinition: { + key, + name, + description: this.dialogRoleDescription.trim(), + }, + }); + this.closeDialog(); + } + + private submitRoleDeleteDialog() { + const expectedText = `delete role ${this.dialogRoleKey}`; + if (this.dialogRoleDeleteConfirmation.trim() !== expectedText) { + this.dialogError = `Type ${expectedText} to confirm role deletion.`; + return; + } + this.dispatchShellEvent('idp-admin-org-role-delete', { + organizationId: this.currentOrg.id, + roleKey: this.dialogRoleKey, + confirmationText: this.dialogRoleDeleteConfirmation.trim(), + }); + this.closeDialog(); + } + + private submitMemberRolesDialog() { + if (!this.dialogMember) return; + if (!this.dialogMemberRoles.length) { + this.dialogError = 'At least one role is required.'; + return; + } + this.dispatchShellEvent('idp-admin-member-roles-update', { + userId: this.dialogMember.userId, + roles: this.dialogMemberRoles, + }); + this.closeDialog(); + } + + private submitAppRoleMappingsDialog() { + if (!this.dialogApp) return; + const roleMappings = this.dialogAppMappings + .map((mappingArg) => ({ + orgRoleKey: mappingArg.orgRoleKey, + appRoles: [...mappingArg.appRoles], + permissions: [...mappingArg.permissions], + scopes: [...mappingArg.scopes], + })) + .filter((mappingArg) => mappingArg.appRoles.length || mappingArg.permissions.length || mappingArg.scopes.length); + const allowedScopes = this.dialogApp.scopes || []; + const invalidScopes = roleMappings.flatMap((mappingArg) => mappingArg.scopes).filter((scopeArg) => !allowedScopes.includes(scopeArg)); + if (invalidScopes.length) { + this.dialogError = `Unsupported app scopes: ${[...new Set(invalidScopes)].join(', ')}.`; + return; + } + this.dispatchShellEvent('idp-admin-app-role-mappings-update', { + organizationId: this.currentOrg.id, + appId: this.dialogApp.id, + roleMappings, + }); + this.closeDialog(); + } + + private renderDialogError(): TemplateResult { + return this.dialogError + ? html`
Action blocked
${this.dialogError}
` + : html``; + } + + private renderRoleUpsertDialog(): TemplateResult { + const isEdit = this.orgRoleDefinitions.some((roleArg) => roleArg.key === this.dialogRoleKey); + return html` +
eventArg.stopPropagation()}> +
+
${isEdit ? 'Edit custom role' : 'Add custom role'}
Custom roles model organisation-specific business access.
+ +
+
+ ${this.renderDialogError()} + ${this.renderFormRow('Role name', 'Readable label shown to admins.', html` this.setDialogRoleField('name', eventArg)} />`, true)} + ${this.renderFormRow('Role key', 'Lowercase stable identifier used in assignments and app mappings.', html` this.setDialogRoleField('key', eventArg)} ?disabled=${isEdit} />`, true)} + ${this.renderFormRow('Description', 'Optional admin note describing when this role should be used.', html``)} +
+
+ + +
+
+ `; + } + + private renderRoleDeleteDialog(): TemplateResult { + const expectedText = `delete role ${this.dialogRoleKey}`; + return html` +
eventArg.stopPropagation()}> +
+
Delete custom role
This removes the role from member assignments and app mappings after backend confirmation.
+ +
+
+ ${this.renderDialogError()} +
${this.dialogRoleName}
Type ${expectedText} to confirm deletion.
+ ${this.renderFormRow('Confirmation', `Type ${expectedText}`, html` this.setDialogRoleField('deleteConfirmation', eventArg)} />`, true)} +
+
+ + +
+
+ `; + } + + private renderMemberRolesDialog(): TemplateResult { + const member = this.dialogMember; + return html` +
eventArg.stopPropagation()}> +
+
Edit member roles
${member?.name || member?.email || 'Member'} receives the selected organisation roles.
+ +
+
+ ${this.renderDialogError()} +
+ ${this.availableOrgRoles.map((roleArg) => { + const checked = this.dialogMemberRoles.includes(roleArg.key); + return html` + + `; + })} +
+
+
+ + +
+
+ `; + } + + private renderAppRoleMappingsDialog(): TemplateResult { + const app = this.dialogApp; + return html` +
eventArg.stopPropagation()}> +
+
Map organisation roles
Map ${this.currentOrg.name} roles to app-specific roles, permissions, and OAuth scopes for ${app?.name || 'this app'}.
+ +
+
+ ${this.renderDialogError()} + ${app?.scopes?.length ? html`
Available OAuth scopes: ${app.scopes.join(', ')}
` : html`
This app has no declared OAuth scopes. Role and permission mappings are still supported.
`} + ${this.dialogAppMappings.map((mappingArg) => { + const role = this.availableOrgRoles.find((roleArg) => roleArg.key === mappingArg.orgRoleKey); + return html` +
+
${role?.name || mappingArg.orgRoleKey}${mappingArg.orgRoleKey}
+
${this.renderFormRow('App roles', '', html` this.setDialogMappingField(mappingArg.orgRoleKey, 'appRoles', eventArg)} />`)}
+
${this.renderFormRow('Permissions', '', html` this.setDialogMappingField(mappingArg.orgRoleKey, 'permissions', eventArg)} />`)}
+
${this.renderFormRow('Scopes', '', html` this.setDialogMappingField(mappingArg.orgRoleKey, 'scopes', eventArg)} />`)}
+
+ `; + })} +
+
+ + +
+
+ `; + } + + private renderDialog(): TemplateResult { + if (this.dialogMode === 'none') { + return html``; + } + + const dialog = this.dialogMode === 'role-upsert' + ? this.renderRoleUpsertDialog() + : this.dialogMode === 'role-delete' + ? this.renderRoleDeleteDialog() + : this.dialogMode === 'member-roles' + ? this.renderMemberRolesDialog() + : this.renderAppRoleMappingsDialog(); + return html`
this.closeDialog()}>${dialog}
`; } private renderNavGroup(items: TNavItem[], active = ''): TemplateResult { return html` ` : html``} ${this.renderNavGroup(this.orgNav, this.page)} - + ${this.globalAdmin ? html`` : html``} `; @@ -1304,136 +2020,6 @@ export class IdpAdminShell extends DeesElement { `; } - private renderApprovalsChart(): TemplateResult { - return html` - - - - - - - - - - - - ${[0, 20, 40, 60, 80].map((tickArg, indexArg) => { - const y = 194 - (tickArg / 80) * 182; - return html`${tickArg}`; - })} - ${['00', '04', '08', '12', '16', '20', '23'].map((labelArg, indexArg) => { - const x = 36 + (indexArg / 6) * 672; - return html`${labelArg}`; - })} - - - - - - - - - `; - } - - private renderChartCard(): TemplateResult { - return html` -
-
-
Approval activity
Hourly - last 24 hours
-
ApprovalsOAuth grants
-
-
${this.renderApprovalsChart()}
-
- `; - } - - private renderThreatsCard(): TemplateResult { - return html` -
-
Needs attention4 openView all →
- ${this.attention.map((itemArg) => html` -
- -
${itemArg.title}
${itemArg.meta}
- -
- `)} -
- `; - } - - private renderApprovalsTable(): TemplateResult { - return html` -
-
Recent approvals142 total
AllPendingDenied
- - - - ${this.approvals.map((approvalArg) => html` - - - - - - - - `)} - -
UserActionDeviceStatusWhen
${approvalArg.user.split(' ').map((partArg) => partArg[0]).slice(0, 2).join('').toUpperCase()}
${approvalArg.user}
${approvalArg.email}
${approvalArg.action}${approvalArg.device}${approvalArg.label}${approvalArg.when}
-
- `; - } - - private feedDotStyle(kind: TFeedItem['dot']): string { - if (kind === 'ok') return '--dot-color:var(--idp-ok);--dot-glow:0 0 6px var(--idp-ok-border)'; - if (kind === 'wn') return '--dot-color:var(--idp-warn)'; - return '--dot-color:var(--idp-info);--dot-glow:0 0 6px var(--idp-info-border)'; - } - - private renderFeedCard(): TemplateResult { - return html` -
-
Cardano feedlive - #9 841 222
- ${this.feed.map((itemArg) => html` -
${itemArg.title} - ${itemArg.detail}
${itemArg.meta}
- `)} -
- `; - } - - private renderGeoCard(): TemplateResult { - const regions = [ - { name: 'Berlin, DE', code: 'DE', count: '1,284', share: '100%', kind: 'home' }, - { name: 'Amsterdam, NL', code: 'NL', count: '642', share: '50%' }, - { name: 'San Francisco, US', code: 'US', count: '521', share: '41%' }, - { name: 'London, GB', code: 'GB', count: '384', share: '30%' }, - { name: 'Tokyo, JP', code: 'JP', count: '218', share: '17%' }, - { name: 'Lagos, NG', code: 'NG', count: '12', share: '1%', kind: 'risk' }, - ]; - return html` -
-
Sign-ins by regionlast 24h
-
- ${regions.map((regionArg) => { - const style = regionArg.kind === 'risk' - ? '--region-color:var(--idp-error);--region-border:var(--idp-error-border);--region-bg:var(--idp-error-bg);--bar-color:var(--idp-error)' - : regionArg.kind === 'home' - ? '--region-color:var(--idp-accent);--region-border:var(--idp-info-border);--region-bg:var(--idp-accent-soft);--bar-color:var(--idp-accent)' - : ''; - return html` -
- ${regionArg.code} -
${regionArg.name}${regionArg.kind === 'risk' ? html`flagged` : regionArg.kind === 'home' ? html`primary` : html``}
- ${regionArg.count} -
- `; - })} -
-
- `; - } - private renderPageHeader(titleArg: string, descriptionArg: string, actionArg?: TemplateResult): TemplateResult { return html`
@@ -1470,156 +2056,483 @@ export class IdpAdminShell extends DeesElement { return html`
${valueArg}
`; } - private renderDangerZone(itemsArg: Array<{ title: string; description: string; action: string }>): TemplateResult { - return html` -
-
Danger Zone
- ${itemsArg.map((itemArg) => html` -
${itemArg.title}
${itemArg.description}
- `)} -
- `; - } - private renderOverview(): TemplateResult { + const firstName = this.user?.name?.split(' ')[0] || 'there'; + const activeSessionCount = this.sessions.filter((sessionArg) => sessionArg.isCurrent).length || this.sessions.length; + const activePassportCount = this.passportDevices.filter((deviceArg) => deviceArg.status === 'active').length; + const kpis: TKpi[] = [ + { + label: 'Organisations', + value: String(this.orgs.length), + delta: 'live', + deltaKind: 'live', + spark: [1, 1, 1, 1, 1, 1, 1, Math.max(this.orgs.length, 1)], + sparkColor: 'var(--idp-spark-info)', + accent: 'var(--idp-chart-1)', + sub: 'Memberships visible to this account', + }, + { + label: 'Sessions', + value: String(this.sessions.length), + delta: `${activeSessionCount} active`, + deltaKind: 'up', + spark: [1, 2, 1, 2, 3, 2, Math.max(this.sessions.length, 1)], + sparkColor: 'var(--idp-spark-up)', + accent: 'var(--idp-chart-2)', + sub: this.sessions.length ? `Latest ${this.formatTimeAgo(Math.max(...this.sessions.map((sessionArg) => sessionArg.lastActive)))}` : 'No active sessions loaded', + }, + { + label: 'Passport devices', + value: String(this.passportDevices.length), + delta: `${activePassportCount} active`, + deltaKind: 'live', + spark: [1, 1, 1, Math.max(this.passportDevices.length, 1)], + sparkColor: 'var(--idp-spark-info)', + accent: 'var(--idp-chart-5)', + sub: 'Cryptographic credentials', + }, + { + label: 'Activity', + value: String(this.activities.length), + delta: 'live', + deltaKind: 'live', + spark: [1, 2, 2, 3, Math.max(this.activities.length, 1)], + sparkColor: 'var(--idp-spark-info)', + accent: 'var(--idp-info)', + sub: 'Recent account events', + }, + ]; return html`
-
Workspace - Overviewlive
-

Good morning, Aegir.

-
Identity activity across @lossless - 142 identities, 9.1k devices online
+
Account - Overviewlive
+

Good morning, ${firstName}.

+
Account-wide operational snapshot using live identity data.
-
24h7d30d90d
+
-
${this.heroKpis.map((kpiArg) => this.renderKpi(kpiArg))}
-
${this.renderChartCard()}${this.renderThreatsCard()}
-
${this.renderApprovalsTable()}${this.renderFeedCard()}
- ${this.renderGeoCard()} +
${kpis.map((kpiArg) => this.renderKpi(kpiArg))}
+ ${this.dataLoading || this.dataError ? this.renderDataState('No overview data', 'The console has not received live account data yet.', 'activity') : html``} +
+
+
Recent activity${this.activities.length} events
+ ${this.activities.length ? this.activities.slice(0, 8).map((activityArg) => html` +
${activityArg.action.replace(/_/g, ' ')} - ${activityArg.description}
${this.formatTimeAgo(activityArg.timestamp)}
+ `) : this.renderStateCard('No activity yet', 'Activity events will appear here after logins, app changes, and organisation updates.', 'activity')} +
+
+
Current sessions${this.sessions.length} total
+ ${this.sessions.length ? this.sessions.slice(0, 5).map((sessionArg) => html` +
${sessionArg.deviceName || 'Unknown device'} - ${sessionArg.browser} ${sessionArg.os}
${this.formatTimeAgo(sessionArg.lastActive)}
+ `) : this.renderStateCard('No sessions loaded', 'Active session telemetry is unavailable or there are no sessions.', 'monitor')} +
+
`; } private renderProfile(): TemplateResult { + const username = this.user?.username || this.user?.email?.split('@')[0] || ''; + const status = this.user?.status || 'active'; return html` ${this.renderPageHeader('Profile', 'Your personal identity details visible to connected apps.')}
- ${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`
AM
JPG, PNG, GIF up to 2 MB
`)} + ${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`
${this.userInitials}
${this.user?.name || 'Unknown User'}
Profile update endpoints are not exposed in this console yet.
`)} ${this.renderSectionCard('Personal information', '', html` - ${this.renderFormRow('Full name', '', html``, true)} - ${this.renderFormRow('Username', 'Used in your public profile URL', html`
idp.global/user/
`)} - ${this.renderFormRow('Email', 'Primary address for login and notifications', html``)} - ${this.renderFormRow('Mobile number', 'Used for SMS verification', html``)} -
+ ${this.renderFormRow('Full name', '', html``, true)} + ${this.renderFormRow('Username', 'Used in your public profile URL', html`
idp.global/user/
`)} + ${this.renderFormRow('Email', 'Primary address for login and notifications', html``)} + ${this.renderFormRow('Mobile number', 'Used for SMS verification', html``)} `)} - ${this.renderSectionCard('Account status', '', html`
Status
Your account is currently active.
Active
Global admin
You have platform-wide administrative access.
Admin
`)} - ${this.renderDangerZone([{ title: 'Delete account', description: 'Permanently delete your account and all associated data. This cannot be undone.', action: 'Delete account' }])} + ${this.renderSectionCard('Account status', '', html`
Status
Your account is currently ${status}.
${status}
${this.globalAdmin ? html`
Global admin
You have platform-wide administrative access.
Admin
` : html``}`)}
`; } private renderSecurity(): TemplateResult { - const passkeys = ['MacBook Pro - Touch ID', 'iPhone 15 Pro - Face ID']; + const passportRows = this.passportDevices.map((deviceArg) => ({ + cells: [ + html` +
+ ${this.getInitials(deviceArg.label)} +
+
${deviceArg.label}
+
${deviceArg.platform}${deviceArg.appVersion ? ` - ${deviceArg.appVersion}` : ''}
+
+
+ `, + html`${deviceArg.status}`, + [ + deviceArg.capabilities?.push ? 'push' : '', + deviceArg.capabilities?.nfc ? 'nfc' : '', + deviceArg.capabilities?.gps ? 'gps' : '', + ].filter(Boolean).join(', ') || '-', + deviceArg.lastSeenAt ? this.formatTimeAgo(deviceArg.lastSeenAt) : 'never', + html`
`, + ], + })); + return html` ${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')} -
- ${this.renderSectionCard('Passkeys', 'Biometric or hardware-key authentication - phishing-resistant and passwordless.', html`${passkeys.map((passkeyArg, indexArg) => html`
${passkeyArg}
Added ${indexArg ? '3 days ago' : '10 days ago'} - Last used ${indexArg ? '1h ago' : '5m ago'}
`)}`, html``)} - ${this.renderSectionCard('Password', 'Update your login password. Use a strong, unique password.', html` - ${this.renderFormRow('Current password', '', html``)} - ${this.renderFormRow('New password', 'Minimum 8 characters', html``)} - ${this.renderFormRow('Confirm password', '', html``)} -
+
+ ${this.credentialMessage ? html`
Credential update
${this.credentialMessage}
` : html``} + ${this.credentialError ? html`
Credential error
${this.credentialError}
` : html``} + ${this.renderSectionCard('Session security', 'Live session data is available and sessions can be revoked from the device list.', html` +
Active sessions
Review and revoke sessions from the Sessions & Devices page.
${this.sessions.length}
+
+ `)} - ${this.renderSectionCard('Two-factor authentication', '', html`
Authenticator app (TOTP)
Generate one-time codes with an authenticator app.
Enabled
`)} + ${this.renderSectionCard('Password', 'Change your password using the existing production password endpoint. All fields are required.', html` + ${this.renderFormRow('Current password', '', html` this.setPasswordField('current', eventArg)} />`, true)} + ${this.renderFormRow('New password', 'Minimum 12 characters.', html` this.setPasswordField('new', eventArg)} />`, true)} + ${this.renderFormRow('Confirm password', '', html` this.setPasswordField('confirm', eventArg)} />`, true)} +
+ + `)} + +
+
+
Enroll passport device
Creates a signed enrollment challenge for the IDP Passport device flow.
+ +
+ ${this.passportEnrollment ? html` +
Enrollment challenge ready
Expires ${new Date(this.passportEnrollment.expiresAt).toLocaleString()}. Use the pairing token or payload in a passport client to complete enrollment.
+ ${this.renderFormRow('Pairing token', '', this.renderCodeBlock(this.passportEnrollment.pairingToken))} + ${this.renderFormRow('Pairing payload', '', this.renderCodeBlock(this.passportEnrollment.pairingPayload))} + ${this.renderFormRow('Signing payload', '', this.renderCodeBlock(this.passportEnrollment.signingPayload))} + ` : html`
No active enrollment challenge.
`} +
+
+ ${this.renderStateCard('TOTP controls not connected', 'No TOTP secret, enrollment, or verification endpoints exist in this backend yet, so no fake TOTP toggle is shown.', 'lock')} + ${this.renderStateCard('WebAuthn passkeys not connected', 'No WebAuthn passkey credential model or assertion endpoints exist yet. Passport devices are the available cryptographic credential path.', 'key')} +
`; } private renderSessions(): TemplateResult { - const sessions = [ - ['MacBook Pro 16"', 'Chrome 124 - macOS 14.4', '185.220.101.42', 'Current session'], - ['iPhone 15 Pro', 'Safari 17 - iOS 17.4', '185.220.101.42', ''], - ['iPad Air', 'Safari 17 - iPadOS 17.3', '91.64.18.227', ''], - ['Windows PC', 'Firefox 125 - Windows 11', '194.31.186.5', ''], - ]; return html` - ${this.renderPageHeader('Sessions & Devices', 'Active login sessions across all your devices.', html``)} -
${sessions.map((sessionArg) => html`
${sessionArg[0]}${sessionArg[3] ? html`${sessionArg[3]}` : html``}
${sessionArg[1]} - IP: ${sessionArg[2]}
Started 5m ago - Active just now
${sessionArg[3] ? html`` : html``}
`)}
+ ${this.renderPageHeader('Sessions & Devices', 'Active login sessions across all your devices.')} +
+ ${this.sessions.length ? this.sessions.map((sessionArg) => html` +
+ +
+
${sessionArg.deviceName || 'Unknown device'}${sessionArg.isCurrent ? html`Current session` : html``}
+
${sessionArg.browser || 'Unknown browser'} - ${sessionArg.os || 'Unknown OS'} - IP: ${sessionArg.ip || 'unknown'}
+
Started ${this.formatTimeAgo(sessionArg.createdAt)} - Active ${this.formatTimeAgo(sessionArg.lastActive)}
+
+ ${sessionArg.isCurrent ? html`` : html``} +
+ `) : this.renderDataState('No sessions', 'There are no active sessions for this account or session telemetry is unavailable.', 'monitor')} +
`; } private renderAccountApps(): TemplateResult { - const apps: TConnectedApp[] = [ - ['foss.global', ['Identity', 'Profile', 'Email'], 'Authorized 3 Apr 2026 - Last used 2h ago'], - ['task.vc', ['Identity', 'Profile', 'Email', 'Orgs (read)'], 'Authorized 19 Apr 2026 - Last used 1d ago'], - ['Acme HR Portal', ['Identity', 'Email'], 'Authorized 4 Mar 2026 - Last used 7d ago'], - ]; + const apps = this.accountApps; return html` ${this.renderPageHeader('Connected Apps', 'Third-party apps and services that have OAuth access to your account.')} -
${apps.map((appArg) => html`
${appArg[0].slice(0, 2).toUpperCase()}
${appArg[0]}
${(appArg[1] as string[]).map((scopeArg) => html`${scopeArg}`)}
${appArg[2]}
`)}
+
+ ${apps.length ? apps.map((appArg) => html`
${appArg.name.slice(0, 2).toUpperCase()}
${appArg.name}
${(appArg.scopes || []).map((scopeArg) => html`${scopeArg}`)}
${appArg.description || appArg.appUrl || 'Connected application'}
`) : this.renderDataState('Account app grants not connected', 'No account-level OAuth grant endpoint is wired into this app yet. Organisation app connections are managed under Organisation > OAuth Apps.', 'grid')} +
`; } private renderOrgGeneral(): TemplateResult { const org = this.currentOrg; + const connectedAppCount = this.orgApps.filter((appArg) => appArg.isConnected).length; + const orgActivities = this.activities + .filter((activityArg) => { + const searchableText = `${activityArg.targetType || ''} ${activityArg.description || ''}`.toLowerCase(); + return Boolean(org.slug && searchableText.includes(org.slug.toLowerCase())) + || Boolean(org.name && searchableText.includes(org.name.toLowerCase())) + || searchableText.includes('org') + || searchableText.includes('organization') + || searchableText.includes('organisation'); + }) + .slice(0, 5); + const kpis: TKpi[] = [ + { + label: 'Members', + value: String(this.orgMembers.length), + delta: `${this.orgInvitations.length} pending`, + deltaKind: 'up', + spark: [1, 1, 2, Math.max(this.orgMembers.length, 1)], + sparkColor: 'var(--idp-spark-up)', + accent: 'var(--idp-chart-5)', + sub: org.name, + }, + { + label: 'Connected apps', + value: String(connectedAppCount), + delta: `${this.orgApps.length} available`, + deltaKind: 'live', + spark: [1, 2, 2, Math.max(connectedAppCount, 1)], + sparkColor: 'var(--idp-spark-info)', + accent: 'var(--idp-info)', + sub: 'Organisation OAuth catalogue', + }, + { + label: 'Role', + value: org.myRole || 'member', + delta: 'live', + deltaKind: 'live', + spark: [1, 1, 1, 1], + sparkColor: 'var(--idp-spark-info)', + accent: 'var(--idp-chart-1)', + sub: 'Your access level', + }, + { + label: 'Activity', + value: String(orgActivities.length), + delta: `${this.activities.length} account events`, + deltaKind: 'up', + spark: [1, 1, Math.max(orgActivities.length, 1)], + sparkColor: 'var(--idp-spark-up)', + accent: 'var(--idp-chart-2)', + sub: 'Recent org-related events', + }, + ]; + return html` - ${this.renderPageHeader('Organisation Settings', 'General configuration for your organisation.')} +
+
+
Organisation - Generallive
+

${org.name}

+
Selected organisation dashboard for ${org.slug ? `@${org.slug}` : org.id || 'no organisation selected'}.
+
+
+
+
+
${kpis.map((kpiArg) => this.renderKpi(kpiArg))}
+ ${this.dataLoading || this.dataError ? this.renderDataState('No organisation data', 'The console has not received live organisation data yet.', 'building') : html``} +
+
+
Organisation profile${org.myRole || 'member'}
+
+
${org.name.slice(0, 2).toUpperCase()}
${org.name}
idp.global/org/${org.slug || 'unassigned'}
+ ${this.renderCodeBlock(org.id || 'No organisation selected')} +
Rename, slug updates, ownership transfer, and deletion are available from Settings with server-side audited confirmation.
+
+
+
+
Recent organisation activity${orgActivities.length} events
+ ${orgActivities.length ? orgActivities.map((activityArg) => html` +
${activityArg.action.replace(/_/g, ' ')} - ${activityArg.description}
${this.formatTimeAgo(activityArg.timestamp)}
+ `) : this.renderStateCard('No org activity yet', 'Organisation-related activity will appear here when the backend reports matching activity events.', 'activity')} +
+
+
+ `; + } + + private renderOrgSettings(): TemplateResult { + const org = this.currentOrg; + this.syncOrgSettingsState(); + const transferCandidates = this.orgMembers.filter((memberArg) => !memberArg.isCurrentUser); + const transferConfirmText = `transfer ${org.slug}`; + const deleteConfirmText = `delete ${org.slug}`; + const identityChanged = this.orgNameDraft.trim() !== org.name || this.orgSlugDraft.trim().toLowerCase() !== org.slug; + return html` + ${this.renderPageHeader('Organisation Settings', 'Audited configuration and owner-controlled destructive operations.')}
- ${this.renderSectionCard('', '', html`
${org.name.slice(0, 2).toUpperCase()}
${org.name}
idp.global/${org.slug}
${this.renderFormRow('Organisation name', '', html``, true)}${this.renderFormRow('URL slug', "Used in your org's public URLs. Changing this may break existing links.", html`
idp.global/org/
`)}
`)} + ${this.orgSettingsError ? html`
Settings action blocked
${this.orgSettingsError}
` : html``} + ${this.renderSectionCard('Organisation identity', `Type ${org.slug} to confirm name or slug changes. The backend verifies this confirmation before saving.`, html` +
${org.name.slice(0, 2).toUpperCase()}
${org.name}
idp.global/org/${org.slug}
+ ${this.renderFormRow('Organisation name', '', html` this.setOrgSettingsField('name', eventArg)} />`, true)} + ${this.renderFormRow('URL slug', "Used in your org's public URLs.", html`
idp.global/org/ this.setOrgSettingsField('slug', eventArg)} />
`)} + ${this.renderFormRow('Confirmation', `Type ${org.slug}`, html` this.setOrgSettingsField('settingsConfirmation', eventArg)} />`)} +
+ + `)} ${this.renderSectionCard('Organisation ID', 'Use this identifier when making API calls.', this.renderCodeBlock(org.id))} - ${this.renderDangerZone([{ title: 'Transfer ownership', description: 'Transfer this organisation to another user. You will lose admin access.', action: 'Transfer' }, { title: 'Delete organisation', description: 'Permanently deletes this organisation, its members, apps and billing. Cannot be undone.', action: 'Delete org' }])} + ${this.renderSectionCard('Transfer ownership', `Owner-only operation. Type ${transferConfirmText} to confirm.`, html` + ${transferCandidates.length ? html` + ${this.renderFormRow('New owner', 'The target user must already be an organisation member.', html` + + `, true)} + ${this.renderFormRow('Confirmation', `Type ${transferConfirmText}`, html` this.setOrgSettingsField('transferConfirmation', eventArg)} />`)} +
+ + ` : this.renderStateCard('No transfer candidates loaded', 'Load organisation members before transferring ownership.', 'users')} + `)} + ${this.renderSectionCard('Delete organisation', `Owner-only destructive operation. Type ${deleteConfirmText} to permanently remove this organisation, its memberships, pending invitations, billing records, and app connections.`, html` + ${this.renderFormRow('Confirmation', `Type ${deleteConfirmText}`, html` this.setOrgSettingsField('deleteConfirmation', eventArg)} />`)} +
+ + `)}
`; } private renderOrgMembers(): TemplateResult { - const members: Array<[name: string, email: string, role: string, joined: string]> = [ - ['Alex Mercer', 'alex@lossless.com', 'owner', 'Joined 120d ago'], - ['Jordan Kim', 'jordan@lossless.com', 'admin', 'Joined 90d ago'], - ['Sam Rivera', 'sam@lossless.com', 'editor', 'Joined 45d ago'], - ['Casey Novak', 'casey@lossless.com', 'viewer', 'Joined 10d ago'], - ['Riley Chen', 'riley@external.io', 'guest', 'Joined 2d ago'], - ]; - const invites: Array<[email: string, role: string, meta: string]> = [ - ['devops@partner.io', 'editor', 'Invited 3d ago - Expires 30 Jul 2026'], - ['audit@consulting.de', 'viewer', 'Invited 1d ago - Expires 1 Aug 2026'], - ]; + const customRoleRows = this.orgRoleDefinitions.map((roleArg) => ({ + cells: [ + html` +
+ ${this.getInitials(roleArg.name)} +
+
${roleArg.name}
+
${roleArg.description || 'Custom organisation role'}
+
+
+ `, + roleArg.key, + html` +
+ + +
+ `, + ], + })); + const memberRows = this.orgMembers.map((memberArg) => ({ + cells: [ + html` +
+ ${this.getInitials(memberArg.name || memberArg.email)} +
+
${memberArg.name || memberArg.email}
+
${memberArg.email}
+
+
+ `, + html`
${memberArg.roles.map((roleArg) => html`${roleArg}`)}
`, + memberArg.isCurrentUser ? html`You` : html`Member`, + memberArg.roles.includes('owner') || memberArg.isCurrentUser + ? html`
` + : html`
`, + ], + })); + const invitationRows = this.orgInvitations.map((inviteArg) => ({ + cells: [ + html` +
+ ${this.getInitials(inviteArg.email)} +
+
${inviteArg.email}
+
Invited ${this.formatTimeAgo(inviteArg.invitedAt)}
+
+
+ `, + html`
${inviteArg.roles.map((roleArg) => html`${roleArg}`)}
`, + new Date(inviteArg.expiresAt).toLocaleDateString(), + html` +
+ + +
+ `, + ], + })); + return html` - ${this.renderPageHeader('Members', '5 members - 2 pending invitations', html``)} + ${this.renderPageHeader('Members', `${this.orgMembers.length} members - ${this.orgInvitations.length} pending invitations`, html``)}
-
Members (5)Pending (2)
- ${this.renderSectionCard('', '', html`${members.map((memberArg, indexArg) => html` -
-
- ${memberArg[0].split(' ').map((partArg) => partArg[0]).join('')} -
${memberArg[0]}
${memberArg[1]}
-
- ${memberArg[3]} - ${memberArg[2]} -
- `)}`)} - ${this.renderSectionCard('Pending invitations', '', html`${invites.map((inviteArg, indexArg) => html` -
-
- -
${inviteArg[0]}
${inviteArg[2]}
-
- ${inviteArg[1]} - -
- `)}`)} + ${this.orgSettingsError ? html`
Role action blocked
${this.orgSettingsError}
` : html``} + +
+ + ${this.orgInvitations.length ? html` + + ` : html``}
`; } private renderOrgApps(): TemplateResult { - const apps: TOAuthApp[] = [ - ['Internal Dev Portal', 'ci_lossless_devportal_7Xa9', ['auth code'], 'OAuth client for our internal developer tools.'], - ['CI Pipeline Auth', 'ci_lossless_ci_4Kp2', ['client credentials'], 'Machine-to-machine auth for deployment pipelines.'], - ]; + const appRows = this.orgApps.map((appArg) => ({ + cells: [ + html` +
+ ${this.getInitials(appArg.name)} +
+
${appArg.name}
+
${appArg.description || appArg.appUrl || 'Global app'}
+
+
+ `, + appArg.clientId || '-', + html`
${(appArg.scopes || []).slice(0, 4).map((scopeArg) => html`${scopeArg}`)}
`, + html`${appArg.isConnected ? 'connected' : 'available'}`, + html`${appArg.roleMappings?.length || 0} mappings`, + html`
${appArg.isConnected ? html`` : html``}
`, + ], + })); + return html` - ${this.renderPageHeader('OAuth Apps', "Custom OIDC clients for your organisation's own apps and services.", html``)} -
${apps.map((appArg) => html`
${appArg[0].slice(0, 2).toUpperCase()}
${appArg[0]}
${appArg[3]}
${appArg[1]}
${(appArg[2] as string[]).map((grantArg) => html`${grantArg}`)}
`)}
${this.renderSectionCard('OAuth credentials', 'Use these to configure your application.', html`
Client ID
${this.renderCodeBlock('ci_lossless_devportal_7Xa9')}
Redirect URI
${this.renderCodeBlock('https://dev.lossless.com/auth/callback')}
`)}
+ ${this.renderPageHeader('Apps', "Global apps connected to this organisation.")} +
+ ${this.orgSettingsError ? html`
App mapping blocked
${this.orgSettingsError}
` : html``} + +
`; } @@ -1633,39 +2546,102 @@ export class IdpAdminShell extends DeesElement { ]; return html` ${this.renderPageHeader('Support', 'idp.global is free for everyone. Paid options cover hands-on recovery and consulting work.')} -
idp.global is free, forever
All platform features - authentication, passkeys, OIDC apps, team management - are included at no cost.
${services.map((serviceArg) => html`
${serviceArg[0]}- ${serviceArg[5]}
${serviceArg[1]}
${serviceArg[2]} ${serviceArg[3]}
`)}
+
idp.global is free, forever
All platform features - authentication, passkeys, OIDC apps, team management - are included at no cost.
${services.map((serviceArg) => html`
${serviceArg[0]}- ${serviceArg[5]}
${serviceArg[1]}
${serviceArg[2]} ${serviceArg[3]}
`)}
`; } private renderGAUsers(): TemplateResult { - const users = [ - ['Alex Mercer', 'alex@lossless.com', '2', 'active', 'Admin', '200d ago'], - ['Jordan Kim', 'jordan@lossless.com', '1', 'active', '', '100d ago'], - ['Sam Rivera', 'sam@lossless.com', '1', 'active', '', '45d ago'], - ['Dana Walsh', 'dana@suspended.de', '0', 'suspended', '', '60d ago'], - ['Morgan Lee', 'morgan@newuser.com', '0', 'new', '', '1d ago'], - ]; - return html`${this.renderPageHeader('All Users', '6 users on the platform')}
${users.map((userArg) => html``)}
UserEmailOrgsStatusAdminJoined
${userArg[0].split(' ').map((partArg) => partArg[0]).join('')}${userArg[0]}
${userArg[1]}${userArg[2]}${userArg[3]}${userArg[4] ? html`${userArg[4]}` : html`-`}${userArg[5]}
`; + return html`${this.renderPageHeader('All Users', 'Platform-wide user administration.')}
${this.renderDataState('User directory not connected', 'The shell is ready for global user data, but no platform user-list endpoint is wired into this app yet. No demo users are shown in production mode.', 'users')}
`; } private renderGAOrgs(): TemplateResult { - const orgs = [['Lossless GmbH', 'lossless', '5', 'Pro', 'active', '200d ago'], ['Task VC', 'task', '3', 'Pro', 'active', '140d ago'], ['Demo Org', 'demo', '1', 'FairUsageFree', 'active', '5d ago'], ['Suspended Co.', 'suspended', '2', 'Pro', 'suspended', '80d ago']]; - return html`${this.renderPageHeader('All Organisations', '4 organisations')}
${orgs.map((orgArg) => html``)}
OrganisationSlugMembersPlanStatusCreated
${orgArg[0].slice(0,2).toUpperCase()}${orgArg[0]}
${orgArg[1]}${orgArg[2]}${orgArg[3]}${orgArg[4]}${orgArg[5]}
`; + const orgRows = this.orgs.map((orgArg) => ({ + cells: [ + html` +
+ ${this.getInitials(orgArg.name)} +
+
${orgArg.name}
+
${orgArg.slug ? `idp.global/org/${orgArg.slug}` : 'No slug'}
+
+
+ `, + orgArg.slug || '-', + html`${orgArg.myRole || 'member'}`, + orgArg.id, + ], + })); + + return html` + ${this.renderPageHeader('All Organisations', `${this.orgs.length} organisations visible to this admin session`)} +
+ +
+ `; } private renderGAApps(): TemplateResult { - const apps = [['foss.global', 'global', 'Productivity', '-', 'active'], ['task.vc', 'global', 'Productivity', '-', 'active'], ['Acme HR', 'partner', 'HR', '412', 'approved'], ['DevOps Suite', 'partner', 'DevOps', '87', 'pending_review']]; - return html`${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}
${apps.map((appArg) => html``)}
AppTypeCategoryInstallsStatus
${appArg[0].slice(0,2).toUpperCase()}${appArg[0]}
${appArg[1]}${appArg[2]}${appArg[3]}${appArg[4].replace('_', ' ')}
`; + const apps = this.adminApps.length ? this.adminApps : this.orgApps; + const appRows = apps.map((appArg) => ({ + cells: [ + html` +
+ ${this.getInitials(appArg.name)} +
+
${appArg.name}
+
${appArg.description || appArg.appUrl || 'Platform app'}
+
+
+ `, + html`${appArg.type || 'global'}`, + appArg.category || '-', + appArg.connectionCount ?? '-', + html`${appArg.status || 'active'}`, + ], + })); + + return html` + ${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')} +
+ +
+ `; } private renderMainContent(): TemplateResult { - const renderers: Record TemplateResult> = { + const renderers: Record TemplateResult> = { overview: () => this.renderOverview(), profile: () => this.renderProfile(), security: () => this.renderSecurity(), sessions: () => this.renderSessions(), apps: () => this.renderAccountApps(), 'org-general': () => this.renderOrgGeneral(), + 'org-settings': () => this.renderOrgSettings(), 'org-members': () => this.renderOrgMembers(), 'org-apps': () => this.renderOrgApps(), support: () => this.renderSupport(), @@ -1678,10 +2654,11 @@ export class IdpAdminShell extends DeesElement { public render(): TemplateResult { return html` -
+
${this.renderSidebar()}
${this.renderMainContent()}
+ ${this.renderDialog()} `; } } diff --git a/ts_web/elements/idp-button.ts b/ts_web/elements/idp-button.ts index dde14a0..9b4a96a 100644 --- a/ts_web/elements/idp-button.ts +++ b/ts_web/elements/idp-button.ts @@ -40,9 +40,15 @@ export class IdpButton extends DeesElement { @property({ type: String }) public accessor icon = ''; + @property({ type: String }) + public accessor type: 'button' | 'submit' | 'reset' = 'button'; + @property({ type: Boolean, reflect: true }) public accessor disabled = false; + @property({ type: Boolean, reflect: true }) + public accessor loading = false; + public static styles = [ ...idpElementStyles, css` @@ -101,7 +107,7 @@ export class IdpButton extends DeesElement { } .accent { background: var(--idp-accent); - color: #fff; + color: var(--idp-accent-fg); box-shadow: 0 4px 14px color-mix(in srgb, var(--idp-accent), transparent 64%); } .accent:hover:not(:disabled) { @@ -126,18 +132,33 @@ export class IdpButton extends DeesElement { } .destructive { background: var(--idp-destructive); - color: #fff; + color: var(--idp-accent-fg); } idp-icon { flex: 0 0 auto; } + .spinner { + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 999px; + animation: spin 700ms linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } `, ]; public render(): TemplateResult { return html` - `; diff --git a/ts_web/elements/idp-checkbox.ts b/ts_web/elements/idp-checkbox.ts new file mode 100644 index 0000000..b4082c9 --- /dev/null +++ b/ts_web/elements/idp-checkbox.ts @@ -0,0 +1,143 @@ +import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element'; +import { idpElementStyles } from './tokens.js'; + +declare global { + interface HTMLElementTagNameMap { + 'idp-checkbox': IdpCheckbox; + } +} + +@customElement('idp-checkbox') +export class IdpCheckbox extends DeesElement { + public static demo = () => html``; + public static demoGroups = ['idp.global v3 primitives']; + + @property({ type: String }) + public accessor label = ''; + + @property({ type: String }) + public accessor name = ''; + + @property({ type: String }) + public accessor key = ''; + + @property({ type: String }) + public accessor value = 'on'; + + @property({ type: String }) + public accessor hint = ''; + + @property({ type: String }) + public accessor error = ''; + + @property({ type: Boolean, reflect: true }) + public accessor checked = false; + + @property({ type: Boolean, reflect: true }) + public accessor required = false; + + @property({ type: Boolean, reflect: true }) + public accessor disabled = false; + + public static styles = [ + ...idpElementStyles, + css` + :host { + display: block; + } + label { + display: grid; + grid-template-columns: 18px 1fr; + gap: 10px; + align-items: start; + color: var(--idp-fg); + font-size: 13px; + line-height: 1.4; + cursor: pointer; + } + input { + appearance: none; + width: 18px; + height: 18px; + margin: 0; + border: 1px solid var(--idp-border); + border-radius: 5px; + background: var(--idp-card); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + } + input:checked { + border-color: var(--idp-accent); + background: var(--idp-accent); + box-shadow: inset 0 0 0 3px var(--idp-card); + } + input:focus-visible { + outline: 2px solid color-mix(in srgb, var(--idp-accent), transparent 68%); + outline-offset: 2px; + } + input[aria-invalid='true'] { + border-color: var(--idp-destructive); + } + input:disabled, + :host([disabled]) label { + cursor: not-allowed; + opacity: 0.55; + } + .copy { + display: grid; + gap: 4px; + } + .hint, + .error { + color: var(--idp-muted-fg); + font-size: 12px; + } + .error { + color: var(--idp-destructive); + } + `, + ]; + + public validate() { + if (this.required && !this.checked) { + this.error = `${this.label || this.name || 'This field'} is required.`; + return false; + } + + this.error = ''; + return true; + } + + private handleChange(eventArg: Event) { + this.checked = (eventArg.target as HTMLInputElement).checked; + if (this.error) { + this.validate(); + } + this.dispatchEvent(new CustomEvent('idp-checkbox-change', { + detail: { name: this.name || this.key, key: this.key || this.name, checked: this.checked, value: this.value }, + bubbles: true, + composed: true, + })); + } + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_web/elements/idp-dashboard-window.ts b/ts_web/elements/idp-dashboard-window.ts index 6e101e0..c3d80c1 100644 --- a/ts_web/elements/idp-dashboard-window.ts +++ b/ts_web/elements/idp-dashboard-window.ts @@ -3,6 +3,7 @@ import { idpElementStyles } from './tokens.js'; import './idp-badge.js'; import './idp-button.js'; import './idp-icon.js'; +import './idp-data-table.js'; type TDashboardStat = { label: string; @@ -24,7 +25,7 @@ declare global { @customElement('idp-dashboard-window') export class IdpDashboardWindow extends DeesElement { - public static demo = () => html``; + public static demo = () => html``; public static demoGroups = ['idp.global v3 composed surfaces']; public static styles = [ @@ -139,7 +140,7 @@ export class IdpDashboardWindow extends DeesElement { place-items: center; border-radius: 4px; background: var(--idp-accent); - color: #fff; + color: var(--idp-accent-fg); font-family: var(--idp-mono); font-size: 10px; font-weight: 700; @@ -340,51 +341,6 @@ export class IdpDashboardWindow extends DeesElement { font-size: 13px; font-weight: 600; } - table { - width: 100%; - border-collapse: collapse; - } - th, td { - padding: 10px 16px; - border-bottom: 1px solid var(--idp-border-soft); - text-align: left; - font-size: 12.5px; - } - th { - color: color-mix(in srgb, var(--idp-muted-fg), transparent 35%); - font-family: var(--idp-mono); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - } - .user { - display: flex; - align-items: center; - gap: 8px; - } - .row-avatar { - width: 22px; - height: 22px; - display: inline-grid; - place-items: center; - border: 1px solid var(--idp-border); - border-radius: 50%; - background: var(--idp-card-2); - color: var(--idp-accent-hover); - font-family: var(--idp-mono); - font-size: 9.5px; - font-weight: 700; - } - .row-name { - color: var(--idp-fg); - font-weight: 500; - } - .row-email, .dim { - color: var(--idp-muted-fg); - font-family: var(--idp-mono); - font-size: 11px; - } .feed-item { display: grid; grid-template-columns: 14px 1fr auto; @@ -450,9 +406,6 @@ export class IdpDashboardWindow extends DeesElement { align-items: flex-start; flex-direction: column; } - th:nth-child(3), td:nth-child(3), th:nth-child(4), td:nth-child(4) { - display: none; - } } `, ]; @@ -522,8 +475,25 @@ export class IdpDashboardWindow extends DeesElement { } public render(): TemplateResult { + const approvalRows = this.approvals.map((rowArg) => ({ + cells: [ + html` +
+ ${rowArg[0].slice(0, 2).toUpperCase()} +
+
${rowArg[0]}
+
${rowArg[1]}
+
+
+ `, + rowArg[2], + rowArg[3], + html`${rowArg[4]}`, + ], + })); + return html` -
+
console.idp.global / dashboard @@ -561,22 +531,23 @@ export class IdpDashboardWindow extends DeesElement { ${this.stats.map((statArg) => this.renderStat(statArg))}
-
-
Recent approvals142 total
- - - - ${this.approvals.map((rowArg) => html` - - - - - - - `)} - -
UserActionDeviceStatus
${rowArg[0].slice(0, 2).toUpperCase()}
${rowArg[0]}
${rowArg[1]}
${rowArg[2]}${rowArg[3]}${rowArg[4]}
-
+
Cardano feedlive
${this.feed.map((itemArg) => html` diff --git a/ts_web/elements/idp-data-table.ts b/ts_web/elements/idp-data-table.ts new file mode 100644 index 0000000..3fda38d --- /dev/null +++ b/ts_web/elements/idp-data-table.ts @@ -0,0 +1,407 @@ +import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element'; +import { idpElementStyles } from './tokens.js'; +import './idp-badge.js'; + +export type TIdpDataTableCell = TemplateResult | string | number | null | undefined; + +export interface IIdpDataTableColumn { + label: string; + width?: string; + align?: 'left' | 'center' | 'right'; + mono?: boolean; + hideBelow?: 'mobile' | 'tablet'; +} + +export interface IIdpDataTableRow { + id?: string; + cells: TIdpDataTableCell[]; +} + +export interface IIdpDataTableTab { + id: string; + label: string; + count?: string | number; +} + +export interface IIdpDataTableTabSelectEventDetail { + tabId: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'idp-data-table': IdpDataTable; + } +} + +@customElement('idp-data-table') +export class IdpDataTable extends DeesElement { + public static demo = () => html` + EU
Example User
user@example.com
`, + 'OAuth grant', + 'Desktop', + html`approved`, + '2m ago', + ], + }, + ]} + > + `; + public static demoGroups = ['idp.global v3 primitives']; + + @property({ type: String }) + public accessor title = ''; + + @property({ type: String }) + public accessor subtitle = ''; + + @property({ type: String }) + public accessor badge = ''; + + @property({ type: String, attribute: 'selected-tab' }) + public accessor selectedTab = ''; + + @property({ type: String, attribute: 'empty-title' }) + public accessor emptyTitle = 'No rows'; + + @property({ type: String, attribute: 'empty-description' }) + public accessor emptyDescription = 'There is no data to display yet.'; + + @property({ type: Array }) + public accessor columns: IIdpDataTableColumn[] = []; + + @property({ type: Array }) + public accessor rows: IIdpDataTableRow[] = []; + + @property({ type: Array }) + public accessor tabs: IIdpDataTableTab[] = []; + + public static styles = [ + ...idpElementStyles, + css` + :host { + display: block; + } + .table-card { + overflow: hidden; + border: 1px solid var(--idp-border); + border-radius: 8px; + background: var(--idp-card); + } + .table-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--idp-border-soft); + } + .title-wrap { + min-width: 0; + } + .title-row { + display: flex; + align-items: center; + gap: 10px; + } + .title { + color: var(--idp-fg); + font-size: 13px; + font-weight: 600; + } + .subtitle { + margin-top: 2px; + color: var(--idp-muted-fg); + font-size: 11.5px; + } + .count-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid var(--idp-border); + border-radius: 999px; + background: var(--idp-muted); + color: var(--idp-muted-fg); + font-family: var(--idp-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 16px; + } + .tabs { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + } + .tab { + padding: 4px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--idp-muted-fg); + cursor: pointer; + font-family: var(--idp-font); + font-size: 11px; + } + .tab:hover, + .tab.active { + background: var(--idp-bg-2); + color: var(--idp-fg); + } + .table-scroll { + overflow-x: auto; + } + table { + width: 100%; + border-collapse: collapse; + } + th, + td { + border-bottom: 1px solid var(--idp-border-soft); + text-align: left; + vertical-align: middle; + } + th { + padding: 9px 16px; + background: var(--idp-muted); + color: var(--idp-fg-3); + font-family: var(--idp-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + } + td { + padding: 10px 16px; + color: var(--idp-fg-2); + font-size: 12.5px; + } + tbody tr:last-child td { + border-bottom: 0; + } + tbody tr:hover td { + background: var(--idp-bg-2); + } + .align-center { + text-align: center; + } + .align-right { + text-align: right; + } + .mono-cell { + color: var(--idp-muted-fg); + font-family: var(--idp-mono); + font-size: 11.5px; + } + .identity-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 190px; + } + .identity-avatar { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid var(--idp-border); + border-radius: 999px; + background: var(--idp-bg-2); + color: var(--avatar-color, var(--idp-accent)); + font-family: var(--idp-mono); + font-size: 9.5px; + font-weight: 600; + } + .identity-primary { + color: var(--idp-fg); + font-size: 12.5px; + font-weight: 500; + } + .identity-secondary, + .cell-secondary { + margin-top: 1px; + color: var(--idp-muted-fg); + font-size: 11px; + line-height: 1.35; + } + .chip-row, + .cell-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .cell-actions { + justify-content: flex-end; + } + .table-action { + min-height: 28px; + padding: 5px 10px; + border: 1px solid var(--idp-border); + border-radius: 7px; + background: transparent; + color: var(--idp-fg); + cursor: pointer; + font-family: var(--idp-font); + font-size: 12px; + } + .table-action:hover { + background: var(--idp-bg-2); + } + .table-action.destructive { + border-color: var(--idp-error-border); + color: var(--idp-error); + } + .table-action.primary { + border-color: var(--idp-accent); + background: var(--idp-accent); + color: var(--idp-accent-fg); + } + .empty-state { + padding: 32px 20px; + color: var(--idp-muted-fg); + text-align: center; + } + .empty-title { + color: var(--idp-fg); + font-size: 13px; + font-weight: 600; + } + .empty-description { + max-width: 420px; + margin: 6px auto 0; + font-size: 12px; + line-height: 1.5; + } + @media (max-width: 900px) { + .hide-tablet { + display: none; + } + } + @media (max-width: 720px) { + .table-head { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .tabs { + width: 100%; + margin-left: 0; + overflow-x: auto; + } + th, + td { + padding-left: 16px; + padding-right: 16px; + } + .hide-mobile { + display: none; + } + } + `, + ]; + + private selectTab(tabIdArg: string) { + this.selectedTab = tabIdArg; + this.dispatchEvent(new CustomEvent('idp-data-table-tab-select', { + detail: { tabId: tabIdArg }, + bubbles: true, + composed: true, + })); + } + + private columnClass(columnArg: IIdpDataTableColumn): string { + const classes = [ + columnArg.align === 'center' ? 'align-center' : '', + columnArg.align === 'right' ? 'align-right' : '', + columnArg.hideBelow === 'mobile' ? 'hide-mobile' : '', + columnArg.hideBelow === 'tablet' ? 'hide-tablet' : '', + ]; + return classes.filter(Boolean).join(' '); + } + + private renderCell(contentArg: TIdpDataTableCell, columnArg: IIdpDataTableColumn): TemplateResult { + const content = contentArg === null || contentArg === undefined || contentArg === '' ? '-' : contentArg; + return columnArg.mono ? html`${content}` : html`${content}`; + } + + public render(): TemplateResult { + const selectedTab = this.selectedTab || this.tabs[0]?.id || ''; + return html` +
+ ${this.title || this.badge || this.tabs.length ? html` +
+
+
+ ${this.title ? html`
${this.title}
` : html``} + ${this.badge ? html`${this.badge}` : html``} +
+ ${this.subtitle ? html`
${this.subtitle}
` : html``} +
+ ${this.tabs.length ? html` +
+ ${this.tabs.map((tabArg) => html` + + `)} +
+ ` : html``} +
+ ` : html``} + ${this.rows.length ? html` +
+ + + + ${this.columns.map((columnArg) => html` + + `)} + + + + ${this.rows.map((rowArg) => html` + + ${this.columns.map((columnArg, indexArg) => html` + + `)} + + `)} + +
${columnArg.label}
${this.renderCell(rowArg.cells[indexArg], columnArg)}
+
+ ` : html` +
+
${this.emptyTitle}
+
${this.emptyDescription}
+
+ `} +
+ `; + } +} diff --git a/ts_web/elements/idp-form-submit.ts b/ts_web/elements/idp-form-submit.ts new file mode 100644 index 0000000..65e2d24 --- /dev/null +++ b/ts_web/elements/idp-form-submit.ts @@ -0,0 +1,65 @@ +import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element'; +import { idpElementStyles } from './tokens.js'; +import './idp-button.js'; +import type { TIdpButtonSize, TIdpButtonVariant } from './idp-button.js'; + +declare global { + interface HTMLElementTagNameMap { + 'idp-form-submit': IdpFormSubmit; + } +} + +@customElement('idp-form-submit') +export class IdpFormSubmit extends DeesElement { + public static demo = () => html`Continue`; + public static demoGroups = ['idp.global v3 primitives']; + + @property({ type: String }) + public accessor text = ''; + + @property({ type: String }) + public accessor variant: TIdpButtonVariant = 'accent'; + + @property({ type: String }) + public accessor size: TIdpButtonSize = 'md'; + + @property({ type: Boolean, reflect: true }) + public accessor disabled = false; + + @property({ type: Boolean, reflect: true }) + public accessor loading = false; + + public static styles = [ + ...idpElementStyles, + css` + :host { + display: block; + } + idp-button { + width: 100%; + } + `, + ]; + + private handleClick() { + if (this.disabled || this.loading) { + return; + } + this.dispatchEvent(new CustomEvent('idp-form-submit-request', { + bubbles: true, + composed: true, + })); + } + + public render(): TemplateResult { + return html` + ${this.text || html``} + `; + } +} diff --git a/ts_web/elements/idp-form.ts b/ts_web/elements/idp-form.ts new file mode 100644 index 0000000..7484087 --- /dev/null +++ b/ts_web/elements/idp-form.ts @@ -0,0 +1,159 @@ +import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element'; +import { idpElementStyles } from './tokens.js'; +import './idp-input.js'; +import './idp-checkbox.js'; + +export type TIdpFormStatus = 'idle' | 'pending' | 'success' | 'error'; +export type TIdpFormData = Record; + +export interface IIdpFormSubmitEventDetail { + data: TIdpFormData; + form: IdpForm; +} + +type TFormControl = HTMLElement & { + name?: string; + key?: string; + value?: string; + checked?: boolean; + validate?: () => boolean; +}; + +declare global { + interface HTMLElementTagNameMap { + 'idp-form': IdpForm; + } +} + +@customElement('idp-form') +export class IdpForm extends DeesElement { + public static demo = () => html` + + + Continue + + `; + public static demoGroups = ['idp.global v3 primitives']; + + @property({ type: String, reflect: true }) + public accessor status: TIdpFormStatus = 'idle'; + + @property({ type: String }) + public accessor statusMessage = ''; + + @property({ type: Boolean, reflect: true }) + public accessor disabled = false; + + public static styles = [ + ...idpElementStyles, + css` + :host { + display: block; + } + form { + display: flex; + flex-direction: column; + gap: 16px; + } + ::slotted(idp-form-submit) { + margin-top: 2px; + } + .status { + padding: 11px 13px; + border-radius: 10px; + border: 1px solid var(--idp-border); + background: var(--idp-muted); + color: var(--idp-muted-fg); + font-size: 13px; + line-height: 1.45; + } + .status.pending { + border-color: color-mix(in srgb, var(--idp-accent), transparent 60%); + color: var(--idp-accent); + } + .status.success { + border-color: color-mix(in srgb, var(--idp-ok), transparent 60%); + color: var(--idp-ok); + } + .status.error { + border-color: color-mix(in srgb, var(--idp-destructive), transparent 60%); + color: var(--idp-destructive); + } + `, + ]; + + public setStatus(statusArg: TIdpFormStatus, messageArg = '') { + this.status = statusArg; + this.statusMessage = messageArg; + } + + public resetStatus() { + this.setStatus('idle', ''); + } + + public submit() { + this.handleSubmit(); + } + + private getControls() { + return Array.from(this.querySelectorAll('idp-input, idp-checkbox')) as TFormControl[]; + } + + private validateControls() { + return this.getControls().every((controlArg) => controlArg.validate ? controlArg.validate() : true); + } + + private getFormData(): TIdpFormData { + const data: TIdpFormData = {}; + for (const control of this.getControls()) { + const name = control.name || control.key; + if (!name) { + continue; + } + + if (typeof control.checked === 'boolean') { + data[name] = control.checked; + } else { + data[name] = control.value || ''; + } + } + return data; + } + + private handleSubmitRequest(eventArg: Event) { + eventArg.preventDefault(); + eventArg.stopPropagation(); + this.handleSubmit(); + } + + private handleSubmit(eventArg?: Event) { + eventArg?.preventDefault(); + if (this.disabled || this.status === 'pending') { + return; + } + if (!this.validateControls()) { + this.setStatus('error', 'Please check the highlighted fields.'); + return; + } + + this.dispatchEvent(new CustomEvent('idp-submit', { + detail: { + data: this.getFormData(), + form: this, + }, + bubbles: true, + composed: true, + })); + } + + public render(): TemplateResult { + return html` +
+ + ${this.statusMessage + ? html`
${this.statusMessage}
` + : html``} +
+ `; + } +} diff --git a/ts_web/elements/idp-input.ts b/ts_web/elements/idp-input.ts index aef5c74..b613d62 100644 --- a/ts_web/elements/idp-input.ts +++ b/ts_web/elements/idp-input.ts @@ -15,6 +15,12 @@ export class IdpInput extends DeesElement { @property({ type: String }) public accessor label = ''; + @property({ type: String }) + public accessor name = ''; + + @property({ type: String }) + public accessor key = ''; + @property({ type: String }) public accessor hint = ''; @@ -27,6 +33,15 @@ export class IdpInput extends DeesElement { @property({ type: String }) public accessor type = 'text'; + @property({ type: String }) + public accessor autocomplete = ''; + + @property({ type: String }) + public accessor error = ''; + + @property({ type: Boolean, reflect: true }) + public accessor required = false; + @property({ type: Boolean, reflect: true }) public accessor disabled = false; @@ -66,18 +81,49 @@ export class IdpInput extends DeesElement { input:disabled { opacity: 0.5; } - .hint { + input[aria-invalid='true'] { + border-color: var(--idp-destructive); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--idp-destructive), transparent 86%); + } + .hint, + .error { color: var(--idp-muted-fg); font-size: 12px; line-height: 1.4; } + .error { + color: var(--idp-destructive); + } `, ]; + public focus() { + this.shadowRoot?.querySelector('input')?.focus(); + } + + public validate() { + const input = this.shadowRoot?.querySelector('input'); + if (this.required && !this.value.trim()) { + this.error = `${this.label || this.name || 'This field'} is required.`; + return false; + } + + if (input && !input.checkValidity()) { + this.error = input.validationMessage; + return false; + } + + this.error = ''; + return true; + } + private handleInput(eventArg: Event) { this.value = (eventArg.target as HTMLInputElement).value; + if (this.error) { + this.validate(); + } this.dispatchEvent(new CustomEvent('idp-input-change', { - detail: { value: this.value }, + detail: { name: this.name || this.key, key: this.key || this.name, value: this.value }, bubbles: true, composed: true, })); @@ -89,12 +135,16 @@ export class IdpInput extends DeesElement { ${this.label ? html`${this.label}` : html``} - ${this.hint ? html`${this.hint}` : html``} + ${this.error ? html`${this.error}` : this.hint ? html`${this.hint}` : html``} `; } diff --git a/ts_web/elements/idp-landing-hero.ts b/ts_web/elements/idp-landing-hero.ts index ebab24f..4b7ff65 100644 --- a/ts_web/elements/idp-landing-hero.ts +++ b/ts_web/elements/idp-landing-hero.ts @@ -24,14 +24,14 @@ export class IdpLandingHero extends DeesElement { .hero { position: relative; overflow: hidden; - background: #0a0a0a; - color: #fafafa; - border-bottom: 1px solid #1c1c1c; + background: var(--idp-bg); + color: var(--idp-fg); + border-bottom: 1px solid var(--idp-border-soft); } .grid { position: absolute; inset: 0; - background-image: linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px); + background-image: linear-gradient(to right, color-mix(in srgb, var(--idp-fg), transparent 97%) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--idp-fg), transparent 97%) 1px, transparent 1px); background-size: 56px 56px; mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 70%); pointer-events: none; @@ -60,17 +60,17 @@ export class IdpLandingHero extends DeesElement { gap: 7px; margin-bottom: 28px; padding: 5px 12px 5px 8px; - border: 1px solid #262626; + border: 1px solid var(--idp-border); border-radius: 999px; - background: rgba(255,255,255,0.04); - color: hsl(0 0% 70%); + background: var(--idp-muted); + color: var(--idp-fg-3); font-size: 12px; } .pill { padding: 2px 7px; border-radius: 999px; background: rgba(59,130,246,0.18); - color: #60a5fa; + color: var(--idp-accent-hover); font-family: var(--idp-mono); font-size: 10px; font-weight: 600; @@ -85,7 +85,7 @@ export class IdpLandingHero extends DeesElement { line-height: 0.96; } h1 em { - color: #60a5fa; + color: var(--idp-accent-hover); font-family: var(--idp-serif); font-style: italic; font-weight: 400; @@ -93,7 +93,7 @@ export class IdpLandingHero extends DeesElement { .sub { max-width: 660px; margin: 0 auto 36px; - color: hsl(0 0% 70%); + color: var(--idp-fg-3); font-size: clamp(16px, 1.6vw, 19px); line-height: 1.55; } @@ -106,7 +106,7 @@ export class IdpLandingHero extends DeesElement { margin-bottom: 20px; } .fineprint { - color: hsl(0 0% 28%); + color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 11px; letter-spacing: 0.04em; @@ -156,7 +156,7 @@ export class IdpLandingHero extends DeesElement {
MIT licensedSelf-hostableNo credit cardCardano mainnet
- +
diff --git a/ts_web/elements/idp-landing-page.ts b/ts_web/elements/idp-landing-page.ts index 5131046..cc98343 100644 --- a/ts_web/elements/idp-landing-page.ts +++ b/ts_web/elements/idp-landing-page.ts @@ -21,19 +21,6 @@ export class IdpLandingPage extends DeesElement { css` :host { display: block; - --idp-bg: #0a0a0a; - --idp-bg-2: #111111; - --idp-card: #121212; - --idp-card-2: #161616; - --idp-fg: #fafafa; - --idp-fg-2: #d4d4d8; - --idp-fg-3: hsl(0 0% 70%); - --idp-muted-fg: hsl(0 0% 55%); - --idp-border: #262626; - --idp-border-soft: #1c1c1c; - --idp-border-strong: #333333; - --idp-accent: #3b82f6; - --idp-accent-hover: #60a5fa; background: var(--idp-bg); color: var(--idp-fg); } @@ -54,7 +41,7 @@ export class IdpLandingPage extends DeesElement { margin: 0 auto; padding: 0 32px; border-bottom: 1px solid var(--idp-border-soft); - background: rgba(10,10,10,0.86); + background: color-mix(in srgb, var(--idp-bg), transparent 14%); backdrop-filter: blur(14px) saturate(140%); } .nav-shell { @@ -62,7 +49,7 @@ export class IdpLandingPage extends DeesElement { top: 0; z-index: 20; border-bottom: 1px solid var(--idp-border-soft); - background: rgba(10,10,10,0.86); + background: color-mix(in srgb, var(--idp-bg), transparent 14%); } .logo { display: inline-flex; @@ -93,7 +80,7 @@ export class IdpLandingPage extends DeesElement { text-decoration: none; } .links a:hover { - background: rgba(255,255,255,0.04); + background: var(--idp-muted); color: var(--idp-fg); } .status { @@ -288,7 +275,7 @@ export class IdpLandingPage extends DeesElement { border: 1px solid var(--idp-border); border-radius: 10px; padding: 20px; - background: linear-gradient(140deg, #1a1a1a 0%, #0a0a0a 100%); + background: linear-gradient(140deg, var(--idp-card) 0%, var(--idp-bg) 100%); } .identity-card::after { content: ''; @@ -416,7 +403,7 @@ export class IdpLandingPage extends DeesElement { } .chain-block.idp { border-left: 2px solid var(--idp-accent); - background: rgba(0,80,185,0.08); + background: var(--idp-accent-soft); } .tiers { display: grid; @@ -431,7 +418,7 @@ export class IdpLandingPage extends DeesElement { } .tier.featured { border-color: var(--idp-accent); - background: linear-gradient(180deg, rgba(59,130,246,0.06) 0%, var(--idp-bg-2) 40%); + background: linear-gradient(180deg, var(--idp-accent-soft) 0%, var(--idp-bg-2) 40%); } .tier-name { margin-bottom: 10px; diff --git a/ts_web/elements/idp-mobile-showcase.ts b/ts_web/elements/idp-mobile-showcase.ts index 5541e74..09bd64e 100644 --- a/ts_web/elements/idp-mobile-showcase.ts +++ b/ts_web/elements/idp-mobile-showcase.ts @@ -25,8 +25,8 @@ export class IdpMobileShowcase extends DeesElement { .showcase { min-height: 100vh; padding: 56px; - background: radial-gradient(circle at 1px 1px, rgba(0,0,0,0.08) 1px, transparent 0) 0 0 / 24px 24px, #fafafa; - color: #09090b; + background: radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--idp-fg), transparent 92%) 1px, transparent 0) 0 0 / 24px 24px, var(--idp-bg); + color: var(--idp-fg); } .head { max-width: 1180px; @@ -38,10 +38,10 @@ export class IdpMobileShowcase extends DeesElement { gap: 6px; margin-bottom: 16px; padding: 4px 10px; - border: 1px solid #e4e4e7; + border: 1px solid var(--idp-border); border-radius: 999px; - background: #fff; - color: #52525b; + background: var(--idp-card); + color: var(--idp-fg-3); font-size: 11px; font-weight: 500; } @@ -49,7 +49,7 @@ export class IdpMobileShowcase extends DeesElement { width: 6px; height: 6px; border-radius: 50%; - background: #16a34a; + background: var(--idp-ok); } h1 { max-width: 900px; @@ -63,7 +63,7 @@ export class IdpMobileShowcase extends DeesElement { p { max-width: 680px; margin: 0; - color: #52525b; + color: var(--idp-fg-3); font-size: 16px; line-height: 1.55; } @@ -73,13 +73,13 @@ export class IdpMobileShowcase extends DeesElement { gap: 24px; margin-top: 24px; padding: 16px; - border: 1px solid #e4e4e7; + border: 1px solid var(--idp-border); border-radius: 12px; - background: #fff; + background: var(--idp-card); } .token-label { margin-bottom: 4px; - color: #71717a; + color: var(--idp-muted-fg); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; @@ -89,7 +89,7 @@ export class IdpMobileShowcase extends DeesElement { display: flex; align-items: center; gap: 8px; - color: #18181b; + color: var(--idp-fg); font-size: 13px; font-weight: 550; } @@ -98,7 +98,7 @@ export class IdpMobileShowcase extends DeesElement { height: 18px; border-radius: 5px; background: var(--swatch); - border: 1px solid #e4e4e7; + border: 1px solid var(--idp-border); } .section { max-width: 1180px; @@ -106,7 +106,7 @@ export class IdpMobileShowcase extends DeesElement { } .section-title { margin-bottom: 18px; - color: #71717a; + color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 12px; font-weight: 700; @@ -126,9 +126,9 @@ export class IdpMobileShowcase extends DeesElement { align-items: start; } .watch, .ipad, .mac { - border: 1px solid #e4e4e7; - background: #fff; - box-shadow: 0 20px 50px rgba(0,0,0,0.08); + border: 1px solid var(--idp-border); + background: var(--idp-card); + box-shadow: 0 20px 50px color-mix(in srgb, var(--idp-fg), transparent 92%); } .watch { width: 236px; @@ -188,8 +188,8 @@ export class IdpMobileShowcase extends DeesElement { } .ipad-sidebar { padding: 18px; - border-right: 1px solid #e4e4e7; - background: #f8f8f7; + border-right: 1px solid var(--idp-border); + background: var(--idp-card-2); } .ipad-main { padding: 22px; @@ -200,8 +200,9 @@ export class IdpMobileShowcase extends DeesElement { gap: 10px; margin-top: 16px; padding: 16px; - border: 1px solid #e4e4e7; + border: 1px solid var(--idp-border); border-radius: 12px; + background: var(--idp-card); } .mac { overflow: hidden; @@ -211,7 +212,7 @@ export class IdpMobileShowcase extends DeesElement { display: flex; gap: 6px; padding: 11px 14px; - border-bottom: 1px solid #e4e4e7; + border-bottom: 1px solid var(--idp-border); } .tdot { width: 10px; @@ -229,8 +230,15 @@ export class IdpMobileShowcase extends DeesElement { justify-content: space-between; gap: 12px; padding: 12px; - border: 1px solid #e4e4e7; + border: 1px solid var(--idp-border); border-radius: 10px; + background: var(--idp-card); + } + .inline-muted { + color: var(--idp-muted-fg); + } + .watch-muted { + color: #a1a1aa; } .row-label { display: inline-flex; @@ -276,8 +284,8 @@ export class IdpMobileShowcase extends DeesElement {
Watch, iPad, Mac
-
idp.global
GitHub wants access
repo:read - Berlin
-

Approval detail

Full context before a sensitive action is approved.

Device
MacBook Pro - Safari - Berlin, DE
Requested scopes
openid, profile, email, repo:read
+
idp.global
GitHub wants access
repo:read - Berlin
+

Approval detail

Full context before a sensitive action is approved.

Device
MacBook Pro - Safari - Berlin, DE
Requested scopes
openid, profile, email, repo:read
Menu bar approvals
GitHub OAuthApprove
NFC tap - door 4FReview
Key rotationConfirm
diff --git a/ts_web/elements/idp-select.ts b/ts_web/elements/idp-select.ts new file mode 100644 index 0000000..2de80b6 --- /dev/null +++ b/ts_web/elements/idp-select.ts @@ -0,0 +1,119 @@ +import { DeesElement, html, property, customElement, css, type TemplateResult } from '@design.estate/dees-element'; +import { idpElementStyles } from './tokens.js'; + +export interface IIdpSelectOption { + option: string; + key: string; + payload?: string; +} + +export interface IIdpSelectEventDetail { + key: string; + payload?: string; + selectedOption: IIdpSelectOption | null; +} + +declare global { + interface HTMLElementTagNameMap { + 'idp-select': IdpSelect; + } +} + +@customElement('idp-select') +export class IdpSelect extends DeesElement { + public static demo = () => html` + + `; + public static demoGroups = ['idp.global v3 primitives']; + + @property({ type: String }) + public accessor label = ''; + + @property({ type: String }) + public accessor placeholder = 'Select...'; + + @property({ type: Array }) + public accessor options: IIdpSelectOption[] = []; + + @property({ type: Object }) + public accessor selectedOption: IIdpSelectOption | null = null; + + @property({ type: Boolean, reflect: true }) + public accessor disabled = false; + + public static styles = [ + ...idpElementStyles, + css` + :host { + display: block; + } + label { + display: grid; + gap: 6px; + } + .label { + color: var(--idp-fg); + font-size: 13px; + font-weight: 500; + } + select { + width: 100%; + height: 36px; + box-sizing: border-box; + padding: 0 34px 0 10px; + border: 1px solid var(--idp-border); + border-radius: 8px; + outline: none; + background: var(--idp-card); + color: var(--idp-fg); + font-family: var(--idp-font); + font-size: 13px; + cursor: pointer; + transition: border-color 120ms ease, box-shadow 120ms ease; + } + select:focus { + border-color: var(--idp-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--idp-accent), transparent 86%); + } + select:disabled { + cursor: not-allowed; + opacity: 0.5; + } + `, + ]; + + private handleChange(eventArg: Event) { + const key = (eventArg.target as HTMLSelectElement).value; + const selectedOption = this.options.find((optionArg) => optionArg.key === key) || null; + this.selectedOption = selectedOption; + this.dispatchEvent(new CustomEvent('idp-select', { + detail: { + key, + payload: selectedOption?.payload, + selectedOption, + }, + bubbles: true, + composed: true, + })); + } + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index a95ac9e..eaa88d2 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -4,6 +4,11 @@ export * from './idp-button.js'; export * from './idp-badge.js'; export * from './idp-card.js'; export * from './idp-input.js'; +export * from './idp-checkbox.js'; +export * from './idp-form.js'; +export * from './idp-form-submit.js'; +export * from './idp-select.js'; +export * from './idp-data-table.js'; export * from './idp-toggle.js'; export * from './idp-approval-card.js'; export * from './idp-mobile-frame.js'; diff --git a/ts_web/elements/tokens.ts b/ts_web/elements/tokens.ts index b909211..32df18d 100644 --- a/ts_web/elements/tokens.ts +++ b/ts_web/elements/tokens.ts @@ -11,11 +11,11 @@ export const idpTheme = { card: 'var(--idp-card)', primary: 'var(--idp-primary)', primaryFg: 'var(--idp-primary-fg)', - accent: 'var(--idp-accent)', - accentHover: 'var(--idp-accent-hover)', - accentSoft: 'var(--idp-accent-soft)', - info: 'var(--idp-info)', - destructive: 'var(--idp-destructive)', + accent: 'var(--idp-accent)', + accentHover: 'var(--idp-accent-hover)', + accentSoft: 'var(--idp-accent-soft)', + info: 'var(--idp-info)', + destructive: 'var(--idp-destructive)', ok: 'var(--idp-ok)', warn: 'var(--idp-warn)', radius: 'var(--idp-radius)', @@ -23,47 +23,104 @@ export const idpTheme = { export const idpBaseStyles = css` :host { - --idp-bg: #fafaf9; - --idp-bg-2: #f4f4f2; - --idp-fg: #0a0a0a; - --idp-fg-2: #3f3f46; - --idp-fg-3: #52525b; - --idp-muted: #f1f1ef; - --idp-muted-fg: #71717a; - --idp-border: #e4e4e7; - --idp-border-soft: #ededec; - --idp-border-strong: #d4d4d8; - --idp-card: #ffffff; - --idp-card-2: #f8f8f7; - --idp-primary: #18181b; - --idp-primary-fg: #fafafa; - --idp-accent: #0050b9; - --idp-accent-hover: #0069f2; - --idp-accent-fg: #ffffff; - --idp-accent-soft: #e6effb; - --idp-destructive: #ef4444; - --idp-ok: #16a34a; - --idp-ok-bg: #f0fdf4; - --idp-ok-border: #bbf7d0; - --idp-warn: #b45309; - --idp-warn-bg: #fef9c3; - --idp-warn-border: #fde68a; - --idp-error: #dc2626; - --idp-error-bg: #fef2f2; - --idp-error-border: #fecaca; - --idp-info: #1e40af; - --idp-info-bg: #eff6ff; - --idp-info-border: #bfdbfe; - --idp-chart-1: #0050b9; - --idp-chart-2: #16a34a; - --idp-chart-3: #dc2626; - --idp-chart-4: #b45309; - --idp-chart-5: #6e5be6; - --idp-spark-up: #16a34a; - --idp-spark-down: #dc2626; - --idp-spark-info: #0050b9; - --idp-radius: 8px; - --idp-radius-lg: 12px; + --sh-bg: ${cssManager.bdTheme('#FAFAF9', '#0A0A0A')}; + --sh-bg-2: ${cssManager.bdTheme('#F4F4F2', '#111111')}; + --sh-card: ${cssManager.bdTheme('#FFFFFF', '#121212')}; + --sh-card-2: ${cssManager.bdTheme('#F8F8F7', '#161616')}; + --sh-muted: ${cssManager.bdTheme('#F1F1EF', '#161616')}; + --sh-muted-fg: ${cssManager.bdTheme('#71717A', 'hsl(0 0% 55%)')}; + --sh-fg: ${cssManager.bdTheme('#0A0A0A', '#FAFAFA')}; + --sh-fg-2: ${cssManager.bdTheme('#3F3F46', '#D4D4D8')}; + --sh-fg-3: ${cssManager.bdTheme('#52525B', 'hsl(0 0% 70%)')}; + --sh-fg-4: ${cssManager.bdTheme('#A1A1AA', 'hsl(0 0% 35%)')}; + --sh-border: ${cssManager.bdTheme('#E4E4E7', '#262626')}; + --sh-border-soft: ${cssManager.bdTheme('#EDEDEC', '#1C1C1C')}; + --sh-border-strong: ${cssManager.bdTheme('#D4D4D8', '#333333')}; + --sh-input: ${cssManager.bdTheme('#FFFFFF', '#161616')}; + --sh-input-bg: ${cssManager.bdTheme('#FFFFFF', '#0A0A0A')}; + --sh-hover: ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(255,255,255,0.04)')}; + --sh-primary: ${cssManager.bdTheme('#18181B', '#FAFAFA')}; + --sh-primary-fg: ${cssManager.bdTheme('#FAFAFA', '#18181B')}; + --sh-accent: ${cssManager.bdTheme('#0050B9', '#3B82F6')}; + --sh-accent-h: ${cssManager.bdTheme('#0069F2', '#60A5FA')}; + --sh-accent-fg: #FFFFFF; + --sh-accent-soft: ${cssManager.bdTheme('#E6EFFB', 'rgba(59,130,246,0.15)')}; + --sh-accent-soft-fg: ${cssManager.bdTheme('#0050B9', '#93BBFD')}; + --sh-accent-ring: ${cssManager.bdTheme('rgba(0,80,185,0.2)', 'rgba(59,130,246,0.35)')}; + --sh-ok: ${cssManager.bdTheme('#16A34A', '#4ADE80')}; + --sh-ok-bg: ${cssManager.bdTheme('#F0FDF4', 'rgba(20,83,45,0.4)')}; + --sh-ok-border: ${cssManager.bdTheme('#BBF7D0', 'rgba(74,222,128,0.25)')}; + --sh-warn: ${cssManager.bdTheme('#B45309', '#FBBF24')}; + --sh-warn-bg: ${cssManager.bdTheme('#FEF9C3', 'rgba(69,26,3,0.6)')}; + --sh-warn-border: ${cssManager.bdTheme('#FDE68A', 'rgba(251,191,36,0.25)')}; + --sh-err: ${cssManager.bdTheme('#DC2626', '#F87171')}; + --sh-err-bg: ${cssManager.bdTheme('#FEF2F2', 'rgba(69,10,10,0.6)')}; + --sh-err-border: ${cssManager.bdTheme('#FECACA', 'rgba(248,113,113,0.25)')}; + --sh-info: ${cssManager.bdTheme('#1E40AF', '#93BBFD')}; + --sh-info-bg: ${cssManager.bdTheme('#EFF6FF', 'rgba(59,130,246,0.15)')}; + --sh-info-border: ${cssManager.bdTheme('#BFDBFE', 'rgba(59,130,246,0.3)')}; + --sh-destructive: #EF4444; + --sh-ring: ${cssManager.bdTheme('#A1A1AA', '#3B82F6')}; + --sh-radius: 8px; + --sh-radius-lg: 12px; + --sh-chart-1: ${cssManager.bdTheme('#0050B9', '#3B82F6')}; + --sh-chart-2: ${cssManager.bdTheme('#16A34A', '#4ADE80')}; + --sh-chart-3: ${cssManager.bdTheme('#DC2626', '#F87171')}; + --sh-chart-4: ${cssManager.bdTheme('#B45309', '#FBBF24')}; + --sh-chart-5: ${cssManager.bdTheme('#6E5BE6', '#A78BFA')}; + --sh-grid: ${cssManager.bdTheme('#E4E4E7', '#262626')}; + --sh-grid-soft: ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(255,255,255,0.04)')}; + --sh-spark-up: ${cssManager.bdTheme('#16A34A', '#4ADE80')}; + --sh-spark-down: ${cssManager.bdTheme('#DC2626', '#F87171')}; + --sh-spark-info: ${cssManager.bdTheme('#0050B9', '#93BBFD')}; + --sh-d1: ${cssManager.bdTheme('#0050B9', '#93BBFD')}; + --sh-d2: ${cssManager.bdTheme('#16A34A', '#4ADE80')}; + --sh-d3: ${cssManager.bdTheme('#B45309', '#FBBF24')}; + --sh-d4: ${cssManager.bdTheme('#DC2626', '#F87171')}; + --sh-d5: ${cssManager.bdTheme('#6E5BE6', '#A78BFA')}; + --sh-d6: ${cssManager.bdTheme('#0891B2', '#22D3EE')}; + + --idp-bg: var(--sh-bg); + --idp-bg-2: var(--sh-bg-2); + --idp-fg: var(--sh-fg); + --idp-fg-2: var(--sh-fg-2); + --idp-fg-3: var(--sh-fg-3); + --idp-muted: var(--sh-muted); + --idp-muted-fg: var(--sh-muted-fg); + --idp-border: var(--sh-border); + --idp-border-soft: var(--sh-border-soft); + --idp-border-strong: var(--sh-border-strong); + --idp-card: var(--sh-card); + --idp-card-2: var(--sh-card-2); + --idp-primary: var(--sh-primary); + --idp-primary-fg: var(--sh-primary-fg); + --idp-accent: var(--sh-accent); + --idp-accent-hover: var(--sh-accent-h); + --idp-accent-fg: var(--sh-accent-fg); + --idp-accent-soft: var(--sh-accent-soft); + --idp-destructive: var(--sh-destructive); + --idp-ok: var(--sh-ok); + --idp-ok-bg: var(--sh-ok-bg); + --idp-ok-border: var(--sh-ok-border); + --idp-warn: var(--sh-warn); + --idp-warn-bg: var(--sh-warn-bg); + --idp-warn-border: var(--sh-warn-border); + --idp-error: var(--sh-err); + --idp-error-bg: var(--sh-err-bg); + --idp-error-border: var(--sh-err-border); + --idp-info: var(--sh-info); + --idp-info-bg: var(--sh-info-bg); + --idp-info-border: var(--sh-info-border); + --idp-chart-1: var(--sh-chart-1); + --idp-chart-2: var(--sh-chart-2); + --idp-chart-3: var(--sh-chart-3); + --idp-chart-4: var(--sh-chart-4); + --idp-chart-5: var(--sh-chart-5); + --idp-spark-up: var(--sh-spark-up); + --idp-spark-down: var(--sh-spark-down); + --idp-spark-info: var(--sh-spark-info); + --idp-radius: var(--sh-radius); + --idp-radius-lg: var(--sh-radius-lg); --idp-font: 'Geist', ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --idp-display: 'Plus Jakarta Sans', 'Geist', ui-sans-serif, system-ui, sans-serif; --idp-mono: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; @@ -76,44 +133,60 @@ export const idpBaseStyles = css` :host([theme="dark"]), :host([dark]) { - --idp-bg: #0a0a0a; - --idp-bg-2: #111111; - --idp-fg: #fafafa; - --idp-fg-2: #d4d4d8; - --idp-fg-3: hsl(0 0% 70%); - --idp-muted: #161616; - --idp-muted-fg: hsl(0 0% 55%); - --idp-border: #262626; - --idp-border-soft: #1c1c1c; - --idp-border-strong: #333333; - --idp-card: #121212; - --idp-card-2: #161616; - --idp-primary: #fafafa; - --idp-primary-fg: #18181b; - --idp-accent: #3b82f6; - --idp-accent-hover: #60a5fa; - --idp-accent-soft: rgba(59, 130, 246, 0.15); - --idp-destructive: #ef4444; - --idp-ok: #4ade80; - --idp-ok-bg: rgba(20, 83, 45, 0.4); - --idp-ok-border: rgba(74, 222, 128, 0.25); - --idp-warn: #fbbf24; - --idp-warn-bg: rgba(69, 26, 3, 0.6); - --idp-warn-border: rgba(251, 191, 36, 0.25); - --idp-error: #f87171; - --idp-error-bg: rgba(69, 10, 10, 0.6); - --idp-error-border: rgba(248, 113, 113, 0.25); - --idp-info: #93bbfd; - --idp-info-bg: rgba(59, 130, 246, 0.15); - --idp-info-border: rgba(59, 130, 246, 0.3); - --idp-chart-1: #3b82f6; - --idp-chart-2: #4ade80; - --idp-chart-3: #f87171; - --idp-chart-4: #fbbf24; - --idp-chart-5: #a78bfa; - --idp-spark-up: #4ade80; - --idp-spark-down: #f87171; - --idp-spark-info: #93bbfd; + --sh-bg: #0A0A0A; + --sh-bg-2: #111111; + --sh-card: #121212; + --sh-card-2: #161616; + --sh-muted: #161616; + --sh-muted-fg: hsl(0 0% 55%); + --sh-fg: #FAFAFA; + --sh-fg-2: #D4D4D8; + --sh-fg-3: hsl(0 0% 70%); + --sh-fg-4: hsl(0 0% 35%); + --sh-border: #262626; + --sh-border-soft: #1C1C1C; + --sh-border-strong: #333333; + --sh-input: #161616; + --sh-input-bg: #0A0A0A; + --sh-hover: rgba(255,255,255,0.04); + --sh-primary: #FAFAFA; + --sh-primary-fg: #18181B; + --sh-accent: #3B82F6; + --sh-accent-h: #60A5FA; + --sh-accent-fg: #FFFFFF; + --sh-accent-soft: rgba(59,130,246,0.15); + --sh-accent-soft-fg: #93BBFD; + --sh-accent-ring: rgba(59,130,246,0.35); + --sh-ok: #4ADE80; + --sh-ok-bg: rgba(20,83,45,0.4); + --sh-ok-border: rgba(74,222,128,0.25); + --sh-warn: #FBBF24; + --sh-warn-bg: rgba(69,26,3,0.6); + --sh-warn-border: rgba(251,191,36,0.25); + --sh-err: #F87171; + --sh-err-bg: rgba(69,10,10,0.6); + --sh-err-border: rgba(248,113,113,0.25); + --sh-info: #93BBFD; + --sh-info-bg: rgba(59,130,246,0.15); + --sh-info-border: rgba(59,130,246,0.3); + --sh-destructive: #EF4444; + --sh-ring: #3B82F6; + --sh-chart-1: #3B82F6; + --sh-chart-2: #4ADE80; + --sh-chart-3: #F87171; + --sh-chart-4: #FBBF24; + --sh-chart-5: #A78BFA; + --sh-grid: #262626; + --sh-grid-soft: rgba(255,255,255,0.04); + --sh-spark-up: #4ADE80; + --sh-spark-down: #F87171; + --sh-spark-info: #93BBFD; + --sh-d1: #93BBFD; + --sh-d2: #4ADE80; + --sh-d3: #FBBF24; + --sh-d4: #F87171; + --sh-d5: #A78BFA; + --sh-d6: #22D3EE; } `;