import { DeesElement, html, property, state, customElement, css, type TemplateResult } from '@design.estate/dees-element'; import { idpElementStyles } from './tokens.js'; import './idp-badge.js'; import './idp-icon.js'; import './idp-data-table.js'; declare global { interface HTMLElementTagNameMap { 'idp-admin-shell': IdpAdminShell; } } type TNavItem = { id: TIdpAdminPage; label: string; icon: string; badge?: string; }; type TKpi = { label: string; value: string; unit?: string; delta: string; deltaKind: 'up' | 'live'; sub: string; accent: string; spark: number[]; sparkColor: string; }; export type TIdpAdminPage = | 'overview' | 'profile' | 'security' | 'sessions' | 'apps' | 'org-general' | 'org-settings' | 'org-members' | 'org-apps' | 'support' | 'ga-users' | 'ga-orgs' | 'ga-apps'; 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; } 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 demoGroups = ['idp.global v3 full pages']; public static styles = [ ...idpElementStyles, css` :host { display: block; height: 100vh; max-height: 100vh; min-height: 0; overflow: hidden; color: var(--idp-fg); } .shell { height: 100%; min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); overflow: hidden; border: 0; background: var(--idp-bg); } .sidebar { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--idp-border); background: var(--idp-bg); } .logo-block { padding: 14px 16px 10px; border-bottom: 1px solid var(--idp-border); } .logo { display: flex; align-items: center; gap: 8px; } .logo-icon { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; border-radius: 7px; background: var(--idp-primary); color: var(--idp-primary-fg); } .logo-text { color: var(--idp-fg); font-size: 13px; font-weight: 700; letter-spacing: -0.3px; } .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; } .nav-title { padding: 0 8px; margin-bottom: 4px; color: var(--idp-muted-fg); font-size: 10px; font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; } .nav-list { display: grid; } .nav-item, .org-switch, .plain-button { border: 0; font-family: inherit; } .nav-item { width: 100%; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 7px; background: transparent; color: var(--idp-muted-fg); font-size: 13px; font-weight: 400; text-align: left; } .nav-item.active { background: var(--idp-muted); color: var(--idp-fg); font-weight: 500; } .nav-item idp-icon { color: currentColor; } .nav-item.active idp-icon { color: var(--idp-accent); } .org-switch-wrap { position: relative; margin-bottom: 2px; } .org-switch { width: 100%; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 7px; background: transparent; color: var(--idp-muted-fg); cursor: pointer; } .org-switch:hover { background: var(--idp-muted); } .org-switch.open { background: var(--idp-muted); } .org-switch.open .org-name { color: var(--idp-accent); } .org-switch.open idp-icon { transform: rotate(180deg); } .org-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 40; overflow: hidden; border: 1px solid var(--idp-border); border-radius: 10px; background: var(--idp-card); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); } .org-menu-title { padding: 6px 8px 3px; color: var(--idp-muted-fg); font-size: 10px; font-weight: 600; letter-spacing: 0.7px; text-transform: uppercase; } .org-menu-item, .org-create { width: 100%; display: flex; align-items: center; gap: 8px; border: 0; background: transparent; font-family: inherit; text-align: left; } .org-menu-item { padding: 6px 8px; cursor: pointer; } .org-menu-item.selected { background: var(--idp-accent-soft); } .org-menu-item .org-avatar { width: 20px; height: 20px; font-size: 7px; } .org-menu-name { overflow: hidden; color: var(--idp-fg); font-size: 12px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } .org-menu-item.selected .org-menu-name { color: var(--idp-accent); } .org-menu-role { color: var(--idp-muted-fg); font-size: 10px; } .org-menu-divider { height: 1px; margin: 3px 0; background: var(--idp-border); } .org-create { padding: 6px 8px 8px; color: var(--idp-muted-fg); cursor: pointer; font-size: 12px; } .org-create-icon { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; border: 1.5px dashed var(--idp-border); border-radius: 5px; } .org-name { flex: 1; min-width: 0; overflow: hidden; color: var(--idp-muted-fg); font-size: 13px; font-weight: 400; text-align: left; text-overflow: ellipsis; white-space: nowrap; } .org-avatar, .user-avatar { display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-family: var(--idp-mono); font-weight: 700; } .org-avatar { width: 14px; height: 14px; border: 1px solid var(--idp-border); border-radius: 6px; background: var(--idp-accent-soft); color: var(--idp-accent); font-size: 5px; } .user-footer { display: flex; align-items: center; gap: 9px; padding: 10px 12px; border-top: 1px solid var(--idp-border); } .user-avatar { width: 26px; height: 26px; border-radius: 50%; background: oklch(0.85 0.08 240); color: oklch(0.3 0.08 240); font-size: 9px; } .user-meta { flex: 1; min-width: 0; } .user-name, .user-email { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-name { color: var(--idp-fg); font-size: 12px; font-weight: 500; } .user-email { color: var(--idp-muted-fg); font-size: 10px; } main { height: 100%; min-height: 0; min-width: 0; overflow: auto; background: var(--idp-bg); } .page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 24px 28px 18px; border-bottom: 1px solid var(--idp-border); background: var(--idp-bg); } .eyebrow-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .eyebrow { color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } .live-pill { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border: 1px solid var(--idp-ok-border); border-radius: 999px; background: var(--idp-ok-bg); color: var(--idp-ok); font-family: var(--idp-mono); font-size: 10px; font-weight: 500; } .live-dot, .feed-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--idp-ok); box-shadow: 0 0 6px var(--idp-ok); animation: idp-blink 1.6s ease-in-out infinite; } @keyframes idp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } h1, h2 { margin: 0; color: var(--idp-fg); font-family: var(--idp-display); letter-spacing: -0.025em; } h1 { font-size: 26px; font-weight: 700; line-height: 1.1; } .lead { margin-top: 4px; color: var(--idp-muted-fg); font-size: 13px; } .lead code, .mono { color: var(--idp-info); font-family: var(--idp-mono); } .page-actions { display: flex; align-items: center; gap: 12px; } .tabs { display: inline-flex; gap: 1px; padding: 2px; border: 1px solid var(--idp-border); border-radius: 6px; background: var(--idp-bg-2); } .tab { padding: 4px 10px; border-radius: 4px; color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 11px; font-weight: 500; } .tab.active { background: var(--idp-muted); color: var(--idp-fg); } .plain-button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 5px 10px; border-radius: 8px; background: transparent; color: var(--idp-fg); font-size: 12px; font-weight: 500; white-space: nowrap; } .plain-button.outline { border: 1px solid var(--idp-border); } .plain-button.primary { background: var(--idp-accent); color: var(--idp-accent-fg); } .plain-button.destructive { background: var(--idp-destructive); color: var(--idp-accent-fg); } .plain-button.ghost { color: var(--idp-fg); } .plain-button:disabled { opacity: 0.45; cursor: not-allowed; } .body { display: flex; flex-direction: column; gap: 16px; padding: 20px 28px 32px; } .narrow-body { max-width: 700px; } .wide-body { max-width: 860px; } .section-card { margin-bottom: 16px; padding: 20px; border: 1px solid var(--idp-border); border-radius: var(--idp-radius); background: var(--idp-card); } .section-card.compact { padding: 14px; } .section-headline { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; } .section-title { color: var(--idp-fg); font-size: 14px; font-weight: 600; } .section-description, .muted { color: var(--idp-muted-fg); font-size: 12px; line-height: 1.5; } .form-row { display: grid; grid-template-columns: 180px 1fr; gap: 16px; align-items: start; padding: 12px 0; border-bottom: 1px solid var(--idp-border); } .form-row:last-child { border-bottom: 0; } .form-label { display: flex; gap: 3px; color: var(--idp-fg); font-size: 13px; font-weight: 500; } .form-hint { margin-top: 2px; color: var(--idp-muted-fg); font-size: 12px; line-height: 1.4; } .input, .select, .textarea { box-sizing: border-box; width: 100%; border: 1px solid var(--idp-border); border-radius: 8px; outline: none; background: var(--idp-bg); color: var(--idp-fg); font-family: inherit; font-size: 13px; } .input, .select { height: 34px; padding: 0 10px; } .textarea { min-height: 96px; padding: 8px 10px; resize: vertical; } .input-group { display: flex; overflow: hidden; border: 1px solid var(--idp-border); border-radius: 8px; background: var(--idp-bg); } .input-prefix { height: 34px; display: flex; align-items: center; padding: 0 8px; border-right: 1px solid var(--idp-border); background: var(--idp-muted); color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 12px; white-space: nowrap; } .input-group .input { border: 0; border-radius: 0; background: transparent; } .divider { height: 1px; margin: 16px 0; background: var(--idp-border); } .code-block { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--idp-border); border-radius: 7px; background: var(--idp-muted); } .code-block code { flex: 1; overflow-wrap: anywhere; color: var(--idp-fg); font-family: var(--idp-mono); font-size: 12px; } .avatar, .app-avatar, .icon-tile { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .avatar { border-radius: 50%; background: oklch(0.85 0.08 240); color: oklch(0.3 0.08 240); font-family: var(--idp-mono); font-weight: 600; } .app-avatar, .icon-tile { border: 1px solid var(--idp-border); border-radius: 10px; background: var(--idp-muted); color: var(--idp-muted-fg); } .app-avatar { width: 40px; height: 40px; background: var(--idp-accent-soft); color: var(--idp-accent); font-size: 14px; font-weight: 700; } .icon-tile { width: 38px; height: 38px; } .list-stack { display: grid; gap: 12px; } .row-card { display: flex; align-items: flex-start; gap: 14px; padding: 16px; border: 1px solid var(--idp-border); border-radius: var(--idp-radius); background: var(--idp-card); } .split-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .chip-row { display: flex; flex-wrap: wrap; gap: 4px; } .switch { width: 36px; height: 20px; position: relative; flex-shrink: 0; border-radius: 10px; background: var(--idp-accent); } .switch::after { content: ''; position: absolute; top: 2px; left: 18px; width: 16px; height: 16px; border-radius: 8px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } .sub-tabs { width: fit-content; display: flex; gap: 2px; margin-bottom: 16px; padding: 3px; border-radius: 8px; background: var(--idp-muted); } .sub-tab { padding: 5px 12px; border-radius: 6px; color: var(--idp-muted-fg); font-size: 12px; } .sub-tab.active { background: var(--idp-bg); color: var(--idp-fg); font-weight: 500; box-shadow: 0 1px 3px rgba(0,0,0,0.06); } .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .kpi, .card { border: 1px solid var(--idp-border); background: var(--idp-card); } .kpi { position: relative; min-height: 132px; display: flex; flex-direction: column; gap: 4px; overflow: hidden; padding: 18px 20px 14px; border-radius: 10px; } .kpi::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--kpi-accent); } .kpi-label { color: var(--idp-muted-fg); font-size: 11.5px; font-weight: 500; letter-spacing: 0.02em; } .kpi-value { color: var(--idp-fg); font-family: var(--idp-display); font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.025em; line-height: 1.1; } .kpi-value span { margin-left: 2px; color: var(--idp-muted-fg); font-size: 14px; font-weight: 500; } .kpi-sub { color: var(--idp-fg-3); font-family: var(--idp-mono); font-size: 11px; } .kpi-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; } .delta { display: inline-flex; align-items: center; gap: 4px; color: var(--idp-ok); font-family: var(--idp-mono); font-size: 11px; font-weight: 600; } .sparkline { width: 84px; opacity: 0.85; } .primary-grid, .secondary-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: 16px; } .card { overflow: hidden; border-radius: 8px; } .card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--idp-border-soft); } .card-title { color: var(--idp-fg); font-size: 13px; font-weight: 600; } .feed-dot { display: inline-block; width: 8px; height: 8px; border-radius: 4px; background: var(--dot-color); box-shadow: var(--dot-glow, none); } .feed-meta { color: var(--idp-muted-fg); font-family: var(--idp-mono); font-size: 11.5px; } .feed-item { display: grid; grid-template-columns: 14px 1fr auto; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--idp-border-soft); } .feed-item:last-child { border-bottom: 0; } .feed-text { color: var(--idp-fg-2); font-size: 12.5px; } .feed-text strong { color: var(--idp-fg); font-weight: 600; } .dialog-backdrop { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0, 0, 0, 0.42); } .dialog-card { width: min(720px, 100%); max-height: min(760px, calc(100vh - 40px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--idp-border); border-radius: 12px; background: var(--idp-card); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); } .dialog-card.wide { width: min(980px, 100%); } .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-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 { grid-template-columns: 1fr; } .sidebar { display: none; } .kpis { grid-template-columns: repeat(2, 1fr); } .primary-grid, .secondary-grid { grid-template-columns: 1fr; } } @media (max-width: 720px) { .page-head, .body { padding-left: 20px; padding-right: 20px; } .page-actions { flex-wrap: wrap; } .kpis { grid-template-columns: 1fr; } .mapping-row { grid-template-columns: 1fr; } } `, ]; @property({ type: String }) 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 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' }, ]; private accountNav: TNavItem[] = [ { id: 'profile', label: 'Profile', icon: 'user' }, { id: 'security', label: 'Security', icon: 'shield' }, { id: 'sessions', label: 'Sessions & Devices', icon: 'monitor' }, { id: 'apps', label: 'Connected Apps', icon: 'grid' }, ]; 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' }, ]; private supportNav: TNavItem[] = [ { id: 'support', label: 'Support', icon: 'shield' }, ]; private adminNav: TNavItem[] = [ { id: 'ga-users', label: 'All Users', icon: 'users' }, { id: 'ga-orgs', label: 'All Organisations', icon: 'building' }, { id: 'ga-apps', label: 'Platform Apps', icon: 'globe' }, ]; private get safeOrgs(): IIdpAdminOrg[] { return Array.isArray(this.orgs) ? this.orgs.filter(Boolean) : []; } 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.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` `; } private renderSidebar(): TemplateResult { const currentOrg = this.currentOrg; return html` `; } private renderSparkline(data: number[], color: string): TemplateResult { const max = Math.max(...data); const min = Math.min(...data); const range = max - min || 1; const width = 100; const height = 22; const points = data.map((valueArg, indexArg) => { const x = (indexArg / (data.length - 1)) * width; const y = height - ((valueArg - min) / range) * (height - 4) - 2; return `${x},${y}`; }).join(' '); const area = `0,${height} ${points} ${width},${height}`; return html` `; } private renderKpi(kpi: TKpi): TemplateResult { return html`
${kpi.label}
${kpi.value}${kpi.unit ? html`${kpi.unit}` : html``}
${kpi.sub}
${kpi.deltaKind === 'live' ? html`` : html``}${kpi.delta}
${this.renderSparkline(kpi.spark, kpi.sparkColor)}
`; } private renderPageHeader(titleArg: string, descriptionArg: string, actionArg?: TemplateResult): TemplateResult { return html`

${titleArg}

${descriptionArg}
${actionArg ? html`
${actionArg}
` : html``}
`; } private renderSectionCard(titleArg: string, descriptionArg: string, contentArg: TemplateResult, actionArg?: TemplateResult): TemplateResult { return html`
${titleArg || actionArg ? html`
${titleArg}
${descriptionArg ? html`
${descriptionArg}
` : html``}
${actionArg}
` : html``} ${contentArg}
`; } private renderFormRow(labelArg: string, hintArg: string, contentArg: TemplateResult, requiredArg = false): TemplateResult { return html`
${labelArg}${requiredArg ? html`*` : html``}
${hintArg ? html`
${hintArg}
` : html``}
${contentArg}
`; } private renderCodeBlock(valueArg: string): TemplateResult { return html`
${valueArg}
`; } 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`
Account - Overviewlive

Good morning, ${firstName}.

Account-wide operational snapshot using live identity data.
${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`
${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.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 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.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('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 { return 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 = this.accountApps; return html` ${this.renderPageHeader('Connected Apps', 'Third-party apps and services that have OAuth access to your account.')}
${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`
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.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.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 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', `${this.orgMembers.length} members - ${this.orgInvitations.length} pending invitations`, html``)}
${this.orgSettingsError ? html`
Role action blocked
${this.orgSettingsError}
` : html``}
${this.orgInvitations.length ? html` ` : html``}
`; } private renderOrgApps(): TemplateResult { 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('Apps', "Global apps connected to this organisation.")}
${this.orgSettingsError ? html`
App mapping blocked
${this.orgSettingsError}
` : html``}
`; } private renderSupport(): TemplateResult { const services = [ ['Account Recovery', 'Lost access to your account or locked out of your organisation? Our team will verify your identity and restore access securely.', 'EUR149', 'per incident', 'key', '1-2 business days'], ['Organisation Recovery', 'All owners have lost access to your organisation. We can verify ownership and restore admin access.', 'EUR249', 'per incident', 'building', '2-3 business days'], ['Data Export & Migration', 'Full export of your org data - users, sessions, app connections - for migration or compliance.', 'EUR199', 'per request', 'box', '3-5 business days'], ['Identity & SSO Consulting', 'Architecture review, OIDC guidance, and custom SSO setup for your organisation stack.', 'EUR190', 'per hour', 'globe', 'Scheduled session'], ['Security Review', 'Audit of connected apps, active sessions, passkey policies, and role assignments.', 'EUR390', 'per review', 'shield', '5-7 business days'], ]; 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]}
`)}
`; } private renderGAUsers(): TemplateResult { 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 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 = 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> = { 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(), 'ga-users': () => this.renderGAUsers(), 'ga-orgs': () => this.renderGAOrgs(), 'ga-apps': () => this.renderGAApps(), }; return (renderers[this.page] || renderers.overview)(); } public render(): TemplateResult { return html`
${this.renderSidebar()}
${this.renderMainContent()}
${this.renderDialog()} `; } }