Files
catalog/ts_web/elements/idp-admin-shell.ts
T

2665 lines
100 KiB
TypeScript

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`<idp-admin-shell global-admin></idp-admin-shell>`;
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<IIdpAdminPasswordChangeEventDetail>('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<IIdpAdminOrgUpdateEventDetail>('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<IIdpAdminOrgTransferEventDetail>('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<IIdpAdminOrgDeleteEventDetail>('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<IIdpAdminPassportEnrollmentEventDetail>('idp-admin-passport-enroll', {
deviceLabel,
});
}
private setPage(pageArg: TIdpAdminPage) {
this.page = pageArg;
this.orgMenuOpen = false;
this.dispatchEvent(new CustomEvent<IIdpAdminNavigateEventDetail>('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<IIdpAdminOrgSelectEventDetail>('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<TDetail>(eventNameArg: string, detailArg: TDetail) {
this.dispatchEvent(new CustomEvent<TDetail>(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`
<div class="state-card">
<span class="state-icon"><idp-icon name=${iconArg as any} size="17"></idp-icon></span>
<div class="state-title">${titleArg}</div>
<div class="state-description">${descriptionArg}</div>
</div>
`;
}
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<IIdpAdminOrgRoleUpsertEventDetail>('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<IIdpAdminOrgRoleDeleteEventDetail>('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<IIdpAdminMemberRolesEventDetail>('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<IIdpAdminAppRoleMappingsEventDetail>('idp-admin-app-role-mappings-update', {
organizationId: this.currentOrg.id,
appId: this.dialogApp.id,
roleMappings,
});
this.closeDialog();
}
private renderDialogError(): TemplateResult {
return this.dialogError
? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Action blocked</div><div class="muted">${this.dialogError}</div></div></div>`
: html``;
}
private renderRoleUpsertDialog(): TemplateResult {
const isEdit = this.orgRoleDefinitions.some((roleArg) => roleArg.key === this.dialogRoleKey);
return html`
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
<div class="dialog-head">
<div><div class="dialog-title">${isEdit ? 'Edit custom role' : 'Add custom role'}</div><div class="muted">Custom roles model organisation-specific business access.</div></div>
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
</div>
<div class="dialog-body">
${this.renderDialogError()}
${this.renderFormRow('Role name', 'Readable label shown to admins.', html`<input class="input" .value=${this.dialogRoleName} @input=${(eventArg: Event) => this.setDialogRoleField('name', eventArg)} />`, true)}
${this.renderFormRow('Role key', 'Lowercase stable identifier used in assignments and app mappings.', html`<input class="input" .value=${this.dialogRoleKey} @input=${(eventArg: Event) => this.setDialogRoleField('key', eventArg)} ?disabled=${isEdit} />`, true)}
${this.renderFormRow('Description', 'Optional admin note describing when this role should be used.', html`<textarea class="textarea" .value=${this.dialogRoleDescription} @input=${(eventArg: Event) => this.setDialogRoleField('description', eventArg)}></textarea>`)}
</div>
<div class="dialog-actions">
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
<button class="plain-button primary" @click=${() => this.submitRoleUpsertDialog()}>Save role</button>
</div>
</div>
`;
}
private renderRoleDeleteDialog(): TemplateResult {
const expectedText = `delete role ${this.dialogRoleKey}`;
return html`
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
<div class="dialog-head">
<div><div class="dialog-title">Delete custom role</div><div class="muted">This removes the role from member assignments and app mappings after backend confirmation.</div></div>
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
</div>
<div class="dialog-body">
${this.renderDialogError()}
<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">${this.dialogRoleName}</div><div class="muted">Type <span class="mono">${expectedText}</span> to confirm deletion.</div></div></div>
${this.renderFormRow('Confirmation', `Type ${expectedText}`, html`<input class="input" .value=${this.dialogRoleDeleteConfirmation} @input=${(eventArg: Event) => this.setDialogRoleField('deleteConfirmation', eventArg)} />`, true)}
</div>
<div class="dialog-actions">
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
<button class="plain-button destructive" @click=${() => this.submitRoleDeleteDialog()}>Delete role</button>
</div>
</div>
`;
}
private renderMemberRolesDialog(): TemplateResult {
const member = this.dialogMember;
return html`
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
<div class="dialog-head">
<div><div class="dialog-title">Edit member roles</div><div class="muted">${member?.name || member?.email || 'Member'} receives the selected organisation roles.</div></div>
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
</div>
<div class="dialog-body">
${this.renderDialogError()}
<div class="role-grid">
${this.availableOrgRoles.map((roleArg) => {
const checked = this.dialogMemberRoles.includes(roleArg.key);
return html`
<label class="role-option">
<input type="checkbox" .checked=${checked} @change=${(eventArg: Event) => this.toggleDialogMemberRole(roleArg.key, (eventArg.target as HTMLInputElement).checked)} />
<span><span class="section-title">${roleArg.name}</span><span class="muted" style="display:block">${roleArg.description || roleArg.key}</span></span>
</label>
`;
})}
</div>
</div>
<div class="dialog-actions">
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
<button class="plain-button primary" @click=${() => this.submitMemberRolesDialog()}>Save roles</button>
</div>
</div>
`;
}
private renderAppRoleMappingsDialog(): TemplateResult {
const app = this.dialogApp;
return html`
<div class="dialog-card wide" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
<div class="dialog-head">
<div><div class="dialog-title">Map organisation roles</div><div class="muted">Map ${this.currentOrg.name} roles to app-specific roles, permissions, and OAuth scopes for ${app?.name || 'this app'}.</div></div>
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
</div>
<div class="dialog-body">
${this.renderDialogError()}
${app?.scopes?.length ? html`<div class="muted">Available OAuth scopes: <span class="mono">${app.scopes.join(', ')}</span></div>` : html`<div class="muted">This app has no declared OAuth scopes. Role and permission mappings are still supported.</div>`}
${this.dialogAppMappings.map((mappingArg) => {
const role = this.availableOrgRoles.find((roleArg) => roleArg.key === mappingArg.orgRoleKey);
return html`
<div class="mapping-row">
<div class="mapping-role">${role?.name || mappingArg.orgRoleKey}<span class="muted">${mappingArg.orgRoleKey}</span></div>
<div>${this.renderFormRow('App roles', '', html`<input class="input" .value=${mappingArg.appRoles.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'appRoles', eventArg)} />`)}</div>
<div>${this.renderFormRow('Permissions', '', html`<input class="input" .value=${mappingArg.permissions.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'permissions', eventArg)} />`)}</div>
<div>${this.renderFormRow('Scopes', '', html`<input class="input" .value=${mappingArg.scopes.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'scopes', eventArg)} />`)}</div>
</div>
`;
})}
</div>
<div class="dialog-actions">
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
<button class="plain-button primary" @click=${() => this.submitAppRoleMappingsDialog()}>Save mappings</button>
</div>
</div>
`;
}
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`<div class="dialog-backdrop" @click=${() => this.closeDialog()}>${dialog}</div>`;
}
private renderNavGroup(items: TNavItem[], active = ''): TemplateResult {
return html`
<div class="nav-list">
${items.map((item) => html`
<button class="nav-item ${item.id === active ? 'active' : ''}" @click=${() => this.setPage(item.id)}>
<idp-icon name=${item.icon as any} size="14"></idp-icon>
${item.label}
${item.badge ? html`<idp-badge variant="accent" style="margin-left:auto">${item.badge}</idp-badge>` : html``}
</button>
`)}
</div>
`;
}
private renderSidebar(): TemplateResult {
const currentOrg = this.currentOrg;
return html`
<aside class="sidebar">
<div class="logo-block">
<div class="logo">
<div class="logo-icon" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<span class="logo-text">idp.global</span>
</div>
</div>
<div class="nav-wrap">
<div class="nav-section"><div class="nav-title">Workspace</div>${this.renderNavGroup(this.workspaceNav, this.page)}</div>
<div class="nav-section"><div class="nav-title">My Account</div>${this.renderNavGroup(this.accountNav, this.page)}</div>
<div class="nav-section">
<div class="nav-title">Organisation</div>
<div class="org-switch-wrap">
<button class="org-switch ${this.orgMenuOpen ? 'open' : ''}" @click=${() => { this.orgMenuOpen = !this.orgMenuOpen; }}>
<span class="org-avatar">${currentOrg.name.slice(0, 2).toUpperCase()}</span>
<span class="org-name">${currentOrg.name}</span>
<idp-icon name="chevron-down" size="10"></idp-icon>
</button>
${this.orgMenuOpen ? html`
<div class="org-menu">
<div class="org-menu-title">Switch organisation</div>
${this.safeOrgs.map((orgArg) => html`
<button class="org-menu-item ${orgArg.id === this.selectedOrgId ? 'selected' : ''}" @click=${() => this.selectOrg(orgArg.id)}>
<span class="org-avatar">${orgArg.name.slice(0, 2).toUpperCase()}</span>
<span style="flex:1;min-width:0"><span class="org-menu-name">${orgArg.name}</span><span class="org-menu-role">${orgArg.myRole || 'member'}</span></span>
${orgArg.id === this.selectedOrgId ? html`<idp-icon name="check" size="11"></idp-icon>` : html``}
</button>
`)}
<div class="org-menu-divider"></div>
<button class="org-create" @click=${() => this.requestOrgCreate()}><span class="org-create-icon"><idp-icon name="plus" size="9"></idp-icon></span>Create organisation</button>
</div>
` : html``}
</div>
${this.renderNavGroup(this.orgNav, this.page)}
</div>
<div class="nav-section"><div class="nav-title">Support</div>${this.renderNavGroup(this.supportNav, this.page)}</div>
${this.globalAdmin ? html`<div class="nav-section"><div class="nav-title">Global Admin</div>${this.renderNavGroup(this.adminNav, this.page)}</div>` : html``}
</div>
<div class="user-footer">
<span class="user-avatar">${this.userInitials}</span>
<div class="user-meta"><div class="user-name">${this.user?.name || 'Unknown User'}</div><div class="user-email">${this.user?.email || 'No email'}</div></div>
</div>
</aside>
`;
}
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`
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<polygon points=${area} fill=${color} opacity="0.12"></polygon>
<polyline points=${points} fill="none" stroke=${color} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></polyline>
</svg>
`;
}
private renderKpi(kpi: TKpi): TemplateResult {
return html`
<div class="kpi" style="--kpi-accent:${kpi.accent}">
<div class="kpi-label">${kpi.label}</div>
<div class="kpi-value">${kpi.value}${kpi.unit ? html`<span>${kpi.unit}</span>` : html``}</div>
<div class="kpi-sub">${kpi.sub}</div>
<div class="kpi-foot">
<span class="delta">${kpi.deltaKind === 'live' ? html`<span class="live-dot"></span>` : html``}${kpi.delta}</span>
<div class="sparkline">${this.renderSparkline(kpi.spark, kpi.sparkColor)}</div>
</div>
</div>
`;
}
private renderPageHeader(titleArg: string, descriptionArg: string, actionArg?: TemplateResult): TemplateResult {
return html`
<header class="page-head">
<div><h1>${titleArg}</h1><div class="lead">${descriptionArg}</div></div>
${actionArg ? html`<div class="page-actions">${actionArg}</div>` : html``}
</header>
`;
}
private renderSectionCard(titleArg: string, descriptionArg: string, contentArg: TemplateResult, actionArg?: TemplateResult): TemplateResult {
return html`
<section class="section-card">
${titleArg || actionArg ? html`
<div class="section-headline">
<div><div class="section-title">${titleArg}</div>${descriptionArg ? html`<div class="section-description">${descriptionArg}</div>` : html``}</div>
${actionArg}
</div>
` : html``}
${contentArg}
</section>
`;
}
private renderFormRow(labelArg: string, hintArg: string, contentArg: TemplateResult, requiredArg = false): TemplateResult {
return html`
<div class="form-row">
<div><div class="form-label">${labelArg}${requiredArg ? html`<span style="color:var(--idp-error)">*</span>` : html``}</div>${hintArg ? html`<div class="form-hint">${hintArg}</div>` : html``}</div>
<div>${contentArg}</div>
</div>
`;
}
private renderCodeBlock(valueArg: string): TemplateResult {
return html`<div class="code-block"><code>${valueArg}</code><idp-icon name="copy" size="13"></idp-icon></div>`;
}
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`
<header class="page-head">
<div>
<div class="eyebrow-row"><span class="eyebrow">Account - Overview</span><span class="live-pill"><span class="live-dot"></span>live</span></div>
<h1>Good morning, ${firstName}.</h1>
<div class="lead">Account-wide operational snapshot using live identity data.</div>
</div>
<div class="page-actions"><button class="plain-button outline" @click=${() => this.setPage('sessions')}>Review sessions</button><button class="plain-button primary" @click=${() => this.setPage('security')}>Manage security</button></div>
</header>
<div class="body">
<div class="kpis">${kpis.map((kpiArg) => this.renderKpi(kpiArg))}</div>
${this.dataLoading || this.dataError ? this.renderDataState('No overview data', 'The console has not received live account data yet.', 'activity') : html``}
<div class="primary-grid">
<div class="card">
<div class="card-head"><span class="card-title">Recent activity</span><idp-badge>${this.activities.length} events</idp-badge></div>
${this.activities.length ? this.activities.slice(0, 8).map((activityArg) => html`
<div class="feed-item"><span class="feed-dot" style="--dot-color:var(--idp-info)"></span><div class="feed-text"><strong>${activityArg.action.replace(/_/g, ' ')}</strong> - ${activityArg.description}</div><span class="feed-meta">${this.formatTimeAgo(activityArg.timestamp)}</span></div>
`) : this.renderStateCard('No activity yet', 'Activity events will appear here after logins, app changes, and organisation updates.', 'activity')}
</div>
<div class="card">
<div class="card-head"><span class="card-title">Current sessions</span><idp-badge>${this.sessions.length} total</idp-badge></div>
${this.sessions.length ? this.sessions.slice(0, 5).map((sessionArg) => html`
<div class="feed-item"><span class="feed-dot" style="--dot-color:${sessionArg.isCurrent ? 'var(--idp-ok)' : 'var(--idp-muted-fg)'}"></span><div class="feed-text"><strong>${sessionArg.deviceName || 'Unknown device'}</strong> - <span class="mono">${sessionArg.browser} ${sessionArg.os}</span></div><span class="feed-meta">${this.formatTimeAgo(sessionArg.lastActive)}</span></div>
`) : this.renderStateCard('No sessions loaded', 'Active session telemetry is unavailable or there are no sessions.', 'monitor')}
</div>
</div>
</div>
`;
}
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.')}
<div class="body narrow-body">
${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`<div class="split-row" style="justify-content:flex-start"><span class="avatar" style="width:56px;height:56px;font-size:20px">${this.userInitials}</span><div><div class="section-title">${this.user?.name || 'Unknown User'}</div><div class="muted" style="margin-top:5px">Profile update endpoints are not exposed in this console yet.</div></div></div>`)}
${this.renderSectionCard('Personal information', '', html`
${this.renderFormRow('Full name', '', html`<input class="input" .value=${this.user?.name || ''} disabled />`, true)}
${this.renderFormRow('Username', 'Used in your public profile URL', html`<div class="input-group"><span class="input-prefix">idp.global/user/</span><input class="input" .value=${username} disabled /></div>`)}
${this.renderFormRow('Email', 'Primary address for login and notifications', html`<input class="input" .value=${this.user?.email || ''} disabled />`)}
${this.renderFormRow('Mobile number', 'Used for SMS verification', html`<input class="input" .value=${this.user?.mobileNumber || ''} disabled />`)}
`)}
${this.renderSectionCard('Account status', '', html`<div class="split-row"><div><div class="section-title">Status</div><div class="muted">Your account is currently ${status}.</div></div><idp-badge variant=${status === 'active' ? 'ok' : status === 'suspended' ? 'error' : 'warn'}>${status}</idp-badge></div>${this.globalAdmin ? html`<div class="divider"></div><div class="split-row"><div><div class="section-title">Global admin</div><div class="muted">You have platform-wide administrative access.</div></div><idp-badge variant="accent">Admin</idp-badge></div>` : html``}`)}
</div>
`;
}
private renderSecurity(): TemplateResult {
const passportRows = this.passportDevices.map((deviceArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(deviceArg.label)}</span>
<div>
<div class="identity-primary">${deviceArg.label}</div>
<div class="identity-secondary">${deviceArg.platform}${deviceArg.appVersion ? ` - ${deviceArg.appVersion}` : ''}</div>
</div>
</div>
`,
html`<idp-badge variant=${deviceArg.status === 'active' ? 'ok' : 'error'}>${deviceArg.status}</idp-badge>`,
[
deviceArg.capabilities?.push ? 'push' : '',
deviceArg.capabilities?.nfc ? 'nfc' : '',
deviceArg.capabilities?.gps ? 'gps' : '',
].filter(Boolean).join(', ') || '-',
deviceArg.lastSeenAt ? this.formatTimeAgo(deviceArg.lastSeenAt) : 'never',
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPassportDeviceEventDetail>('idp-admin-passport-revoke', { deviceId: deviceArg.id })}>Revoke</button></div>`,
],
}));
return html`
${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')}
<div class="body wide-body">
${this.credentialMessage ? html`<div class="notice-card"><idp-icon name="shield" size="16"></idp-icon><div><div class="section-title">Credential update</div><div class="muted">${this.credentialMessage}</div></div></div>` : html``}
${this.credentialError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Credential error</div><div class="muted">${this.credentialError}</div></div></div>` : html``}
${this.renderSectionCard('Session security', 'Live session data is available and sessions can be revoked from the device list.', html`
<div class="split-row"><div><div class="section-title">Active sessions</div><div class="muted">Review and revoke sessions from the Sessions & Devices page.</div></div><idp-badge variant="accent">${this.sessions.length}</idp-badge></div>
<div class="divider"></div>
<button class="plain-button outline" @click=${() => this.setPage('sessions')}>Open sessions</button>
`)}
${this.renderSectionCard('Password', 'Change your password using the existing production password endpoint. All fields are required.', html`
${this.renderFormRow('Current password', '', html`<input class="input" type="password" autocomplete="current-password" .value=${this.currentPassword} @input=${(eventArg: Event) => this.setPasswordField('current', eventArg)} />`, true)}
${this.renderFormRow('New password', 'Minimum 12 characters.', html`<input class="input" type="password" autocomplete="new-password" .value=${this.newPassword} @input=${(eventArg: Event) => this.setPasswordField('new', eventArg)} />`, true)}
${this.renderFormRow('Confirm password', '', html`<input class="input" type="password" autocomplete="new-password" .value=${this.confirmPassword} @input=${(eventArg: Event) => this.setPasswordField('confirm', eventArg)} />`, true)}
<div class="divider"></div>
<button class="plain-button primary" @click=${() => this.submitPasswordChange()}>Update password</button>
`)}
<idp-data-table
title="Passport devices"
subtitle="Cryptographic IDP Passport devices registered for this account."
badge=${`${this.passportDevices.length} total`}
empty-title="No passport devices"
empty-description="Passport devices will appear here after enrollment from the mobile passport app."
.columns=${[
{ label: 'Device' },
{ label: 'Status' },
{ label: 'Capabilities', hideBelow: 'mobile' },
{ label: 'Last seen', mono: true, hideBelow: 'mobile' },
{ label: 'Action', align: 'right' },
]}
.rows=${passportRows}
></idp-data-table>
<div class="section-card">
<div class="section-headline">
<div><div class="section-title">Enroll passport device</div><div class="section-description">Creates a signed enrollment challenge for the IDP Passport device flow.</div></div>
<button class="plain-button outline" @click=${() => this.requestPassportEnrollment()}>Create challenge</button>
</div>
${this.passportEnrollment ? html`
<div class="notice-card" style="margin-bottom:14px"><idp-icon name="key" size="16"></idp-icon><div><div class="section-title">Enrollment challenge ready</div><div class="muted">Expires ${new Date(this.passportEnrollment.expiresAt).toLocaleString()}. Use the pairing token or payload in a passport client to complete enrollment.</div></div></div>
${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`<div class="muted">No active enrollment challenge.</div>`}
</div>
<div class="primary-grid">
${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')}
</div>
</div>
`;
}
private renderSessions(): TemplateResult {
return html`
${this.renderPageHeader('Sessions & Devices', 'Active login sessions across all your devices.')}
<div class="body narrow-body"><div class="list-stack">
${this.sessions.length ? this.sessions.map((sessionArg) => html`
<div class="row-card">
<span class="icon-tile"><idp-icon name="monitor" size="17"></idp-icon></span>
<div style="flex:1">
<div style="display:flex;gap:8px;align-items:center"><span class="section-title">${sessionArg.deviceName || 'Unknown device'}</span>${sessionArg.isCurrent ? html`<idp-badge variant="ok">Current session</idp-badge>` : html``}</div>
<div class="muted">${sessionArg.browser || 'Unknown browser'} - ${sessionArg.os || 'Unknown OS'} - IP: <span class="mono">${sessionArg.ip || 'unknown'}</span></div>
<div class="muted">Started ${this.formatTimeAgo(sessionArg.createdAt)} - Active ${this.formatTimeAgo(sessionArg.lastActive)}</div>
</div>
${sessionArg.isCurrent ? html`` : html`<button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.dispatchShellEvent<IIdpAdminSessionEventDetail>('idp-admin-session-revoke', { sessionId: sessionArg.id })}>Revoke</button>`}
</div>
`) : this.renderDataState('No sessions', 'There are no active sessions for this account or session telemetry is unavailable.', 'monitor')}
</div></div>
`;
}
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.')}
<div class="body narrow-body"><div class="list-stack">
${apps.length ? apps.map((appArg) => html`<div class="row-card"><span class="app-avatar">${appArg.name.slice(0, 2).toUpperCase()}</span><div style="flex:1"><div class="section-title" style="margin-bottom:4px">${appArg.name}</div><div class="chip-row">${(appArg.scopes || []).map((scopeArg) => html`<idp-badge variant="outline">${scopeArg}</idp-badge>`)}</div><div class="muted" style="margin-top:6px">${appArg.description || appArg.appUrl || 'Connected application'}</div></div><button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.dispatchShellEvent<IIdpAdminAppToggleEventDetail>('idp-admin-app-toggle', { appId: appArg.id, connected: false })}>Revoke</button></div>`) : 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')}
</div></div>
`;
}
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`
<header class="page-head">
<div>
<div class="eyebrow-row"><span class="eyebrow">Organisation - General</span><span class="live-pill"><span class="live-dot"></span>live</span></div>
<h1>${org.name}</h1>
<div class="lead">Selected organisation dashboard for <code>${org.slug ? `@${org.slug}` : org.id || 'no organisation selected'}</code>.</div>
</div>
<div class="page-actions"><button class="plain-button outline" @click=${() => this.setPage('org-members')}>Manage members</button><button class="plain-button primary" @click=${() => this.setPage('org-apps')}>Manage apps</button></div>
</header>
<div class="body">
<div class="kpis">${kpis.map((kpiArg) => this.renderKpi(kpiArg))}</div>
${this.dataLoading || this.dataError ? this.renderDataState('No organisation data', 'The console has not received live organisation data yet.', 'building') : html``}
<div class="primary-grid">
<div class="card">
<div class="card-head"><span class="card-title">Organisation profile</span><idp-badge>${org.myRole || 'member'}</idp-badge></div>
<div style="padding:16px">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:16px"><span class="org-avatar" style="width:48px;height:48px;font-size:17px">${org.name.slice(0, 2).toUpperCase()}</span><div><div style="font-size:16px;font-weight:600;color:var(--idp-fg)">${org.name}</div><div class="mono">idp.global/org/${org.slug || 'unassigned'}</div></div></div>
${this.renderCodeBlock(org.id || 'No organisation selected')}
<div class="muted" style="margin-top:10px">Rename, slug updates, ownership transfer, and deletion are available from Settings with server-side audited confirmation.</div>
</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">Recent organisation activity</span><idp-badge>${orgActivities.length} events</idp-badge></div>
${orgActivities.length ? orgActivities.map((activityArg) => html`
<div class="feed-item"><span class="feed-dot" style="--dot-color:var(--idp-info)"></span><div class="feed-text"><strong>${activityArg.action.replace(/_/g, ' ')}</strong> - ${activityArg.description}</div><span class="feed-meta">${this.formatTimeAgo(activityArg.timestamp)}</span></div>
`) : this.renderStateCard('No org activity yet', 'Organisation-related activity will appear here when the backend reports matching activity events.', 'activity')}
</div>
</div>
</div>
`;
}
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.')}
<div class="body narrow-body">
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Settings action blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
${this.renderSectionCard('Organisation identity', `Type ${org.slug} to confirm name or slug changes. The backend verifies this confirmation before saving.`, html`
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px"><span class="org-avatar" style="width:48px;height:48px;font-size:17px">${org.name.slice(0, 2).toUpperCase()}</span><div><div style="font-size:16px;font-weight:600;color:var(--idp-fg)">${org.name}</div><div class="mono">idp.global/org/${org.slug}</div></div></div>
${this.renderFormRow('Organisation name', '', html`<input class="input" .value=${this.orgNameDraft} @input=${(eventArg: Event) => this.setOrgSettingsField('name', eventArg)} />`, true)}
${this.renderFormRow('URL slug', "Used in your org's public URLs.", html`<div class="input-group"><span class="input-prefix">idp.global/org/</span><input class="input" .value=${this.orgSlugDraft} @input=${(eventArg: Event) => this.setOrgSettingsField('slug', eventArg)} /></div>`)}
${this.renderFormRow('Confirmation', `Type ${org.slug}`, html`<input class="input" .value=${this.orgSettingsConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('settingsConfirmation', eventArg)} />`)}
<div class="divider"></div>
<button class="plain-button primary" ?disabled=${!identityChanged} @click=${() => this.submitOrgSettingsUpdate()}>Apply identity changes</button>
`)}
${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`
<select class="select" .value=${this.transferOwnerId} @change=${(eventArg: Event) => this.setOrgSettingsField('transferOwnerId', eventArg)}>
<option value="">Select a member</option>
${transferCandidates.map((memberArg) => html`<option value=${memberArg.userId}>${memberArg.name || memberArg.email} (${memberArg.email})</option>`)}
</select>
`, true)}
${this.renderFormRow('Confirmation', `Type ${transferConfirmText}`, html`<input class="input" .value=${this.transferConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('transferConfirmation', eventArg)} />`)}
<div class="divider"></div>
<button class="plain-button outline" @click=${() => this.submitOrgTransfer()}>Transfer ownership</button>
` : 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`<input class="input" .value=${this.deleteConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('deleteConfirmation', eventArg)} />`)}
<div class="divider"></div>
<button class="plain-button destructive" @click=${() => this.submitOrgDelete()}>Delete organisation</button>
`)}
</div>
`;
}
private renderOrgMembers(): TemplateResult {
const customRoleRows = this.orgRoleDefinitions.map((roleArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(roleArg.name)}</span>
<div>
<div class="identity-primary">${roleArg.name}</div>
<div class="identity-secondary">${roleArg.description || 'Custom organisation role'}</div>
</div>
</div>
`,
roleArg.key,
html`
<div class="cell-actions">
<button class="table-action" @click=${() => this.openRoleUpsertDialog(roleArg)}>Edit</button>
<button class="table-action destructive" @click=${() => this.openRoleDeleteDialog(roleArg)}>Delete</button>
</div>
`,
],
}));
const memberRows = this.orgMembers.map((memberArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(memberArg.name || memberArg.email)}</span>
<div>
<div class="identity-primary">${memberArg.name || memberArg.email}</div>
<div class="identity-secondary">${memberArg.email}</div>
</div>
</div>
`,
html`<div class="chip-row">${memberArg.roles.map((roleArg) => html`<idp-badge variant=${this.roleVariant(roleArg)}>${roleArg}</idp-badge>`)}</div>`,
memberArg.isCurrentUser ? html`<idp-badge variant="accent">You</idp-badge>` : html`<idp-badge variant="outline">Member</idp-badge>`,
memberArg.roles.includes('owner') || memberArg.isCurrentUser
? html`<div class="cell-actions"><button class="table-action" @click=${() => this.openMemberRolesDialog(memberArg)}>Edit roles</button></div>`
: html`<div class="cell-actions"><button class="table-action" @click=${() => this.openMemberRolesDialog(memberArg)}>Edit roles</button><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminMemberEventDetail>('idp-admin-member-remove', { userId: memberArg.userId })}>Remove</button></div>`,
],
}));
const invitationRows = this.orgInvitations.map((inviteArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(inviteArg.email)}</span>
<div>
<div class="identity-primary">${inviteArg.email}</div>
<div class="identity-secondary">Invited ${this.formatTimeAgo(inviteArg.invitedAt)}</div>
</div>
</div>
`,
html`<div class="chip-row">${inviteArg.roles.map((roleArg) => html`<idp-badge variant=${this.roleVariant(roleArg)}>${roleArg}</idp-badge>`)}</div>`,
new Date(inviteArg.expiresAt).toLocaleDateString(),
html`
<div class="cell-actions">
<button class="table-action" @click=${() => this.dispatchShellEvent<IIdpAdminInvitationEventDetail>('idp-admin-invitation-resend', { invitationId: inviteArg.id })}>Resend</button>
<button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminInvitationEventDetail>('idp-admin-invitation-cancel', { invitationId: inviteArg.id })}>Cancel</button>
</div>
`,
],
}));
return html`
${this.renderPageHeader('Members', `${this.orgMembers.length} members - ${this.orgInvitations.length} pending invitations`, html`<button class="plain-button primary" @click=${() => this.dispatchShellEvent('idp-admin-member-invite', { orgId: this.currentOrg.id })}><idp-icon name="plus" size="12"></idp-icon>Invite member</button>`)}
<div class="body wide-body">
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Role action blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
<idp-data-table
title="Custom organisation roles"
subtitle="Business roles defined by this organisation. Platform roles like owner/admin remain protected."
badge=${`${this.orgRoleDefinitions.length} custom`}
empty-title="No custom roles"
empty-description="Create custom roles such as Finance, Support Lead, or Contractor, then assign them to members."
.columns=${[
{ label: 'Role' },
{ label: 'Key', mono: true },
{ label: 'Action', align: 'right' },
]}
.rows=${customRoleRows}
></idp-data-table>
<div><button class="plain-button outline" @click=${() => this.openRoleUpsertDialog()}><idp-icon name="plus" size="12"></idp-icon>Add custom role</button></div>
<idp-data-table
title="Members"
badge=${`${this.orgMembers.length} total`}
empty-title="No members loaded"
empty-description="Members will appear here after organisation membership data is loaded."
.columns=${[
{ label: 'User' },
{ label: 'Roles', hideBelow: 'mobile' },
{ label: 'Status' },
{ label: 'Action', align: 'right' },
]}
.rows=${memberRows}
></idp-data-table>
${this.orgInvitations.length ? html`
<idp-data-table
title="Pending invitations"
badge=${`${this.orgInvitations.length} total`}
.columns=${[
{ label: 'Invitee' },
{ label: 'Roles', hideBelow: 'mobile' },
{ label: 'Expires', mono: true },
{ label: 'Action', align: 'right' },
]}
.rows=${invitationRows}
></idp-data-table>
` : html``}
</div>
`;
}
private renderOrgApps(): TemplateResult {
const appRows = this.orgApps.map((appArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(appArg.name)}</span>
<div>
<div class="identity-primary">${appArg.name}</div>
<div class="identity-secondary">${appArg.description || appArg.appUrl || 'Global app'}</div>
</div>
</div>
`,
appArg.clientId || '-',
html`<div class="chip-row">${(appArg.scopes || []).slice(0, 4).map((scopeArg) => html`<idp-badge variant="outline">${scopeArg}</idp-badge>`)}</div>`,
html`<idp-badge variant=${appArg.isConnected ? 'ok' : 'outline'}>${appArg.isConnected ? 'connected' : 'available'}</idp-badge>`,
html`<idp-badge variant=${appArg.roleMappings?.length ? 'accent' : 'outline'}>${appArg.roleMappings?.length || 0} mappings</idp-badge>`,
html`<div class="cell-actions">${appArg.isConnected ? html`<button class="table-action" @click=${() => this.openAppRoleMappingsDialog(appArg)}>Map roles</button>` : html``}<button class="table-action ${appArg.isConnected ? '' : 'primary'}" @click=${() => this.dispatchShellEvent<IIdpAdminAppToggleEventDetail>('idp-admin-app-toggle', { appId: appArg.id, connected: !appArg.isConnected })}>${appArg.isConnected ? 'Disconnect' : 'Connect'}</button></div>`,
],
}));
return html`
${this.renderPageHeader('Apps', "Global apps connected to this organisation.")}
<div class="body wide-body">
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">App mapping blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
<idp-data-table
title="OAuth apps"
badge=${`${this.orgApps.length} total`}
empty-title="No apps available"
empty-description="Global apps will appear here after app catalogue data is loaded."
.columns=${[
{ label: 'App' },
{ label: 'Client ID', mono: true, hideBelow: 'tablet' },
{ label: 'Scopes', hideBelow: 'mobile' },
{ label: 'Status' },
{ label: 'Role mappings' },
{ label: 'Action', align: 'right' },
]}
.rows=${appRows}
></idp-data-table>
</div>
`;
}
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.')}
<div class="body narrow-body"><div class="row-card" style="background:var(--idp-accent-soft);border-color:var(--idp-info-border)"><idp-icon name="shield" size="16" style="color:var(--idp-accent)"></idp-icon><div><div class="section-title" style="color:var(--idp-accent)">idp.global is free, forever</div><div class="muted">All platform features - authentication, passkeys, OIDC apps, team management - are included at no cost.</div></div></div><div class="list-stack">${services.map((serviceArg) => html`<div class="row-card"><span class="icon-tile"><idp-icon name=${serviceArg[4] as any} size="16"></idp-icon></span><div style="flex:1"><div style="display:flex;gap:8px;align-items:baseline"><span class="section-title">${serviceArg[0]}</span><span class="muted">- ${serviceArg[5]}</span></div><div class="muted">${serviceArg[1]}</div><div style="display:flex;align-items:center;justify-content:space-between;margin-top:10px"><div><span style="font-size:16px;font-weight:700;color:var(--idp-fg)">${serviceArg[2]}</span> <span class="muted">${serviceArg[3]}</span></div><button class="plain-button outline" disabled>Request flow pending</button></div></div></div>`)}</div></div>
`;
}
private renderGAUsers(): TemplateResult {
return html`${this.renderPageHeader('All Users', 'Platform-wide user administration.')}<div class="body wide-body">${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')}</div>`;
}
private renderGAOrgs(): TemplateResult {
const orgRows = this.orgs.map((orgArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(orgArg.name)}</span>
<div>
<div class="identity-primary">${orgArg.name}</div>
<div class="identity-secondary">${orgArg.slug ? `idp.global/org/${orgArg.slug}` : 'No slug'}</div>
</div>
</div>
`,
orgArg.slug || '-',
html`<idp-badge variant=${this.roleVariant(orgArg.myRole || 'member')}>${orgArg.myRole || 'member'}</idp-badge>`,
orgArg.id,
],
}));
return html`
${this.renderPageHeader('All Organisations', `${this.orgs.length} organisations visible to this admin session`)}
<div class="body wide-body">
<idp-data-table
title="Organisations"
badge=${`${this.orgs.length} total`}
empty-title="No organisations"
empty-description="No organisations are visible to this admin session."
.columns=${[
{ label: 'Organisation' },
{ label: 'Slug', mono: true, hideBelow: 'mobile' },
{ label: 'Role' },
{ label: 'Identifier', mono: true, hideBelow: 'tablet' },
]}
.rows=${orgRows}
></idp-data-table>
</div>
`;
}
private renderGAApps(): TemplateResult {
const apps = this.adminApps.length ? this.adminApps : this.orgApps;
const appRows = apps.map((appArg) => ({
cells: [
html`
<div class="identity-cell">
<span class="identity-avatar">${this.getInitials(appArg.name)}</span>
<div>
<div class="identity-primary">${appArg.name}</div>
<div class="identity-secondary">${appArg.description || appArg.appUrl || 'Platform app'}</div>
</div>
</div>
`,
html`<idp-badge variant=${appArg.type === 'global' ? 'accent' : 'outline'}>${appArg.type || 'global'}</idp-badge>`,
appArg.category || '-',
appArg.connectionCount ?? '-',
html`<idp-badge variant=${appArg.status === 'suspended' ? 'error' : appArg.status === 'pending_review' ? 'warn' : 'ok'}>${appArg.status || 'active'}</idp-badge>`,
],
}));
return html`
${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}
<div class="body wide-body">
<idp-data-table
title="Platform apps"
badge=${`${apps.length} total`}
empty-title="No platform apps"
empty-description="Global app administration data is not available for this session."
.columns=${[
{ label: 'App' },
{ label: 'Type' },
{ label: 'Category', hideBelow: 'mobile' },
{ label: 'Connections', align: 'right', mono: true, hideBelow: 'mobile' },
{ label: 'Status' },
]}
.rows=${appRows}
></idp-data-table>
</div>
`;
}
private renderMainContent(): TemplateResult {
const renderers: Record<TIdpAdminPage, () => 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`
<div class="shell">
${this.renderSidebar()}
<main>${this.renderMainContent()}</main>
</div>
${this.renderDialog()}
`;
}
}