2026-05-03 10:11:06 +00:00
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' ;
2026-05-07 15:35:37 +00:00
import './idp-data-table.js' ;
2026-05-03 10:11:06 +00:00
declare global {
interface HTMLElementTagNameMap {
'idp-admin-shell' : IdpAdminShell ;
}
}
type TNavItem = {
2026-05-07 15:35:37 +00:00
id : TIdpAdminPage ;
2026-05-03 10:11:06 +00:00
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 ;
} ;
2026-05-07 15:35:37 +00:00
export type TIdpAdminPage =
2026-05-03 10:11:06 +00:00
| 'overview'
| 'profile'
| 'security'
| 'sessions'
| 'apps'
| 'org-general'
2026-05-07 15:35:37 +00:00
| 'org-settings'
2026-05-03 10:11:06 +00:00
| 'org-members'
| 'org-apps'
| 'support'
| 'ga-users'
| 'ga-orgs'
| 'ga-apps' ;
2026-05-07 15:35:37 +00:00
export interface IIdpAdminUser {
name : string ;
email : string ;
initials? : string ;
username? : string ;
mobileNumber? : string ;
status? : string ;
}
export interface IIdpAdminOrg {
2026-05-03 10:11:06 +00:00
id : string ;
name : string ;
slug : string ;
2026-05-07 15:35:37 +00:00
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 ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
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 ;
}
2026-05-03 10:11:06 +00:00
@customElement ( 'idp-admin-shell' )
export class IdpAdminShell extends DeesElement {
2026-05-07 15:35:37 +00:00
public static demo = ( ) = > html ` <idp-admin-shell global-admin></idp-admin-shell> ` ;
2026-05-03 10:11:06 +00:00
public static demoGroups = [ 'idp.global v3 full pages' ] ;
public static styles = [
. . . idpElementStyles ,
css `
:host {
display: block;
2026-05-07 15:35:37 +00:00
height: 100vh;
max-height: 100vh;
min-height: 0;
overflow: hidden;
2026-05-03 10:11:06 +00:00
color: var(--idp-fg);
}
.shell {
2026-05-07 15:35:37 +00:00
height: 100%;
min-height: 0;
2026-05-03 10:11:06 +00:00
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
2026-05-07 15:35:37 +00:00
border: 0;
2026-05-03 10:11:06 +00:00
background: var(--idp-bg);
}
.sidebar {
2026-05-07 15:35:37 +00:00
height: 100%;
min-height: 0;
2026-05-03 10:11:06 +00:00
display: flex;
flex-direction: column;
2026-05-07 15:35:37 +00:00
overflow: hidden;
2026-05-03 10:11:06 +00:00
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;
2026-05-07 15:35:37 +00:00
min-height: 0;
overflow: auto;
2026-05-03 10:11:06 +00:00
padding: 10px 8px;
}
2026-05-07 15:35:37 +00:00
.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);
}
2026-05-03 10:11:06 +00:00
.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,
2026-05-07 15:35:37 +00:00
.user-avatar {
2026-05-03 10:11:06 +00:00
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 {
2026-05-07 15:35:37 +00:00
height: 100%;
min-height: 0;
2026-05-03 10:11:06 +00:00
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);
2026-05-07 15:35:37 +00:00
color: var(--idp-accent-fg);
2026-05-03 10:11:06 +00:00
}
.plain-button.ghost {
color: var(--idp-fg);
}
2026-05-07 15:35:37 +00:00
.plain-button:disabled {
opacity: 0.45;
cursor: not-allowed;
2026-05-03 10:11:06 +00:00
}
.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;
}
2026-05-07 15:35:37 +00:00
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 100;
2026-05-03 10:11:06 +00:00
display: flex;
align-items: center;
2026-05-07 15:35:37 +00:00
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.42);
2026-05-03 10:11:06 +00:00
}
2026-05-07 15:35:37 +00:00
.dialog-card {
width: min(720px, 100%);
max-height: min(760px, calc(100vh - 40px));
display: flex;
flex-direction: column;
2026-05-03 10:11:06 +00:00
overflow: hidden;
2026-05-07 15:35:37 +00:00
border: 1px solid var(--idp-border);
border-radius: 12px;
background: var(--idp-card);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
2026-05-03 10:11:06 +00:00
}
2026-05-07 15:35:37 +00:00
.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);
2026-05-03 10:11:06 +00:00
}
2026-05-07 15:35:37 +00:00
.dialog-title {
2026-05-03 10:11:06 +00:00
color: var(--idp-fg);
2026-05-07 15:35:37 +00:00
font-size: 15px;
font-weight: 700;
2026-05-03 10:11:06 +00:00
}
2026-05-07 15:35:37 +00:00
.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 {
2026-05-03 10:11:06 +00:00
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;
}
2026-05-07 15:35:37 +00:00
.page-actions {
2026-05-03 10:11:06 +00:00
flex-wrap: wrap;
}
.kpis {
grid-template-columns: 1fr;
}
2026-05-07 15:35:37 +00:00
.mapping-row {
grid-template-columns: 1fr;
2026-05-03 10:11:06 +00:00
}
}
` ,
] ;
@property ( { type : String } )
2026-05-07 15:35:37 +00:00
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 = '' ;
2026-05-03 10:11:06 +00:00
@state ( )
private accessor orgMenuOpen = false ;
@state ( )
2026-05-07 15:35:37 +00:00
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 [ ] = [ ] ;
2026-05-03 10:11:06 +00:00
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' } ,
2026-05-07 15:35:37 +00:00
{ id : 'org-settings' , label : 'Settings' , icon : 'settings' } ,
2026-05-03 10:11:06 +00:00
{ 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' } ,
] ;
2026-05-07 15:35:37 +00:00
private get safeOrgs ( ) : IIdpAdminOrg [ ] {
return Array . isArray ( this . orgs ) ? this . orgs . filter ( Boolean ) : [ ] ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
private get currentOrg ( ) : IIdpAdminOrg {
const orgs = this . safeOrgs ;
return orgs . find ( ( orgArg ) = > orgArg . id === this . selectedOrgId ) || orgs [ 0 ] || {
id : '' ,
name : 'No organisation' ,
slug : '' ,
myRole : '' ,
} ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
private get userInitials ( ) : string {
if ( this . user ? . initials ) {
return this . user . initials ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
const source = this . user ? . name || this . user ? . email || '?' ;
return source
. split ( /\s+|@/ )
. filter ( Boolean )
. map ( ( partArg ) = > partArg [ 0 ] )
. slice ( 0 , 2 )
. join ( '' )
. toUpperCase ( ) ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
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 ;
}
2026-05-03 10:11:06 +00:00
2026-05-07 15:35:37 +00:00
this . dispatchShellEvent < IIdpAdminOrgTransferEventDetail > ( 'idp-admin-org-transfer' , {
organizationId : org.id ,
newOwnerId : this.transferOwnerId ,
confirmationText : this.transferConfirmation.trim ( ) ,
} ) ;
2026-05-03 10:11:06 +00:00
}
2026-05-07 15:35:37 +00:00
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 ) {
2026-05-03 10:11:06 +00:00
this . page = pageArg ;
this . orgMenuOpen = false ;
2026-05-07 15:35:37 +00:00
this . dispatchEvent ( new CustomEvent < IIdpAdminNavigateEventDetail > ( 'idp-admin-navigate' , {
detail : { page : pageArg } ,
bubbles : true ,
composed : true ,
} ) ) ;
2026-05-03 10:11:06 +00:00
}
private selectOrg ( orgIdArg : string ) {
2026-05-07 15:35:37 +00:00
this . selectedOrgId = orgIdArg ;
2026-05-03 10:11:06 +00:00
this . orgMenuOpen = false ;
this . page = 'org-general' ;
2026-05-07 15:35:37 +00:00
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> ` ;
2026-05-03 10:11:06 +00:00
}
private renderNavGroup ( items : TNavItem [ ] , active = '' ) : TemplateResult {
return html `
<div class="nav-list">
${ items . map ( ( item ) = > html `
2026-05-07 15:35:37 +00:00
<button class="nav-item ${ item . id === active ? 'active' : '' } " @click= ${ ( ) = > this . setPage ( item . id ) } >
2026-05-03 10:11:06 +00:00
<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>
2026-05-07 15:35:37 +00:00
${ this . safeOrgs . map ( ( orgArg ) = > html `
<button class="org-menu-item ${ orgArg . id === this . selectedOrgId ? 'selected' : '' } " @click= ${ ( ) = > this . selectOrg ( orgArg . id ) } >
2026-05-03 10:11:06 +00:00
<span class="org-avatar"> ${ orgArg . name . slice ( 0 , 2 ) . toUpperCase ( ) } </span>
2026-05-07 15:35:37 +00:00
<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 ` ` }
2026-05-03 10:11:06 +00:00
</button>
` ) }
<div class="org-menu-divider"></div>
2026-05-07 15:35:37 +00:00
<button class="org-create" @click= ${ ( ) = > this . requestOrgCreate ( ) } ><span class="org-create-icon"><idp-icon name="plus" size="9"></idp-icon></span>Create organisation</button>
2026-05-03 10:11:06 +00:00
</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>
2026-05-07 15:35:37 +00:00
${ this . globalAdmin ? html ` <div class="nav-section"><div class="nav-title">Global Admin</div> ${ this . renderNavGroup ( this . adminNav , this . page ) } </div> ` : html ` ` }
2026-05-03 10:11:06 +00:00
</div>
<div class="user-footer">
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
</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 {
2026-05-07 15:35:37 +00:00
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' ,
} ,
] ;
2026-05-03 10:11:06 +00:00
return html `
<header class="page-head">
<div>
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
</div>
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
</header>
<div class="body">
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
</div>
` ;
}
private renderProfile ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
const username = this . user ? . username || this . user ? . email ? . split ( '@' ) [ 0 ] || '' ;
const status = this . user ? . status || 'active' ;
2026-05-03 10:11:06 +00:00
return html `
${ this . renderPageHeader ( 'Profile' , 'Your personal identity details visible to connected apps.' ) }
<div class="body narrow-body">
2026-05-07 15:35:37 +00:00
${ 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> ` ) }
2026-05-03 10:11:06 +00:00
${ this . renderSectionCard ( 'Personal information' , '' , html `
2026-05-07 15:35:37 +00:00
${ 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 /> ` ) }
2026-05-03 10:11:06 +00:00
` ) }
2026-05-07 15:35:37 +00:00
${ 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 ` ` } ` ) }
2026-05-03 10:11:06 +00:00
</div>
` ;
}
private renderSecurity ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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> ` ,
] ,
} ) ) ;
2026-05-03 10:11:06 +00:00
return html `
${ this . renderPageHeader ( 'Security' , 'Manage how you authenticate and protect your account.' ) }
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
` ) }
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
</div>
` ;
}
private renderSessions ( ) : TemplateResult {
return html `
2026-05-07 15:35:37 +00:00
${ 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>
2026-05-03 10:11:06 +00:00
` ;
}
private renderAccountApps ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
const apps = this . accountApps ;
2026-05-03 10:11:06 +00:00
return html `
${ this . renderPageHeader ( 'Connected Apps' , 'Third-party apps and services that have OAuth access to your account.' ) }
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
` ;
}
private renderOrgGeneral ( ) : TemplateResult {
const org = this . currentOrg ;
2026-05-07 15:35:37 +00:00
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' ,
} ,
] ;
2026-05-03 10:11:06 +00:00
return html `
2026-05-07 15:35:37 +00:00
<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.' ) }
2026-05-03 10:11:06 +00:00
<div class="body narrow-body">
2026-05-07 15:35:37 +00:00
${ 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>
` ) }
2026-05-03 10:11:06 +00:00
${ this . renderSectionCard ( 'Organisation ID' , 'Use this identifier when making API calls.' , this . renderCodeBlock ( org . id ) ) }
2026-05-07 15:35:37 +00:00
${ 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>
` ) }
2026-05-03 10:11:06 +00:00
</div>
` ;
}
private renderOrgMembers ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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>
2026-05-03 10:11:06 +00:00
</div>
</div>
2026-05-07 15:35:37 +00:00
` ,
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>
2026-05-03 10:11:06 +00:00
</div>
</div>
2026-05-07 15:35:37 +00:00
` ,
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 ` ` }
2026-05-03 10:11:06 +00:00
</div>
` ;
}
private renderOrgApps ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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> ` ,
] ,
} ) ) ;
2026-05-03 10:11:06 +00:00
return html `
2026-05-07 15:35:37 +00:00
${ 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>
2026-05-03 10:11:06 +00:00
` ;
}
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.' ) }
2026-05-07 15:35:37 +00:00
<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>
2026-05-03 10:11:06 +00:00
` ;
}
private renderGAUsers ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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> ` ;
2026-05-03 10:11:06 +00:00
}
private renderGAOrgs ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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>
` ;
2026-05-03 10:11:06 +00:00
}
private renderGAApps ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
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>
` ;
2026-05-03 10:11:06 +00:00
}
private renderMainContent ( ) : TemplateResult {
2026-05-07 15:35:37 +00:00
const renderers : Record < TIdpAdminPage , ( ) = > TemplateResult > = {
2026-05-03 10:11:06 +00:00
overview : ( ) = > this . renderOverview ( ) ,
profile : ( ) = > this . renderProfile ( ) ,
security : ( ) = > this . renderSecurity ( ) ,
sessions : ( ) = > this . renderSessions ( ) ,
apps : ( ) = > this . renderAccountApps ( ) ,
'org-general' : ( ) = > this . renderOrgGeneral ( ) ,
2026-05-07 15:35:37 +00:00
'org-settings' : ( ) = > this . renderOrgSettings ( ) ,
2026-05-03 10:11:06 +00:00
'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 `
2026-05-07 15:35:37 +00:00
<div class="shell">
2026-05-03 10:11:06 +00:00
${ this . renderSidebar ( ) }
<main> ${ this . renderMainContent ( ) } </main>
</div>
2026-05-07 15:35:37 +00:00
${ this . renderDialog ( ) }
2026-05-03 10:11:06 +00:00
` ;
}
}