2809 lines
106 KiB
TypeScript
2809 lines
106 KiB
TypeScript
import { DeesElement, html, property, state, customElement, css, type TemplateResult } from '@design.estate/dees-element';
|
|
import { idpElementStyles } from './tokens.js';
|
|
import './idp-badge.js';
|
|
import './idp-icon.js';
|
|
import './idp-data-table.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'idp-admin-shell': IdpAdminShell;
|
|
}
|
|
}
|
|
|
|
type TNavItem = {
|
|
id: TIdpAdminPage;
|
|
label: string;
|
|
icon: string;
|
|
badge?: string;
|
|
};
|
|
|
|
type TKpi = {
|
|
label: string;
|
|
value: string;
|
|
unit?: string;
|
|
delta: string;
|
|
deltaKind: 'up' | 'live';
|
|
sub: string;
|
|
accent: string;
|
|
spark: number[];
|
|
sparkColor: string;
|
|
};
|
|
|
|
export type TIdpAdminPage =
|
|
| 'overview'
|
|
| 'profile'
|
|
| 'security'
|
|
| 'sessions'
|
|
| 'apps'
|
|
| 'org-general'
|
|
| 'org-settings'
|
|
| 'org-members'
|
|
| 'org-apps'
|
|
| 'support'
|
|
| 'ga-users'
|
|
| 'ga-orgs'
|
|
| 'ga-apps';
|
|
|
|
export interface IIdpAdminUser {
|
|
name: string;
|
|
email: string;
|
|
initials?: string;
|
|
username?: string;
|
|
mobileNumber?: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface IIdpAdminOrg {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
myRole?: string;
|
|
}
|
|
|
|
export interface IIdpAdminNavigateEventDetail {
|
|
page: TIdpAdminPage;
|
|
}
|
|
|
|
export interface IIdpAdminOrgSelectEventDetail {
|
|
orgId: string;
|
|
org: IIdpAdminOrg | null;
|
|
}
|
|
|
|
export interface IIdpAdminSession {
|
|
id: string;
|
|
deviceName: string;
|
|
browser: string;
|
|
os: string;
|
|
ip: string;
|
|
lastActive: number;
|
|
createdAt: number;
|
|
isCurrent: boolean;
|
|
}
|
|
|
|
export interface IIdpAdminActivity {
|
|
id: string;
|
|
action: string;
|
|
description: string;
|
|
timestamp: number;
|
|
ip?: string;
|
|
targetType?: string;
|
|
}
|
|
|
|
export interface IIdpAdminApp {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
logoUrl?: string;
|
|
appUrl?: string;
|
|
category?: string;
|
|
type?: string;
|
|
status?: string;
|
|
isConnected?: boolean;
|
|
connectionCount?: number;
|
|
clientId?: string;
|
|
scopes?: string[];
|
|
grants?: string[];
|
|
roleMappings?: IIdpAdminAppRoleMapping[];
|
|
}
|
|
|
|
export interface IIdpAdminOrgRoleDefinition {
|
|
key: string;
|
|
name: string;
|
|
description?: string;
|
|
createdAt?: number;
|
|
updatedAt?: number;
|
|
}
|
|
|
|
export interface IIdpAdminAppRoleMapping {
|
|
orgRoleKey: string;
|
|
appRoles: string[];
|
|
permissions: string[];
|
|
scopes: string[];
|
|
}
|
|
|
|
export interface IIdpAdminMember {
|
|
userId: string;
|
|
name: string;
|
|
email: string;
|
|
roles: string[];
|
|
isCurrentUser?: boolean;
|
|
}
|
|
|
|
export interface IIdpAdminInvitation {
|
|
id: string;
|
|
email: string;
|
|
roles: string[];
|
|
invitedAt: number;
|
|
expiresAt: number;
|
|
status?: string;
|
|
}
|
|
|
|
export interface IIdpAdminPassportDevice {
|
|
id: string;
|
|
label: string;
|
|
platform: string;
|
|
status: string;
|
|
capabilities?: {
|
|
gps?: boolean;
|
|
nfc?: boolean;
|
|
push?: boolean;
|
|
};
|
|
appVersion?: string;
|
|
createdAt: number;
|
|
lastSeenAt?: number;
|
|
lastChallengeAt?: number;
|
|
pushRegistered?: boolean;
|
|
}
|
|
|
|
export interface IIdpAdminPassportEnrollment {
|
|
challengeId: string;
|
|
pairingToken: string;
|
|
pairingPayload: string;
|
|
signingPayload: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export interface IIdpAdminPasskey {
|
|
id: string;
|
|
label: string;
|
|
credentialId?: string;
|
|
status: string;
|
|
backedUp?: boolean;
|
|
deviceType?: string;
|
|
transports?: string[];
|
|
createdAt: number;
|
|
lastUsedAt?: number | null;
|
|
}
|
|
|
|
export interface IIdpAdminTotpEnrollment {
|
|
credentialId: string;
|
|
secret: string;
|
|
otpauthUrl: string;
|
|
}
|
|
|
|
export interface IIdpAdminSessionEventDetail {
|
|
sessionId: string;
|
|
}
|
|
|
|
export interface IIdpAdminAppToggleEventDetail {
|
|
appId: string;
|
|
connected: boolean;
|
|
}
|
|
|
|
export interface IIdpAdminMemberEventDetail {
|
|
userId: string;
|
|
}
|
|
|
|
export interface IIdpAdminMemberRolesEventDetail {
|
|
userId: string;
|
|
roles: string[];
|
|
}
|
|
|
|
export interface IIdpAdminInvitationEventDetail {
|
|
invitationId: string;
|
|
}
|
|
|
|
export interface IIdpAdminOrgUpdateEventDetail {
|
|
organizationId: string;
|
|
name: string;
|
|
slug: string;
|
|
confirmationText: string;
|
|
}
|
|
|
|
export interface IIdpAdminOrgTransferEventDetail {
|
|
organizationId: string;
|
|
newOwnerId: string;
|
|
confirmationText: string;
|
|
}
|
|
|
|
export interface IIdpAdminOrgDeleteEventDetail {
|
|
organizationId: string;
|
|
confirmationText: string;
|
|
}
|
|
|
|
export interface IIdpAdminOrgRoleUpsertEventDetail {
|
|
organizationId: string;
|
|
roleDefinition: {
|
|
key: string;
|
|
name: string;
|
|
description?: string;
|
|
};
|
|
}
|
|
|
|
export interface IIdpAdminOrgRoleDeleteEventDetail {
|
|
organizationId: string;
|
|
roleKey: string;
|
|
confirmationText: string;
|
|
}
|
|
|
|
export interface IIdpAdminAppRoleMappingsEventDetail {
|
|
organizationId: string;
|
|
appId: string;
|
|
roleMappings: IIdpAdminAppRoleMapping[];
|
|
}
|
|
|
|
export interface IIdpAdminPasswordChangeEventDetail {
|
|
currentPassword: string;
|
|
newPassword: string;
|
|
}
|
|
|
|
export interface IIdpAdminPassportEnrollmentEventDetail {
|
|
deviceLabel: string;
|
|
}
|
|
|
|
export interface IIdpAdminPassportDeviceEventDetail {
|
|
deviceId: string;
|
|
}
|
|
|
|
export interface IIdpAdminTotpVerifyEventDetail {
|
|
credentialId: string;
|
|
code: string;
|
|
}
|
|
|
|
export interface IIdpAdminTotpCodeEventDetail {
|
|
code: string;
|
|
}
|
|
|
|
export interface IIdpAdminPasskeyRegistrationEventDetail {
|
|
label: string;
|
|
}
|
|
|
|
export interface IIdpAdminPasskeyEventDetail {
|
|
passkeyId: string;
|
|
}
|
|
|
|
@customElement('idp-admin-shell')
|
|
export class IdpAdminShell extends DeesElement {
|
|
public static demo = () => html`<idp-admin-shell global-admin></idp-admin-shell>`;
|
|
public static demoGroups = ['idp.global v3 full pages'];
|
|
|
|
public static styles = [
|
|
...idpElementStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
height: 100vh;
|
|
max-height: 100vh;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
color: var(--idp-fg);
|
|
}
|
|
.shell {
|
|
height: 100%;
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: 220px minmax(0, 1fr);
|
|
overflow: hidden;
|
|
border: 0;
|
|
background: var(--idp-bg);
|
|
}
|
|
.sidebar {
|
|
height: 100%;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border-right: 1px solid var(--idp-border);
|
|
background: var(--idp-bg);
|
|
}
|
|
.logo-block {
|
|
padding: 14px 16px 10px;
|
|
border-bottom: 1px solid var(--idp-border);
|
|
}
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.logo-icon {
|
|
width: 26px;
|
|
height: 26px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
border-radius: 7px;
|
|
background: var(--idp-primary);
|
|
color: var(--idp-primary-fg);
|
|
}
|
|
.logo-text {
|
|
color: var(--idp-fg);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
.nav-wrap {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
padding: 10px 8px;
|
|
}
|
|
.state-card {
|
|
padding: 42px 24px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: var(--idp-radius);
|
|
background: var(--idp-card);
|
|
color: var(--idp-muted-fg);
|
|
text-align: center;
|
|
}
|
|
.state-icon {
|
|
width: 38px;
|
|
height: 38px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 12px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 10px;
|
|
background: var(--idp-muted);
|
|
color: var(--idp-muted-fg);
|
|
}
|
|
.state-title {
|
|
margin-bottom: 4px;
|
|
color: var(--idp-fg);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
.state-description {
|
|
max-width: 420px;
|
|
margin: 0 auto;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
.notice-card {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border: 1px solid var(--idp-info-border);
|
|
border-radius: var(--idp-radius);
|
|
background: var(--idp-info-bg);
|
|
color: var(--idp-info);
|
|
}
|
|
.notice-card .muted {
|
|
color: var(--idp-muted-fg);
|
|
}
|
|
.nav-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
.nav-title {
|
|
padding: 0 8px;
|
|
margin-bottom: 4px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
}
|
|
.nav-list {
|
|
display: grid;
|
|
}
|
|
.nav-item,
|
|
.org-switch,
|
|
.plain-button {
|
|
border: 0;
|
|
font-family: inherit;
|
|
}
|
|
.nav-item {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 8px;
|
|
border-radius: 7px;
|
|
background: transparent;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
text-align: left;
|
|
}
|
|
.nav-item.active {
|
|
background: var(--idp-muted);
|
|
color: var(--idp-fg);
|
|
font-weight: 500;
|
|
}
|
|
.nav-item idp-icon {
|
|
color: currentColor;
|
|
}
|
|
.nav-item.active idp-icon {
|
|
color: var(--idp-accent);
|
|
}
|
|
.org-switch-wrap {
|
|
position: relative;
|
|
margin-bottom: 2px;
|
|
}
|
|
.org-switch {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 8px;
|
|
border-radius: 7px;
|
|
background: transparent;
|
|
color: var(--idp-muted-fg);
|
|
cursor: pointer;
|
|
}
|
|
.org-switch:hover {
|
|
background: var(--idp-muted);
|
|
}
|
|
.org-switch.open {
|
|
background: var(--idp-muted);
|
|
}
|
|
.org-switch.open .org-name {
|
|
color: var(--idp-accent);
|
|
}
|
|
.org-switch.open idp-icon {
|
|
transform: rotate(180deg);
|
|
}
|
|
.org-menu {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 40;
|
|
overflow: hidden;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 10px;
|
|
background: var(--idp-card);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.org-menu-title {
|
|
padding: 6px 8px 3px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.7px;
|
|
text-transform: uppercase;
|
|
}
|
|
.org-menu-item,
|
|
.org-create {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: 0;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
text-align: left;
|
|
}
|
|
.org-menu-item {
|
|
padding: 6px 8px;
|
|
cursor: pointer;
|
|
}
|
|
.org-menu-item.selected {
|
|
background: var(--idp-accent-soft);
|
|
}
|
|
.org-menu-item .org-avatar {
|
|
width: 20px;
|
|
height: 20px;
|
|
font-size: 7px;
|
|
}
|
|
.org-menu-name {
|
|
overflow: hidden;
|
|
color: var(--idp-fg);
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.org-menu-item.selected .org-menu-name {
|
|
color: var(--idp-accent);
|
|
}
|
|
.org-menu-role {
|
|
color: var(--idp-muted-fg);
|
|
font-size: 10px;
|
|
}
|
|
.org-menu-divider {
|
|
height: 1px;
|
|
margin: 3px 0;
|
|
background: var(--idp-border);
|
|
}
|
|
.org-create {
|
|
padding: 6px 8px 8px;
|
|
color: var(--idp-muted-fg);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
.org-create-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
border: 1.5px dashed var(--idp-border);
|
|
border-radius: 5px;
|
|
}
|
|
.org-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
text-align: left;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.org-avatar,
|
|
.user-avatar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
font-family: var(--idp-mono);
|
|
font-weight: 700;
|
|
}
|
|
.org-avatar {
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 6px;
|
|
background: var(--idp-accent-soft);
|
|
color: var(--idp-accent);
|
|
font-size: 5px;
|
|
}
|
|
.user-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
padding: 10px 12px;
|
|
border-top: 1px solid var(--idp-border);
|
|
}
|
|
.user-avatar {
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 50%;
|
|
background: oklch(0.85 0.08 240);
|
|
color: oklch(0.3 0.08 240);
|
|
font-size: 9px;
|
|
}
|
|
.user-meta {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.user-name,
|
|
.user-email {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.user-name {
|
|
color: var(--idp-fg);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
.user-email {
|
|
color: var(--idp-muted-fg);
|
|
font-size: 10px;
|
|
}
|
|
main {
|
|
height: 100%;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
overflow: auto;
|
|
background: var(--idp-bg);
|
|
}
|
|
.page-head {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
padding: 24px 28px 18px;
|
|
border-bottom: 1px solid var(--idp-border);
|
|
background: var(--idp-bg);
|
|
}
|
|
.eyebrow-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.eyebrow {
|
|
color: var(--idp-muted-fg);
|
|
font-family: var(--idp-mono);
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.live-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 1px 7px;
|
|
border: 1px solid var(--idp-ok-border);
|
|
border-radius: 999px;
|
|
background: var(--idp-ok-bg);
|
|
color: var(--idp-ok);
|
|
font-family: var(--idp-mono);
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
}
|
|
.live-dot,
|
|
.feed-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--idp-ok);
|
|
box-shadow: 0 0 6px var(--idp-ok);
|
|
animation: idp-blink 1.6s ease-in-out infinite;
|
|
}
|
|
@keyframes idp-blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.35; }
|
|
}
|
|
h1,
|
|
h2 {
|
|
margin: 0;
|
|
color: var(--idp-fg);
|
|
font-family: var(--idp-display);
|
|
letter-spacing: -0.025em;
|
|
}
|
|
h1 {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
.lead {
|
|
margin-top: 4px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 13px;
|
|
}
|
|
.lead code,
|
|
.mono {
|
|
color: var(--idp-info);
|
|
font-family: var(--idp-mono);
|
|
}
|
|
.page-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.tabs {
|
|
display: inline-flex;
|
|
gap: 1px;
|
|
padding: 2px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 6px;
|
|
background: var(--idp-bg-2);
|
|
}
|
|
.tab {
|
|
padding: 4px 10px;
|
|
border-radius: 4px;
|
|
color: var(--idp-muted-fg);
|
|
font-family: var(--idp-mono);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
.tab.active {
|
|
background: var(--idp-muted);
|
|
color: var(--idp-fg);
|
|
}
|
|
.plain-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
height: 28px;
|
|
padding: 5px 10px;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: var(--idp-fg);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}
|
|
.plain-button.outline {
|
|
border: 1px solid var(--idp-border);
|
|
}
|
|
.plain-button.primary {
|
|
background: var(--idp-accent);
|
|
color: var(--idp-accent-fg);
|
|
}
|
|
.plain-button.destructive {
|
|
background: var(--idp-destructive);
|
|
color: var(--idp-accent-fg);
|
|
}
|
|
.plain-button.ghost {
|
|
color: var(--idp-fg);
|
|
}
|
|
.plain-button:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
padding: 20px 28px 32px;
|
|
}
|
|
.narrow-body {
|
|
max-width: 700px;
|
|
}
|
|
.wide-body {
|
|
max-width: 860px;
|
|
}
|
|
.section-card {
|
|
margin-bottom: 16px;
|
|
padding: 20px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: var(--idp-radius);
|
|
background: var(--idp-card);
|
|
}
|
|
.section-card.compact {
|
|
padding: 14px;
|
|
}
|
|
.section-headline {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.section-title {
|
|
color: var(--idp-fg);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
.section-description,
|
|
.muted {
|
|
color: var(--idp-muted-fg);
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 180px 1fr;
|
|
gap: 16px;
|
|
align-items: start;
|
|
padding: 12px 0;
|
|
border-bottom: 1px solid var(--idp-border);
|
|
}
|
|
.form-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
.form-label {
|
|
display: flex;
|
|
gap: 3px;
|
|
color: var(--idp-fg);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
.form-hint {
|
|
margin-top: 2px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
.input,
|
|
.select,
|
|
.textarea {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 8px;
|
|
outline: none;
|
|
background: var(--idp-bg);
|
|
color: var(--idp-fg);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
}
|
|
.input,
|
|
.select {
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
}
|
|
.textarea {
|
|
min-height: 96px;
|
|
padding: 8px 10px;
|
|
resize: vertical;
|
|
}
|
|
.input-group {
|
|
display: flex;
|
|
overflow: hidden;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 8px;
|
|
background: var(--idp-bg);
|
|
}
|
|
.input-prefix {
|
|
height: 34px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 8px;
|
|
border-right: 1px solid var(--idp-border);
|
|
background: var(--idp-muted);
|
|
color: var(--idp-muted-fg);
|
|
font-family: var(--idp-mono);
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
.input-group .input {
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
}
|
|
.divider {
|
|
height: 1px;
|
|
margin: 16px 0;
|
|
background: var(--idp-border);
|
|
}
|
|
.code-block {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 10px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 7px;
|
|
background: var(--idp-muted);
|
|
}
|
|
.code-block code {
|
|
flex: 1;
|
|
overflow-wrap: anywhere;
|
|
color: var(--idp-fg);
|
|
font-family: var(--idp-mono);
|
|
font-size: 12px;
|
|
}
|
|
.avatar,
|
|
.app-avatar,
|
|
.icon-tile {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.avatar {
|
|
border-radius: 50%;
|
|
background: oklch(0.85 0.08 240);
|
|
color: oklch(0.3 0.08 240);
|
|
font-family: var(--idp-mono);
|
|
font-weight: 600;
|
|
}
|
|
.app-avatar,
|
|
.icon-tile {
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 10px;
|
|
background: var(--idp-muted);
|
|
color: var(--idp-muted-fg);
|
|
}
|
|
.app-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
background: var(--idp-accent-soft);
|
|
color: var(--idp-accent);
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.icon-tile {
|
|
width: 38px;
|
|
height: 38px;
|
|
}
|
|
.list-stack {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
.row-card {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 14px;
|
|
padding: 16px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: var(--idp-radius);
|
|
background: var(--idp-card);
|
|
}
|
|
.split-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
.chip-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
.switch {
|
|
width: 36px;
|
|
height: 20px;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
border-radius: 10px;
|
|
background: var(--idp-accent);
|
|
}
|
|
.switch::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 18px;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
}
|
|
.sub-tabs {
|
|
width: fit-content;
|
|
display: flex;
|
|
gap: 2px;
|
|
margin-bottom: 16px;
|
|
padding: 3px;
|
|
border-radius: 8px;
|
|
background: var(--idp-muted);
|
|
}
|
|
.sub-tab {
|
|
padding: 5px 12px;
|
|
border-radius: 6px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 12px;
|
|
}
|
|
.sub-tab.active {
|
|
background: var(--idp-bg);
|
|
color: var(--idp-fg);
|
|
font-weight: 500;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
}
|
|
.kpis {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 12px;
|
|
}
|
|
.kpi,
|
|
.card {
|
|
border: 1px solid var(--idp-border);
|
|
background: var(--idp-card);
|
|
}
|
|
.kpi {
|
|
position: relative;
|
|
min-height: 132px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
overflow: hidden;
|
|
padding: 18px 20px 14px;
|
|
border-radius: 10px;
|
|
}
|
|
.kpi::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 2px;
|
|
background: var(--kpi-accent);
|
|
}
|
|
.kpi-label {
|
|
color: var(--idp-muted-fg);
|
|
font-size: 11.5px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.kpi-value {
|
|
color: var(--idp-fg);
|
|
font-family: var(--idp-display);
|
|
font-size: 30px;
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: -0.025em;
|
|
line-height: 1.1;
|
|
}
|
|
.kpi-value span {
|
|
margin-left: 2px;
|
|
color: var(--idp-muted-fg);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
.kpi-sub {
|
|
color: var(--idp-fg-3);
|
|
font-family: var(--idp-mono);
|
|
font-size: 11px;
|
|
}
|
|
.kpi-foot {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-top: auto;
|
|
}
|
|
.delta {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: var(--idp-ok);
|
|
font-family: var(--idp-mono);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
.sparkline {
|
|
width: 84px;
|
|
opacity: 0.85;
|
|
}
|
|
.primary-grid,
|
|
.secondary-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
|
|
gap: 16px;
|
|
}
|
|
.card {
|
|
overflow: hidden;
|
|
border-radius: 8px;
|
|
}
|
|
.card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--idp-border-soft);
|
|
}
|
|
.card-title {
|
|
color: var(--idp-fg);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
.feed-dot {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 4px;
|
|
background: var(--dot-color);
|
|
box-shadow: var(--dot-glow, none);
|
|
}
|
|
.feed-meta {
|
|
color: var(--idp-muted-fg);
|
|
font-family: var(--idp-mono);
|
|
font-size: 11.5px;
|
|
}
|
|
.feed-item {
|
|
display: grid;
|
|
grid-template-columns: 14px 1fr auto;
|
|
gap: 12px;
|
|
align-items: center;
|
|
padding: 11px 16px;
|
|
border-bottom: 1px solid var(--idp-border-soft);
|
|
}
|
|
.feed-item:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
.feed-text {
|
|
color: var(--idp-fg-2);
|
|
font-size: 12.5px;
|
|
}
|
|
.feed-text strong {
|
|
color: var(--idp-fg);
|
|
font-weight: 600;
|
|
}
|
|
.dialog-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
background: rgba(0, 0, 0, 0.42);
|
|
}
|
|
.dialog-card {
|
|
width: min(720px, 100%);
|
|
max-height: min(760px, calc(100vh - 40px));
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 12px;
|
|
background: var(--idp-card);
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
|
}
|
|
.dialog-card.wide {
|
|
width: min(980px, 100%);
|
|
}
|
|
.dialog-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 18px 20px 14px;
|
|
border-bottom: 1px solid var(--idp-border);
|
|
}
|
|
.dialog-title {
|
|
color: var(--idp-fg);
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
.dialog-body {
|
|
display: grid;
|
|
gap: 14px;
|
|
overflow: auto;
|
|
padding: 18px 20px;
|
|
}
|
|
.dialog-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
padding: 14px 20px;
|
|
border-top: 1px solid var(--idp-border);
|
|
}
|
|
.role-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
.role-option {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 9px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 8px;
|
|
background: var(--idp-bg);
|
|
}
|
|
.role-option input {
|
|
margin-top: 2px;
|
|
}
|
|
.mapping-row {
|
|
display: grid;
|
|
grid-template-columns: 160px repeat(3, minmax(0, 1fr));
|
|
gap: 8px;
|
|
align-items: start;
|
|
padding: 10px;
|
|
border: 1px solid var(--idp-border);
|
|
border-radius: 8px;
|
|
background: var(--idp-bg);
|
|
}
|
|
.mapping-role {
|
|
color: var(--idp-fg);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
.mapping-role .muted {
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
.mapping-row .form-row {
|
|
display: block;
|
|
padding: 0;
|
|
border-bottom: 0;
|
|
}
|
|
.mapping-row .form-label {
|
|
margin-bottom: 4px;
|
|
}
|
|
@media (max-width: 1120px) {
|
|
.shell {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.sidebar {
|
|
display: none;
|
|
}
|
|
.kpis {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.primary-grid,
|
|
.secondary-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
@media (max-width: 720px) {
|
|
.page-head,
|
|
.body {
|
|
padding-left: 20px;
|
|
padding-right: 20px;
|
|
}
|
|
.page-actions {
|
|
flex-wrap: wrap;
|
|
}
|
|
.kpis {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.mapping-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
`,
|
|
];
|
|
|
|
@property({ type: String })
|
|
public accessor page: TIdpAdminPage = 'overview';
|
|
|
|
@property({ type: Object })
|
|
public accessor user: IIdpAdminUser = {
|
|
name: '',
|
|
email: '',
|
|
};
|
|
|
|
@property({ type: Array })
|
|
public accessor orgs: IIdpAdminOrg[] = [];
|
|
|
|
@property({ type: String, attribute: 'selected-org-id' })
|
|
public accessor selectedOrgId = '';
|
|
|
|
@property({ type: Boolean, attribute: 'global-admin', reflect: true })
|
|
public accessor globalAdmin = false;
|
|
|
|
@property({ type: Boolean, attribute: 'data-loading', reflect: true })
|
|
public accessor dataLoading = false;
|
|
|
|
@property({ type: String, attribute: 'data-error' })
|
|
public accessor dataError = '';
|
|
|
|
@property({ type: Array })
|
|
public accessor sessions: IIdpAdminSession[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor activities: IIdpAdminActivity[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor orgMembers: IIdpAdminMember[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor orgInvitations: IIdpAdminInvitation[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor orgRoleDefinitions: IIdpAdminOrgRoleDefinition[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor orgApps: IIdpAdminApp[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor accountApps: IIdpAdminApp[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor adminApps: IIdpAdminApp[] = [];
|
|
|
|
@property({ type: Array })
|
|
public accessor passportDevices: IIdpAdminPassportDevice[] = [];
|
|
|
|
@property({ type: Object })
|
|
public accessor passportEnrollment: IIdpAdminPassportEnrollment | null = null;
|
|
|
|
@property({ type: Boolean, attribute: 'totp-enabled' })
|
|
public accessor totpEnabled = false;
|
|
|
|
@property({ type: Number, attribute: 'backup-codes-remaining' })
|
|
public accessor backupCodesRemaining = 0;
|
|
|
|
@property({ type: Object })
|
|
public accessor totpEnrollment: IIdpAdminTotpEnrollment | null = null;
|
|
|
|
@property({ type: Array })
|
|
public accessor passkeys: IIdpAdminPasskey[] = [];
|
|
|
|
@property({ type: String, attribute: 'credential-message' })
|
|
public accessor credentialMessage = '';
|
|
|
|
@state()
|
|
private accessor orgMenuOpen = false;
|
|
|
|
@state()
|
|
private accessor currentPassword = '';
|
|
|
|
@state()
|
|
private accessor newPassword = '';
|
|
|
|
@state()
|
|
private accessor confirmPassword = '';
|
|
|
|
@state()
|
|
private accessor credentialError = '';
|
|
|
|
@state()
|
|
private accessor orgSettingsOrgId = '';
|
|
|
|
@state()
|
|
private accessor orgNameDraft = '';
|
|
|
|
@state()
|
|
private accessor orgSlugDraft = '';
|
|
|
|
@state()
|
|
private accessor orgSettingsConfirmation = '';
|
|
|
|
@state()
|
|
private accessor transferOwnerId = '';
|
|
|
|
@state()
|
|
private accessor transferConfirmation = '';
|
|
|
|
@state()
|
|
private accessor deleteConfirmation = '';
|
|
|
|
@state()
|
|
private accessor orgSettingsError = '';
|
|
|
|
@state()
|
|
private accessor dialogMode: 'none' | 'role-upsert' | 'role-delete' | 'member-roles' | 'app-role-mappings' = 'none';
|
|
|
|
@state()
|
|
private accessor dialogError = '';
|
|
|
|
@state()
|
|
private accessor dialogRoleKey = '';
|
|
|
|
@state()
|
|
private accessor dialogRoleName = '';
|
|
|
|
@state()
|
|
private accessor dialogRoleDescription = '';
|
|
|
|
@state()
|
|
private accessor dialogRoleDeleteConfirmation = '';
|
|
|
|
@state()
|
|
private accessor dialogMember: IIdpAdminMember | null = null;
|
|
|
|
@state()
|
|
private accessor dialogMemberRoles: string[] = [];
|
|
|
|
@state()
|
|
private accessor dialogApp: IIdpAdminApp | null = null;
|
|
|
|
@state()
|
|
private accessor dialogAppMappings: IIdpAdminAppRoleMapping[] = [];
|
|
|
|
private workspaceNav: TNavItem[] = [
|
|
{ id: 'overview', label: 'Overview', icon: 'grid' },
|
|
];
|
|
|
|
private accountNav: TNavItem[] = [
|
|
{ id: 'profile', label: 'Profile', icon: 'user' },
|
|
{ id: 'security', label: 'Security', icon: 'shield' },
|
|
{ id: 'sessions', label: 'Sessions & Devices', icon: 'monitor' },
|
|
{ id: 'apps', label: 'Connected Apps', icon: 'grid' },
|
|
];
|
|
|
|
private orgNav: TNavItem[] = [
|
|
{ id: 'org-general', label: 'General', icon: 'building' },
|
|
{ id: 'org-settings', label: 'Settings', icon: 'settings' },
|
|
{ id: 'org-members', label: 'Members', icon: 'users' },
|
|
{ id: 'org-apps', label: 'OAuth Apps', icon: 'box' },
|
|
];
|
|
|
|
private supportNav: TNavItem[] = [
|
|
{ id: 'support', label: 'Support', icon: 'shield' },
|
|
];
|
|
|
|
private adminNav: TNavItem[] = [
|
|
{ id: 'ga-users', label: 'All Users', icon: 'users' },
|
|
{ id: 'ga-orgs', label: 'All Organisations', icon: 'building' },
|
|
{ id: 'ga-apps', label: 'Platform Apps', icon: 'globe' },
|
|
];
|
|
|
|
private get safeOrgs(): IIdpAdminOrg[] {
|
|
return Array.isArray(this.orgs) ? this.orgs.filter(Boolean) : [];
|
|
}
|
|
|
|
private get currentOrg(): IIdpAdminOrg {
|
|
const orgs = this.safeOrgs;
|
|
return orgs.find((orgArg) => orgArg.id === this.selectedOrgId) || orgs[0] || {
|
|
id: '',
|
|
name: 'No organisation',
|
|
slug: '',
|
|
myRole: '',
|
|
};
|
|
}
|
|
|
|
private get userInitials(): string {
|
|
if (this.user?.initials) {
|
|
return this.user.initials;
|
|
}
|
|
|
|
const source = this.user?.name || this.user?.email || '?';
|
|
return source
|
|
.split(/\s+|@/)
|
|
.filter(Boolean)
|
|
.map((partArg) => partArg[0])
|
|
.slice(0, 2)
|
|
.join('')
|
|
.toUpperCase();
|
|
}
|
|
|
|
private getInitials(valueArg?: string): string {
|
|
const source = valueArg || '?';
|
|
return source
|
|
.split(/\s+|@|-|\./)
|
|
.filter(Boolean)
|
|
.map((partArg) => partArg[0])
|
|
.slice(0, 2)
|
|
.join('')
|
|
.toUpperCase();
|
|
}
|
|
|
|
private setPasswordField(fieldArg: 'current' | 'new' | 'confirm', eventArg: Event) {
|
|
const value = (eventArg.target as HTMLInputElement).value;
|
|
if (fieldArg === 'current') this.currentPassword = value;
|
|
if (fieldArg === 'new') this.newPassword = value;
|
|
if (fieldArg === 'confirm') this.confirmPassword = value;
|
|
this.credentialError = '';
|
|
}
|
|
|
|
private submitPasswordChange() {
|
|
if (!this.currentPassword || !this.newPassword || !this.confirmPassword) {
|
|
this.credentialError = 'Enter your current password and confirm the new password.';
|
|
return;
|
|
}
|
|
|
|
if (this.newPassword.length < 12) {
|
|
this.credentialError = 'Use at least 12 characters for the new password.';
|
|
return;
|
|
}
|
|
|
|
if (this.newPassword !== this.confirmPassword) {
|
|
this.credentialError = 'New password and confirmation do not match.';
|
|
return;
|
|
}
|
|
|
|
this.dispatchShellEvent<IIdpAdminPasswordChangeEventDetail>('idp-admin-password-change', {
|
|
currentPassword: this.currentPassword,
|
|
newPassword: this.newPassword,
|
|
});
|
|
}
|
|
|
|
private syncOrgSettingsState() {
|
|
const org = this.currentOrg;
|
|
if (this.orgSettingsOrgId === org.id) {
|
|
return;
|
|
}
|
|
|
|
this.orgSettingsOrgId = org.id;
|
|
this.orgNameDraft = org.name || '';
|
|
this.orgSlugDraft = org.slug || '';
|
|
this.orgSettingsConfirmation = '';
|
|
this.transferOwnerId = '';
|
|
this.transferConfirmation = '';
|
|
this.deleteConfirmation = '';
|
|
this.orgSettingsError = '';
|
|
}
|
|
|
|
private setOrgSettingsField(
|
|
fieldArg: 'name' | 'slug' | 'settingsConfirmation' | 'transferOwnerId' | 'transferConfirmation' | 'deleteConfirmation',
|
|
eventArg: Event
|
|
) {
|
|
const value = (eventArg.target as HTMLInputElement | HTMLSelectElement).value;
|
|
if (fieldArg === 'name') this.orgNameDraft = value;
|
|
if (fieldArg === 'slug') this.orgSlugDraft = value.trim().toLowerCase();
|
|
if (fieldArg === 'settingsConfirmation') this.orgSettingsConfirmation = value;
|
|
if (fieldArg === 'transferOwnerId') this.transferOwnerId = value;
|
|
if (fieldArg === 'transferConfirmation') this.transferConfirmation = value;
|
|
if (fieldArg === 'deleteConfirmation') this.deleteConfirmation = value;
|
|
this.orgSettingsError = '';
|
|
}
|
|
|
|
private submitOrgSettingsUpdate() {
|
|
const org = this.currentOrg;
|
|
const name = this.orgNameDraft.trim();
|
|
const slug = this.orgSlugDraft.trim().toLowerCase();
|
|
if (!org.id) {
|
|
this.orgSettingsError = 'Select an organisation before updating settings.';
|
|
return;
|
|
}
|
|
if (!name || !slug) {
|
|
this.orgSettingsError = 'Organisation name and slug are required.';
|
|
return;
|
|
}
|
|
if (name === org.name && slug === org.slug) {
|
|
this.orgSettingsError = 'Change the organisation name or slug before applying settings.';
|
|
return;
|
|
}
|
|
if (this.orgSettingsConfirmation.trim() !== org.slug) {
|
|
this.orgSettingsError = `Type ${org.slug} to confirm organisation settings changes.`;
|
|
return;
|
|
}
|
|
|
|
this.dispatchShellEvent<IIdpAdminOrgUpdateEventDetail>('idp-admin-org-update', {
|
|
organizationId: org.id,
|
|
name,
|
|
slug,
|
|
confirmationText: this.orgSettingsConfirmation.trim(),
|
|
});
|
|
}
|
|
|
|
private submitOrgTransfer() {
|
|
const org = this.currentOrg;
|
|
const expectedText = `transfer ${org.slug}`;
|
|
if (!org.id) {
|
|
this.orgSettingsError = 'Select an organisation before transferring ownership.';
|
|
return;
|
|
}
|
|
if (!this.transferOwnerId) {
|
|
this.orgSettingsError = 'Select the member who should become the new owner.';
|
|
return;
|
|
}
|
|
if (this.transferConfirmation.trim() !== expectedText) {
|
|
this.orgSettingsError = `Type ${expectedText} to confirm ownership transfer.`;
|
|
return;
|
|
}
|
|
|
|
this.dispatchShellEvent<IIdpAdminOrgTransferEventDetail>('idp-admin-org-transfer', {
|
|
organizationId: org.id,
|
|
newOwnerId: this.transferOwnerId,
|
|
confirmationText: this.transferConfirmation.trim(),
|
|
});
|
|
}
|
|
|
|
private submitOrgDelete() {
|
|
const org = this.currentOrg;
|
|
const expectedText = `delete ${org.slug}`;
|
|
if (!org.id) {
|
|
this.orgSettingsError = 'Select an organisation before deleting it.';
|
|
return;
|
|
}
|
|
if (this.deleteConfirmation.trim() !== expectedText) {
|
|
this.orgSettingsError = `Type ${expectedText} to confirm organisation deletion.`;
|
|
return;
|
|
}
|
|
|
|
this.dispatchShellEvent<IIdpAdminOrgDeleteEventDetail>('idp-admin-org-delete', {
|
|
organizationId: org.id,
|
|
confirmationText: this.deleteConfirmation.trim(),
|
|
});
|
|
}
|
|
|
|
private requestPassportEnrollment() {
|
|
const fallbackLabel = typeof navigator !== 'undefined'
|
|
? navigator.userAgent.includes('Mobile') ? 'Mobile passport device' : 'Desktop passport device'
|
|
: 'Passport device';
|
|
const deviceLabel = globalThis.prompt?.('Device label', fallbackLabel)?.trim();
|
|
if (!deviceLabel) {
|
|
return;
|
|
}
|
|
|
|
this.dispatchShellEvent<IIdpAdminPassportEnrollmentEventDetail>('idp-admin-passport-enroll', {
|
|
deviceLabel,
|
|
});
|
|
}
|
|
|
|
private requestTotpEnrollment() {
|
|
this.dispatchShellEvent('idp-admin-totp-start', {});
|
|
}
|
|
|
|
private verifyTotpEnrollment() {
|
|
if (!this.totpEnrollment) {
|
|
return;
|
|
}
|
|
const code = globalThis.prompt?.('Authenticator code')?.trim();
|
|
if (!code) {
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminTotpVerifyEventDetail>('idp-admin-totp-verify', {
|
|
credentialId: this.totpEnrollment.credentialId,
|
|
code,
|
|
});
|
|
}
|
|
|
|
private disableTotp() {
|
|
const code = globalThis.prompt?.('Enter your current authenticator code to disable TOTP')?.trim();
|
|
if (!code) {
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminTotpCodeEventDetail>('idp-admin-totp-disable', { code });
|
|
}
|
|
|
|
private regenerateBackupCodes() {
|
|
const code = globalThis.prompt?.('Enter your current authenticator code to regenerate backup codes')?.trim();
|
|
if (!code) {
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminTotpCodeEventDetail>('idp-admin-backup-codes-regenerate', { code });
|
|
}
|
|
|
|
private requestPasskeyRegistration() {
|
|
const fallbackLabel = typeof navigator !== 'undefined'
|
|
? navigator.userAgent.includes('Mobile') ? 'Mobile passkey' : 'Desktop passkey'
|
|
: 'Passkey';
|
|
const label = globalThis.prompt?.('Passkey label', fallbackLabel)?.trim();
|
|
if (!label) {
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminPasskeyRegistrationEventDetail>('idp-admin-passkey-register', { label });
|
|
}
|
|
|
|
private setPage(pageArg: TIdpAdminPage) {
|
|
this.page = pageArg;
|
|
this.orgMenuOpen = false;
|
|
this.dispatchEvent(new CustomEvent<IIdpAdminNavigateEventDetail>('idp-admin-navigate', {
|
|
detail: { page: pageArg },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
|
|
private selectOrg(orgIdArg: string) {
|
|
this.selectedOrgId = orgIdArg;
|
|
this.orgMenuOpen = false;
|
|
this.page = 'org-general';
|
|
this.dispatchEvent(new CustomEvent<IIdpAdminOrgSelectEventDetail>('idp-admin-org-select', {
|
|
detail: {
|
|
orgId: orgIdArg,
|
|
org: this.orgs.find((orgArg) => orgArg.id === orgIdArg) || null,
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
|
|
private requestOrgCreate() {
|
|
this.orgMenuOpen = false;
|
|
this.dispatchEvent(new CustomEvent('idp-admin-org-create', {
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
|
|
private dispatchShellEvent<TDetail>(eventNameArg: string, detailArg: TDetail) {
|
|
this.dispatchEvent(new CustomEvent<TDetail>(eventNameArg, {
|
|
detail: detailArg,
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
|
|
private formatTimeAgo(timestampArg?: number): string {
|
|
if (!timestampArg) {
|
|
return 'unknown';
|
|
}
|
|
|
|
const diff = Date.now() - timestampArg;
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 7) return `${days}d ago`;
|
|
return new Date(timestampArg).toLocaleDateString();
|
|
}
|
|
|
|
private renderStateCard(titleArg: string, descriptionArg: string, iconArg = 'box'): TemplateResult {
|
|
return html`
|
|
<div class="state-card">
|
|
<span class="state-icon"><idp-icon name=${iconArg as any} size="17"></idp-icon></span>
|
|
<div class="state-title">${titleArg}</div>
|
|
<div class="state-description">${descriptionArg}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderDataState(emptyTitleArg: string, emptyDescriptionArg: string, iconArg = 'box'): TemplateResult | null {
|
|
if (this.dataLoading) {
|
|
return this.renderStateCard('Loading data', 'Fetching the latest account and organisation data.', 'cloud');
|
|
}
|
|
|
|
if (this.dataError) {
|
|
return this.renderStateCard('Data unavailable', this.dataError, 'alert');
|
|
}
|
|
|
|
return this.renderStateCard(emptyTitleArg, emptyDescriptionArg, iconArg);
|
|
}
|
|
|
|
private roleVariant(roleArg: string): 'default' | 'accent' | 'ok' | 'warn' | 'error' | 'outline' {
|
|
if (roleArg === 'owner') return 'accent';
|
|
if (roleArg === 'admin') return 'warn';
|
|
if (roleArg === 'editor') return 'ok';
|
|
if (roleArg === 'outlaw') return 'error';
|
|
return 'outline';
|
|
}
|
|
|
|
private get platformRoles(): IIdpAdminOrgRoleDefinition[] {
|
|
return [
|
|
{ key: 'owner', name: 'Owner', description: 'Protected idp.global owner role.' },
|
|
{ key: 'admin', name: 'Admin', description: 'Protected idp.global admin role.' },
|
|
{ key: 'editor', name: 'Editor', description: 'Standard organisation role.' },
|
|
{ key: 'viewer', name: 'Viewer', description: 'Standard organisation role.' },
|
|
{ key: 'guest', name: 'Guest', description: 'Standard organisation role.' },
|
|
{ key: 'outlaw', name: 'Outlaw', description: 'Restricted organisation role.' },
|
|
];
|
|
}
|
|
|
|
private get availableOrgRoles(): IIdpAdminOrgRoleDefinition[] {
|
|
const customRoles = this.orgRoleDefinitions || [];
|
|
const customRoleKeys = new Set(customRoles.map((roleArg) => roleArg.key));
|
|
return [
|
|
...this.platformRoles.filter((roleArg) => !customRoleKeys.has(roleArg.key)),
|
|
...customRoles,
|
|
];
|
|
}
|
|
|
|
private parseCsv(valueArg: string): string[] {
|
|
return [...new Set(valueArg.split(',').map((entryArg) => entryArg.trim()).filter(Boolean))];
|
|
}
|
|
|
|
private normalizeRoleKey(valueArg: string): string {
|
|
return valueArg.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
private closeDialog() {
|
|
this.dialogMode = 'none';
|
|
this.dialogError = '';
|
|
this.dialogRoleKey = '';
|
|
this.dialogRoleName = '';
|
|
this.dialogRoleDescription = '';
|
|
this.dialogRoleDeleteConfirmation = '';
|
|
this.dialogMember = null;
|
|
this.dialogMemberRoles = [];
|
|
this.dialogApp = null;
|
|
this.dialogAppMappings = [];
|
|
}
|
|
|
|
private openRoleUpsertDialog(roleArg?: IIdpAdminOrgRoleDefinition) {
|
|
this.dialogMode = 'role-upsert';
|
|
this.dialogError = '';
|
|
this.dialogRoleKey = roleArg?.key || '';
|
|
this.dialogRoleName = roleArg?.name || '';
|
|
this.dialogRoleDescription = roleArg?.description || '';
|
|
}
|
|
|
|
private openRoleDeleteDialog(roleArg: IIdpAdminOrgRoleDefinition) {
|
|
this.dialogMode = 'role-delete';
|
|
this.dialogError = '';
|
|
this.dialogRoleKey = roleArg.key;
|
|
this.dialogRoleName = roleArg.name;
|
|
this.dialogRoleDescription = roleArg.description || '';
|
|
this.dialogRoleDeleteConfirmation = '';
|
|
}
|
|
|
|
private openMemberRolesDialog(memberArg: IIdpAdminMember) {
|
|
this.dialogMode = 'member-roles';
|
|
this.dialogError = '';
|
|
this.dialogMember = memberArg;
|
|
this.dialogMemberRoles = [...memberArg.roles];
|
|
}
|
|
|
|
private openAppRoleMappingsDialog(appArg: IIdpAdminApp) {
|
|
this.dialogMode = 'app-role-mappings';
|
|
this.dialogError = '';
|
|
this.dialogApp = appArg;
|
|
this.dialogAppMappings = this.availableOrgRoles.map((roleArg) => {
|
|
const existingMapping = (appArg.roleMappings || []).find((mappingArg) => mappingArg.orgRoleKey === roleArg.key);
|
|
return {
|
|
orgRoleKey: roleArg.key,
|
|
appRoles: [...(existingMapping?.appRoles || [])],
|
|
permissions: [...(existingMapping?.permissions || [])],
|
|
scopes: [...(existingMapping?.scopes || [])],
|
|
};
|
|
});
|
|
}
|
|
|
|
private setDialogRoleField(fieldArg: 'key' | 'name' | 'description' | 'deleteConfirmation', eventArg: Event) {
|
|
const value = (eventArg.target as HTMLInputElement).value;
|
|
if (fieldArg === 'key') this.dialogRoleKey = this.normalizeRoleKey(value);
|
|
if (fieldArg === 'name') {
|
|
this.dialogRoleName = value;
|
|
if (!this.dialogRoleKey) {
|
|
this.dialogRoleKey = this.normalizeRoleKey(value);
|
|
}
|
|
}
|
|
if (fieldArg === 'description') this.dialogRoleDescription = value;
|
|
if (fieldArg === 'deleteConfirmation') this.dialogRoleDeleteConfirmation = value;
|
|
this.dialogError = '';
|
|
}
|
|
|
|
private toggleDialogMemberRole(roleKeyArg: string, checkedArg: boolean) {
|
|
const nextRoles = new Set(this.dialogMemberRoles);
|
|
if (checkedArg) {
|
|
nextRoles.add(roleKeyArg);
|
|
} else {
|
|
nextRoles.delete(roleKeyArg);
|
|
}
|
|
this.dialogMemberRoles = [...nextRoles];
|
|
this.dialogError = '';
|
|
}
|
|
|
|
private setDialogMappingField(roleKeyArg: string, fieldArg: 'appRoles' | 'permissions' | 'scopes', eventArg: Event) {
|
|
const value = (eventArg.target as HTMLInputElement).value;
|
|
this.dialogAppMappings = this.dialogAppMappings.map((mappingArg) => mappingArg.orgRoleKey === roleKeyArg
|
|
? { ...mappingArg, [fieldArg]: this.parseCsv(value) }
|
|
: mappingArg
|
|
);
|
|
this.dialogError = '';
|
|
}
|
|
|
|
private submitRoleUpsertDialog() {
|
|
const key = this.normalizeRoleKey(this.dialogRoleKey);
|
|
const name = this.dialogRoleName.trim();
|
|
if (!key || !name) {
|
|
this.dialogError = 'Role key and name are required.';
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminOrgRoleUpsertEventDetail>('idp-admin-org-role-upsert', {
|
|
organizationId: this.currentOrg.id,
|
|
roleDefinition: {
|
|
key,
|
|
name,
|
|
description: this.dialogRoleDescription.trim(),
|
|
},
|
|
});
|
|
this.closeDialog();
|
|
}
|
|
|
|
private submitRoleDeleteDialog() {
|
|
const expectedText = `delete role ${this.dialogRoleKey}`;
|
|
if (this.dialogRoleDeleteConfirmation.trim() !== expectedText) {
|
|
this.dialogError = `Type ${expectedText} to confirm role deletion.`;
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminOrgRoleDeleteEventDetail>('idp-admin-org-role-delete', {
|
|
organizationId: this.currentOrg.id,
|
|
roleKey: this.dialogRoleKey,
|
|
confirmationText: this.dialogRoleDeleteConfirmation.trim(),
|
|
});
|
|
this.closeDialog();
|
|
}
|
|
|
|
private submitMemberRolesDialog() {
|
|
if (!this.dialogMember) return;
|
|
if (!this.dialogMemberRoles.length) {
|
|
this.dialogError = 'At least one role is required.';
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminMemberRolesEventDetail>('idp-admin-member-roles-update', {
|
|
userId: this.dialogMember.userId,
|
|
roles: this.dialogMemberRoles,
|
|
});
|
|
this.closeDialog();
|
|
}
|
|
|
|
private submitAppRoleMappingsDialog() {
|
|
if (!this.dialogApp) return;
|
|
const roleMappings = this.dialogAppMappings
|
|
.map((mappingArg) => ({
|
|
orgRoleKey: mappingArg.orgRoleKey,
|
|
appRoles: [...mappingArg.appRoles],
|
|
permissions: [...mappingArg.permissions],
|
|
scopes: [...mappingArg.scopes],
|
|
}))
|
|
.filter((mappingArg) => mappingArg.appRoles.length || mappingArg.permissions.length || mappingArg.scopes.length);
|
|
const allowedScopes = this.dialogApp.scopes || [];
|
|
const invalidScopes = roleMappings.flatMap((mappingArg) => mappingArg.scopes).filter((scopeArg) => !allowedScopes.includes(scopeArg));
|
|
if (invalidScopes.length) {
|
|
this.dialogError = `Unsupported app scopes: ${[...new Set(invalidScopes)].join(', ')}.`;
|
|
return;
|
|
}
|
|
this.dispatchShellEvent<IIdpAdminAppRoleMappingsEventDetail>('idp-admin-app-role-mappings-update', {
|
|
organizationId: this.currentOrg.id,
|
|
appId: this.dialogApp.id,
|
|
roleMappings,
|
|
});
|
|
this.closeDialog();
|
|
}
|
|
|
|
private renderDialogError(): TemplateResult {
|
|
return this.dialogError
|
|
? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Action blocked</div><div class="muted">${this.dialogError}</div></div></div>`
|
|
: html``;
|
|
}
|
|
|
|
private renderRoleUpsertDialog(): TemplateResult {
|
|
const isEdit = this.orgRoleDefinitions.some((roleArg) => roleArg.key === this.dialogRoleKey);
|
|
return html`
|
|
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
|
|
<div class="dialog-head">
|
|
<div><div class="dialog-title">${isEdit ? 'Edit custom role' : 'Add custom role'}</div><div class="muted">Custom roles model organisation-specific business access.</div></div>
|
|
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
${this.renderDialogError()}
|
|
${this.renderFormRow('Role name', 'Readable label shown to admins.', html`<input class="input" .value=${this.dialogRoleName} @input=${(eventArg: Event) => this.setDialogRoleField('name', eventArg)} />`, true)}
|
|
${this.renderFormRow('Role key', 'Lowercase stable identifier used in assignments and app mappings.', html`<input class="input" .value=${this.dialogRoleKey} @input=${(eventArg: Event) => this.setDialogRoleField('key', eventArg)} ?disabled=${isEdit} />`, true)}
|
|
${this.renderFormRow('Description', 'Optional admin note describing when this role should be used.', html`<textarea class="textarea" .value=${this.dialogRoleDescription} @input=${(eventArg: Event) => this.setDialogRoleField('description', eventArg)}></textarea>`)}
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
|
|
<button class="plain-button primary" @click=${() => this.submitRoleUpsertDialog()}>Save role</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderRoleDeleteDialog(): TemplateResult {
|
|
const expectedText = `delete role ${this.dialogRoleKey}`;
|
|
return html`
|
|
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
|
|
<div class="dialog-head">
|
|
<div><div class="dialog-title">Delete custom role</div><div class="muted">This removes the role from member assignments and app mappings after backend confirmation.</div></div>
|
|
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
${this.renderDialogError()}
|
|
<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">${this.dialogRoleName}</div><div class="muted">Type <span class="mono">${expectedText}</span> to confirm deletion.</div></div></div>
|
|
${this.renderFormRow('Confirmation', `Type ${expectedText}`, html`<input class="input" .value=${this.dialogRoleDeleteConfirmation} @input=${(eventArg: Event) => this.setDialogRoleField('deleteConfirmation', eventArg)} />`, true)}
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
|
|
<button class="plain-button destructive" @click=${() => this.submitRoleDeleteDialog()}>Delete role</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderMemberRolesDialog(): TemplateResult {
|
|
const member = this.dialogMember;
|
|
return html`
|
|
<div class="dialog-card" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
|
|
<div class="dialog-head">
|
|
<div><div class="dialog-title">Edit member roles</div><div class="muted">${member?.name || member?.email || 'Member'} receives the selected organisation roles.</div></div>
|
|
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
${this.renderDialogError()}
|
|
<div class="role-grid">
|
|
${this.availableOrgRoles.map((roleArg) => {
|
|
const checked = this.dialogMemberRoles.includes(roleArg.key);
|
|
return html`
|
|
<label class="role-option">
|
|
<input type="checkbox" .checked=${checked} @change=${(eventArg: Event) => this.toggleDialogMemberRole(roleArg.key, (eventArg.target as HTMLInputElement).checked)} />
|
|
<span><span class="section-title">${roleArg.name}</span><span class="muted" style="display:block">${roleArg.description || roleArg.key}</span></span>
|
|
</label>
|
|
`;
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
|
|
<button class="plain-button primary" @click=${() => this.submitMemberRolesDialog()}>Save roles</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderAppRoleMappingsDialog(): TemplateResult {
|
|
const app = this.dialogApp;
|
|
return html`
|
|
<div class="dialog-card wide" @click=${(eventArg: Event) => eventArg.stopPropagation()}>
|
|
<div class="dialog-head">
|
|
<div><div class="dialog-title">Map organisation roles</div><div class="muted">Map ${this.currentOrg.name} roles to app-specific roles, permissions, and OAuth scopes for ${app?.name || 'this app'}.</div></div>
|
|
<button class="plain-button ghost" @click=${() => this.closeDialog()}>Close</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
${this.renderDialogError()}
|
|
${app?.scopes?.length ? html`<div class="muted">Available OAuth scopes: <span class="mono">${app.scopes.join(', ')}</span></div>` : html`<div class="muted">This app has no declared OAuth scopes. Role and permission mappings are still supported.</div>`}
|
|
${this.dialogAppMappings.map((mappingArg) => {
|
|
const role = this.availableOrgRoles.find((roleArg) => roleArg.key === mappingArg.orgRoleKey);
|
|
return html`
|
|
<div class="mapping-row">
|
|
<div class="mapping-role">${role?.name || mappingArg.orgRoleKey}<span class="muted">${mappingArg.orgRoleKey}</span></div>
|
|
<div>${this.renderFormRow('App roles', '', html`<input class="input" .value=${mappingArg.appRoles.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'appRoles', eventArg)} />`)}</div>
|
|
<div>${this.renderFormRow('Permissions', '', html`<input class="input" .value=${mappingArg.permissions.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'permissions', eventArg)} />`)}</div>
|
|
<div>${this.renderFormRow('Scopes', '', html`<input class="input" .value=${mappingArg.scopes.join(', ')} @input=${(eventArg: Event) => this.setDialogMappingField(mappingArg.orgRoleKey, 'scopes', eventArg)} />`)}</div>
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="plain-button outline" @click=${() => this.closeDialog()}>Cancel</button>
|
|
<button class="plain-button primary" @click=${() => this.submitAppRoleMappingsDialog()}>Save mappings</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderDialog(): TemplateResult {
|
|
if (this.dialogMode === 'none') {
|
|
return html``;
|
|
}
|
|
|
|
const dialog = this.dialogMode === 'role-upsert'
|
|
? this.renderRoleUpsertDialog()
|
|
: this.dialogMode === 'role-delete'
|
|
? this.renderRoleDeleteDialog()
|
|
: this.dialogMode === 'member-roles'
|
|
? this.renderMemberRolesDialog()
|
|
: this.renderAppRoleMappingsDialog();
|
|
return html`<div class="dialog-backdrop" @click=${() => this.closeDialog()}>${dialog}</div>`;
|
|
}
|
|
|
|
private renderNavGroup(items: TNavItem[], active = ''): TemplateResult {
|
|
return html`
|
|
<div class="nav-list">
|
|
${items.map((item) => html`
|
|
<button class="nav-item ${item.id === active ? 'active' : ''}" @click=${() => this.setPage(item.id)}>
|
|
<idp-icon name=${item.icon as any} size="14"></idp-icon>
|
|
${item.label}
|
|
${item.badge ? html`<idp-badge variant="accent" style="margin-left:auto">${item.badge}</idp-badge>` : html``}
|
|
</button>
|
|
`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSidebar(): TemplateResult {
|
|
const currentOrg = this.currentOrg;
|
|
return html`
|
|
<aside class="sidebar">
|
|
<div class="logo-block">
|
|
<div class="logo">
|
|
<div class="logo-icon" aria-hidden="true">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="logo-text">idp.global</span>
|
|
</div>
|
|
</div>
|
|
<div class="nav-wrap">
|
|
<div class="nav-section"><div class="nav-title">Workspace</div>${this.renderNavGroup(this.workspaceNav, this.page)}</div>
|
|
<div class="nav-section"><div class="nav-title">My Account</div>${this.renderNavGroup(this.accountNav, this.page)}</div>
|
|
<div class="nav-section">
|
|
<div class="nav-title">Organisation</div>
|
|
<div class="org-switch-wrap">
|
|
<button class="org-switch ${this.orgMenuOpen ? 'open' : ''}" @click=${() => { this.orgMenuOpen = !this.orgMenuOpen; }}>
|
|
<span class="org-avatar">${currentOrg.name.slice(0, 2).toUpperCase()}</span>
|
|
<span class="org-name">${currentOrg.name}</span>
|
|
<idp-icon name="chevron-down" size="10"></idp-icon>
|
|
</button>
|
|
${this.orgMenuOpen ? html`
|
|
<div class="org-menu">
|
|
<div class="org-menu-title">Switch organisation</div>
|
|
${this.safeOrgs.map((orgArg) => html`
|
|
<button class="org-menu-item ${orgArg.id === this.selectedOrgId ? 'selected' : ''}" @click=${() => this.selectOrg(orgArg.id)}>
|
|
<span class="org-avatar">${orgArg.name.slice(0, 2).toUpperCase()}</span>
|
|
<span style="flex:1;min-width:0"><span class="org-menu-name">${orgArg.name}</span><span class="org-menu-role">${orgArg.myRole || 'member'}</span></span>
|
|
${orgArg.id === this.selectedOrgId ? html`<idp-icon name="check" size="11"></idp-icon>` : html``}
|
|
</button>
|
|
`)}
|
|
<div class="org-menu-divider"></div>
|
|
<button class="org-create" @click=${() => this.requestOrgCreate()}><span class="org-create-icon"><idp-icon name="plus" size="9"></idp-icon></span>Create organisation</button>
|
|
</div>
|
|
` : html``}
|
|
</div>
|
|
${this.renderNavGroup(this.orgNav, this.page)}
|
|
</div>
|
|
<div class="nav-section"><div class="nav-title">Support</div>${this.renderNavGroup(this.supportNav, this.page)}</div>
|
|
${this.globalAdmin ? html`<div class="nav-section"><div class="nav-title">Global Admin</div>${this.renderNavGroup(this.adminNav, this.page)}</div>` : html``}
|
|
</div>
|
|
<div class="user-footer">
|
|
<span class="user-avatar">${this.userInitials}</span>
|
|
<div class="user-meta"><div class="user-name">${this.user?.name || 'Unknown User'}</div><div class="user-email">${this.user?.email || 'No email'}</div></div>
|
|
</div>
|
|
</aside>
|
|
`;
|
|
}
|
|
|
|
private renderSparkline(data: number[], color: string): TemplateResult {
|
|
const max = Math.max(...data);
|
|
const min = Math.min(...data);
|
|
const range = max - min || 1;
|
|
const width = 100;
|
|
const height = 22;
|
|
const points = data.map((valueArg, indexArg) => {
|
|
const x = (indexArg / (data.length - 1)) * width;
|
|
const y = height - ((valueArg - min) / range) * (height - 4) - 2;
|
|
return `${x},${y}`;
|
|
}).join(' ');
|
|
const area = `0,${height} ${points} ${width},${height}`;
|
|
|
|
return html`
|
|
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
|
<polygon points=${area} fill=${color} opacity="0.12"></polygon>
|
|
<polyline points=${points} fill="none" stroke=${color} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></polyline>
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
private renderKpi(kpi: TKpi): TemplateResult {
|
|
return html`
|
|
<div class="kpi" style="--kpi-accent:${kpi.accent}">
|
|
<div class="kpi-label">${kpi.label}</div>
|
|
<div class="kpi-value">${kpi.value}${kpi.unit ? html`<span>${kpi.unit}</span>` : html``}</div>
|
|
<div class="kpi-sub">${kpi.sub}</div>
|
|
<div class="kpi-foot">
|
|
<span class="delta">${kpi.deltaKind === 'live' ? html`<span class="live-dot"></span>` : html``}${kpi.delta}</span>
|
|
<div class="sparkline">${this.renderSparkline(kpi.spark, kpi.sparkColor)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPageHeader(titleArg: string, descriptionArg: string, actionArg?: TemplateResult): TemplateResult {
|
|
return html`
|
|
<header class="page-head">
|
|
<div><h1>${titleArg}</h1><div class="lead">${descriptionArg}</div></div>
|
|
${actionArg ? html`<div class="page-actions">${actionArg}</div>` : html``}
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
private renderSectionCard(titleArg: string, descriptionArg: string, contentArg: TemplateResult, actionArg?: TemplateResult): TemplateResult {
|
|
return html`
|
|
<section class="section-card">
|
|
${titleArg || actionArg ? html`
|
|
<div class="section-headline">
|
|
<div><div class="section-title">${titleArg}</div>${descriptionArg ? html`<div class="section-description">${descriptionArg}</div>` : html``}</div>
|
|
${actionArg}
|
|
</div>
|
|
` : html``}
|
|
${contentArg}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
private renderFormRow(labelArg: string, hintArg: string, contentArg: TemplateResult, requiredArg = false): TemplateResult {
|
|
return html`
|
|
<div class="form-row">
|
|
<div><div class="form-label">${labelArg}${requiredArg ? html`<span style="color:var(--idp-error)">*</span>` : html``}</div>${hintArg ? html`<div class="form-hint">${hintArg}</div>` : html``}</div>
|
|
<div>${contentArg}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderCodeBlock(valueArg: string): TemplateResult {
|
|
return html`<div class="code-block"><code>${valueArg}</code><idp-icon name="copy" size="13"></idp-icon></div>`;
|
|
}
|
|
|
|
private renderOverview(): TemplateResult {
|
|
const firstName = this.user?.name?.split(' ')[0] || 'there';
|
|
const activeSessionCount = this.sessions.filter((sessionArg) => sessionArg.isCurrent).length || this.sessions.length;
|
|
const activePassportCount = this.passportDevices.filter((deviceArg) => deviceArg.status === 'active').length;
|
|
const kpis: TKpi[] = [
|
|
{
|
|
label: 'Organisations',
|
|
value: String(this.orgs.length),
|
|
delta: 'live',
|
|
deltaKind: 'live',
|
|
spark: [1, 1, 1, 1, 1, 1, 1, Math.max(this.orgs.length, 1)],
|
|
sparkColor: 'var(--idp-spark-info)',
|
|
accent: 'var(--idp-chart-1)',
|
|
sub: 'Memberships visible to this account',
|
|
},
|
|
{
|
|
label: 'Sessions',
|
|
value: String(this.sessions.length),
|
|
delta: `${activeSessionCount} active`,
|
|
deltaKind: 'up',
|
|
spark: [1, 2, 1, 2, 3, 2, Math.max(this.sessions.length, 1)],
|
|
sparkColor: 'var(--idp-spark-up)',
|
|
accent: 'var(--idp-chart-2)',
|
|
sub: this.sessions.length ? `Latest ${this.formatTimeAgo(Math.max(...this.sessions.map((sessionArg) => sessionArg.lastActive)))}` : 'No active sessions loaded',
|
|
},
|
|
{
|
|
label: 'Passport devices',
|
|
value: String(this.passportDevices.length),
|
|
delta: `${activePassportCount} active`,
|
|
deltaKind: 'live',
|
|
spark: [1, 1, 1, Math.max(this.passportDevices.length, 1)],
|
|
sparkColor: 'var(--idp-spark-info)',
|
|
accent: 'var(--idp-chart-5)',
|
|
sub: 'Cryptographic credentials',
|
|
},
|
|
{
|
|
label: 'Activity',
|
|
value: String(this.activities.length),
|
|
delta: 'live',
|
|
deltaKind: 'live',
|
|
spark: [1, 2, 2, 3, Math.max(this.activities.length, 1)],
|
|
sparkColor: 'var(--idp-spark-info)',
|
|
accent: 'var(--idp-info)',
|
|
sub: 'Recent account events',
|
|
},
|
|
];
|
|
return html`
|
|
<header class="page-head">
|
|
<div>
|
|
<div class="eyebrow-row"><span class="eyebrow">Account - Overview</span><span class="live-pill"><span class="live-dot"></span>live</span></div>
|
|
<h1>Good morning, ${firstName}.</h1>
|
|
<div class="lead">Account-wide operational snapshot using live identity data.</div>
|
|
</div>
|
|
<div class="page-actions"><button class="plain-button outline" @click=${() => this.setPage('sessions')}>Review sessions</button><button class="plain-button primary" @click=${() => this.setPage('security')}>Manage security</button></div>
|
|
</header>
|
|
<div class="body">
|
|
<div class="kpis">${kpis.map((kpiArg) => this.renderKpi(kpiArg))}</div>
|
|
${this.dataLoading || this.dataError ? this.renderDataState('No overview data', 'The console has not received live account data yet.', 'activity') : html``}
|
|
<div class="primary-grid">
|
|
<div class="card">
|
|
<div class="card-head"><span class="card-title">Recent activity</span><idp-badge>${this.activities.length} events</idp-badge></div>
|
|
${this.activities.length ? this.activities.slice(0, 8).map((activityArg) => html`
|
|
<div class="feed-item"><span class="feed-dot" style="--dot-color:var(--idp-info)"></span><div class="feed-text"><strong>${activityArg.action.replace(/_/g, ' ')}</strong> - ${activityArg.description}</div><span class="feed-meta">${this.formatTimeAgo(activityArg.timestamp)}</span></div>
|
|
`) : this.renderStateCard('No activity yet', 'Activity events will appear here after logins, app changes, and organisation updates.', 'activity')}
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-head"><span class="card-title">Current sessions</span><idp-badge>${this.sessions.length} total</idp-badge></div>
|
|
${this.sessions.length ? this.sessions.slice(0, 5).map((sessionArg) => html`
|
|
<div class="feed-item"><span class="feed-dot" style="--dot-color:${sessionArg.isCurrent ? 'var(--idp-ok)' : 'var(--idp-muted-fg)'}"></span><div class="feed-text"><strong>${sessionArg.deviceName || 'Unknown device'}</strong> - <span class="mono">${sessionArg.browser} ${sessionArg.os}</span></div><span class="feed-meta">${this.formatTimeAgo(sessionArg.lastActive)}</span></div>
|
|
`) : this.renderStateCard('No sessions loaded', 'Active session telemetry is unavailable or there are no sessions.', 'monitor')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderProfile(): TemplateResult {
|
|
const username = this.user?.username || this.user?.email?.split('@')[0] || '';
|
|
const status = this.user?.status || 'active';
|
|
return html`
|
|
${this.renderPageHeader('Profile', 'Your personal identity details visible to connected apps.')}
|
|
<div class="body narrow-body">
|
|
${this.renderSectionCard('Avatar', 'Shown to apps that request your profile.', html`<div class="split-row" style="justify-content:flex-start"><span class="avatar" style="width:56px;height:56px;font-size:20px">${this.userInitials}</span><div><div class="section-title">${this.user?.name || 'Unknown User'}</div><div class="muted" style="margin-top:5px">Profile update endpoints are not exposed in this console yet.</div></div></div>`)}
|
|
${this.renderSectionCard('Personal information', '', html`
|
|
${this.renderFormRow('Full name', '', html`<input class="input" .value=${this.user?.name || ''} disabled />`, true)}
|
|
${this.renderFormRow('Username', 'Used in your public profile URL', html`<div class="input-group"><span class="input-prefix">idp.global/user/</span><input class="input" .value=${username} disabled /></div>`)}
|
|
${this.renderFormRow('Email', 'Primary address for login and notifications', html`<input class="input" .value=${this.user?.email || ''} disabled />`)}
|
|
${this.renderFormRow('Mobile number', 'Used for SMS verification', html`<input class="input" .value=${this.user?.mobileNumber || ''} disabled />`)}
|
|
`)}
|
|
${this.renderSectionCard('Account status', '', html`<div class="split-row"><div><div class="section-title">Status</div><div class="muted">Your account is currently ${status}.</div></div><idp-badge variant=${status === 'active' ? 'ok' : status === 'suspended' ? 'error' : 'warn'}>${status}</idp-badge></div>${this.globalAdmin ? html`<div class="divider"></div><div class="split-row"><div><div class="section-title">Global admin</div><div class="muted">You have platform-wide administrative access.</div></div><idp-badge variant="accent">Admin</idp-badge></div>` : html``}`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSecurity(): TemplateResult {
|
|
const passportRows = this.passportDevices.map((deviceArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(deviceArg.label)}</span>
|
|
<div>
|
|
<div class="identity-primary">${deviceArg.label}</div>
|
|
<div class="identity-secondary">${deviceArg.platform}${deviceArg.appVersion ? ` - ${deviceArg.appVersion}` : ''}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
html`<idp-badge variant=${deviceArg.status === 'active' ? 'ok' : 'error'}>${deviceArg.status}</idp-badge>`,
|
|
[
|
|
deviceArg.capabilities?.push ? 'push' : '',
|
|
deviceArg.capabilities?.nfc ? 'nfc' : '',
|
|
deviceArg.capabilities?.gps ? 'gps' : '',
|
|
].filter(Boolean).join(', ') || '-',
|
|
deviceArg.lastSeenAt ? this.formatTimeAgo(deviceArg.lastSeenAt) : 'never',
|
|
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPassportDeviceEventDetail>('idp-admin-passport-revoke', { deviceId: deviceArg.id })}>Revoke</button></div>`,
|
|
],
|
|
}));
|
|
const passkeyRows = this.passkeys.map((passkeyArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(passkeyArg.label)}</span>
|
|
<div>
|
|
<div class="identity-primary">${passkeyArg.label}</div>
|
|
<div class="identity-secondary">${passkeyArg.deviceType || 'passkey'}${passkeyArg.backedUp ? ' - backed up' : ''}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
html`<idp-badge variant=${passkeyArg.status === 'active' ? 'ok' : 'error'}>${passkeyArg.status}</idp-badge>`,
|
|
(passkeyArg.transports || []).join(', ') || '-',
|
|
passkeyArg.lastUsedAt ? this.formatTimeAgo(passkeyArg.lastUsedAt) : 'never',
|
|
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPasskeyEventDetail>('idp-admin-passkey-revoke', { passkeyId: passkeyArg.id })}>Revoke</button></div>`,
|
|
],
|
|
}));
|
|
|
|
return html`
|
|
${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')}
|
|
<div class="body wide-body">
|
|
${this.credentialMessage ? html`<div class="notice-card"><idp-icon name="shield" size="16"></idp-icon><div><div class="section-title">Credential update</div><div class="muted">${this.credentialMessage}</div></div></div>` : html``}
|
|
${this.credentialError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Credential error</div><div class="muted">${this.credentialError}</div></div></div>` : html``}
|
|
${this.renderSectionCard('Session security', 'Live session data is available and sessions can be revoked from the device list.', html`
|
|
<div class="split-row"><div><div class="section-title">Active sessions</div><div class="muted">Review and revoke sessions from the Sessions & Devices page.</div></div><idp-badge variant="accent">${this.sessions.length}</idp-badge></div>
|
|
<div class="divider"></div>
|
|
<button class="plain-button outline" @click=${() => this.setPage('sessions')}>Open sessions</button>
|
|
`)}
|
|
${this.renderSectionCard('Password', 'Change your password using the existing production password endpoint. All fields are required.', html`
|
|
${this.renderFormRow('Current password', '', html`<input class="input" type="password" autocomplete="current-password" .value=${this.currentPassword} @input=${(eventArg: Event) => this.setPasswordField('current', eventArg)} />`, true)}
|
|
${this.renderFormRow('New password', 'Minimum 12 characters.', html`<input class="input" type="password" autocomplete="new-password" .value=${this.newPassword} @input=${(eventArg: Event) => this.setPasswordField('new', eventArg)} />`, true)}
|
|
${this.renderFormRow('Confirm password', '', html`<input class="input" type="password" autocomplete="new-password" .value=${this.confirmPassword} @input=${(eventArg: Event) => this.setPasswordField('confirm', eventArg)} />`, true)}
|
|
<div class="divider"></div>
|
|
<button class="plain-button primary" @click=${() => this.submitPasswordChange()}>Update password</button>
|
|
`)}
|
|
${this.renderSectionCard('Authenticator app', 'TOTP protects password and magic-link sign-ins with a six-digit code from an authenticator app.', html`
|
|
<div class="split-row"><div><div class="section-title">Status</div><div class="muted">${this.totpEnabled ? `${this.backupCodesRemaining} backup codes remaining.` : 'No authenticator app is enrolled.'}</div></div><idp-badge variant=${this.totpEnabled ? 'ok' : 'warn'}>${this.totpEnabled ? 'Enabled' : 'Disabled'}</idp-badge></div>
|
|
${this.totpEnrollment ? html`
|
|
<div class="divider"></div>
|
|
<div class="notice-card"><idp-icon name="qr" size="16"></idp-icon><div><div class="section-title">TOTP setup pending</div><div class="muted">Scan the otpauth URL or enter the manual secret, then verify the current code.</div></div></div>
|
|
${this.renderFormRow('Manual secret', '', this.renderCodeBlock(this.totpEnrollment.secret))}
|
|
${this.renderFormRow('otpauth URL', '', this.renderCodeBlock(this.totpEnrollment.otpauthUrl))}
|
|
<button class="plain-button primary" @click=${() => this.verifyTotpEnrollment()}>Verify setup code</button>
|
|
` : html`
|
|
<div class="divider"></div>
|
|
${this.totpEnabled ? html`
|
|
<div class="cell-actions" style="justify-content:flex-start">
|
|
<button class="plain-button outline" @click=${() => this.regenerateBackupCodes()}>Regenerate backup codes</button>
|
|
<button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.disableTotp()}>Disable TOTP</button>
|
|
</div>
|
|
` : html`<button class="plain-button primary" @click=${() => this.requestTotpEnrollment()}>Enable authenticator app</button>`}
|
|
`}
|
|
`)}
|
|
<idp-data-table
|
|
title="Passkeys"
|
|
subtitle="WebAuthn credentials for passwordless login and MFA step-up."
|
|
badge=${`${this.passkeys.length} total`}
|
|
empty-title="No passkeys"
|
|
empty-description="Register a passkey to sign in with platform authenticators or security keys."
|
|
.columns=${[
|
|
{ label: 'Passkey' },
|
|
{ label: 'Status' },
|
|
{ label: 'Transports', hideBelow: 'mobile' },
|
|
{ label: 'Last used', mono: true, hideBelow: 'mobile' },
|
|
{ label: 'Action', align: 'right' },
|
|
]}
|
|
.rows=${passkeyRows}
|
|
></idp-data-table>
|
|
<div class="section-card">
|
|
<div class="section-headline">
|
|
<div><div class="section-title">Register passkey</div><div class="section-description">Creates a WebAuthn registration challenge in this browser.</div></div>
|
|
<button class="plain-button outline" @click=${() => this.requestPasskeyRegistration()}>Register passkey</button>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
`;
|
|
}
|
|
|
|
private renderSessions(): TemplateResult {
|
|
return html`
|
|
${this.renderPageHeader('Sessions & Devices', 'Active login sessions across all your devices.')}
|
|
<div class="body narrow-body"><div class="list-stack">
|
|
${this.sessions.length ? this.sessions.map((sessionArg) => html`
|
|
<div class="row-card">
|
|
<span class="icon-tile"><idp-icon name="monitor" size="17"></idp-icon></span>
|
|
<div style="flex:1">
|
|
<div style="display:flex;gap:8px;align-items:center"><span class="section-title">${sessionArg.deviceName || 'Unknown device'}</span>${sessionArg.isCurrent ? html`<idp-badge variant="ok">Current session</idp-badge>` : html``}</div>
|
|
<div class="muted">${sessionArg.browser || 'Unknown browser'} - ${sessionArg.os || 'Unknown OS'} - IP: <span class="mono">${sessionArg.ip || 'unknown'}</span></div>
|
|
<div class="muted">Started ${this.formatTimeAgo(sessionArg.createdAt)} - Active ${this.formatTimeAgo(sessionArg.lastActive)}</div>
|
|
</div>
|
|
${sessionArg.isCurrent ? html`` : html`<button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.dispatchShellEvent<IIdpAdminSessionEventDetail>('idp-admin-session-revoke', { sessionId: sessionArg.id })}>Revoke</button>`}
|
|
</div>
|
|
`) : this.renderDataState('No sessions', 'There are no active sessions for this account or session telemetry is unavailable.', 'monitor')}
|
|
</div></div>
|
|
`;
|
|
}
|
|
|
|
private renderAccountApps(): TemplateResult {
|
|
const apps = this.accountApps;
|
|
return html`
|
|
${this.renderPageHeader('Connected Apps', 'Third-party apps and services that have OAuth access to your account.')}
|
|
<div class="body narrow-body"><div class="list-stack">
|
|
${apps.length ? apps.map((appArg) => html`<div class="row-card"><span class="app-avatar">${appArg.name.slice(0, 2).toUpperCase()}</span><div style="flex:1"><div class="section-title" style="margin-bottom:4px">${appArg.name}</div><div class="chip-row">${(appArg.scopes || []).map((scopeArg) => html`<idp-badge variant="outline">${scopeArg}</idp-badge>`)}</div><div class="muted" style="margin-top:6px">${appArg.description || appArg.appUrl || 'Connected application'}</div></div><button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.dispatchShellEvent<IIdpAdminAppToggleEventDetail>('idp-admin-app-toggle', { appId: appArg.id, connected: false })}>Revoke</button></div>`) : this.renderDataState('Account app grants not connected', 'No account-level OAuth grant endpoint is wired into this app yet. Organisation app connections are managed under Organisation > OAuth Apps.', 'grid')}
|
|
</div></div>
|
|
`;
|
|
}
|
|
|
|
private renderOrgGeneral(): TemplateResult {
|
|
const org = this.currentOrg;
|
|
const connectedAppCount = this.orgApps.filter((appArg) => appArg.isConnected).length;
|
|
const orgActivities = this.activities
|
|
.filter((activityArg) => {
|
|
const searchableText = `${activityArg.targetType || ''} ${activityArg.description || ''}`.toLowerCase();
|
|
return Boolean(org.slug && searchableText.includes(org.slug.toLowerCase()))
|
|
|| Boolean(org.name && searchableText.includes(org.name.toLowerCase()))
|
|
|| searchableText.includes('org')
|
|
|| searchableText.includes('organization')
|
|
|| searchableText.includes('organisation');
|
|
})
|
|
.slice(0, 5);
|
|
const kpis: TKpi[] = [
|
|
{
|
|
label: 'Members',
|
|
value: String(this.orgMembers.length),
|
|
delta: `${this.orgInvitations.length} pending`,
|
|
deltaKind: 'up',
|
|
spark: [1, 1, 2, Math.max(this.orgMembers.length, 1)],
|
|
sparkColor: 'var(--idp-spark-up)',
|
|
accent: 'var(--idp-chart-5)',
|
|
sub: org.name,
|
|
},
|
|
{
|
|
label: 'Connected apps',
|
|
value: String(connectedAppCount),
|
|
delta: `${this.orgApps.length} available`,
|
|
deltaKind: 'live',
|
|
spark: [1, 2, 2, Math.max(connectedAppCount, 1)],
|
|
sparkColor: 'var(--idp-spark-info)',
|
|
accent: 'var(--idp-info)',
|
|
sub: 'Organisation OAuth catalogue',
|
|
},
|
|
{
|
|
label: 'Role',
|
|
value: org.myRole || 'member',
|
|
delta: 'live',
|
|
deltaKind: 'live',
|
|
spark: [1, 1, 1, 1],
|
|
sparkColor: 'var(--idp-spark-info)',
|
|
accent: 'var(--idp-chart-1)',
|
|
sub: 'Your access level',
|
|
},
|
|
{
|
|
label: 'Activity',
|
|
value: String(orgActivities.length),
|
|
delta: `${this.activities.length} account events`,
|
|
deltaKind: 'up',
|
|
spark: [1, 1, Math.max(orgActivities.length, 1)],
|
|
sparkColor: 'var(--idp-spark-up)',
|
|
accent: 'var(--idp-chart-2)',
|
|
sub: 'Recent org-related events',
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<header class="page-head">
|
|
<div>
|
|
<div class="eyebrow-row"><span class="eyebrow">Organisation - General</span><span class="live-pill"><span class="live-dot"></span>live</span></div>
|
|
<h1>${org.name}</h1>
|
|
<div class="lead">Selected organisation dashboard for <code>${org.slug ? `@${org.slug}` : org.id || 'no organisation selected'}</code>.</div>
|
|
</div>
|
|
<div class="page-actions"><button class="plain-button outline" @click=${() => this.setPage('org-members')}>Manage members</button><button class="plain-button primary" @click=${() => this.setPage('org-apps')}>Manage apps</button></div>
|
|
</header>
|
|
<div class="body">
|
|
<div class="kpis">${kpis.map((kpiArg) => this.renderKpi(kpiArg))}</div>
|
|
${this.dataLoading || this.dataError ? this.renderDataState('No organisation data', 'The console has not received live organisation data yet.', 'building') : html``}
|
|
<div class="primary-grid">
|
|
<div class="card">
|
|
<div class="card-head"><span class="card-title">Organisation profile</span><idp-badge>${org.myRole || 'member'}</idp-badge></div>
|
|
<div style="padding:16px">
|
|
<div style="display:flex;align-items:center;gap:14px;margin-bottom:16px"><span class="org-avatar" style="width:48px;height:48px;font-size:17px">${org.name.slice(0, 2).toUpperCase()}</span><div><div style="font-size:16px;font-weight:600;color:var(--idp-fg)">${org.name}</div><div class="mono">idp.global/org/${org.slug || 'unassigned'}</div></div></div>
|
|
${this.renderCodeBlock(org.id || 'No organisation selected')}
|
|
<div class="muted" style="margin-top:10px">Rename, slug updates, ownership transfer, and deletion are available from Settings with server-side audited confirmation.</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-head"><span class="card-title">Recent organisation activity</span><idp-badge>${orgActivities.length} events</idp-badge></div>
|
|
${orgActivities.length ? orgActivities.map((activityArg) => html`
|
|
<div class="feed-item"><span class="feed-dot" style="--dot-color:var(--idp-info)"></span><div class="feed-text"><strong>${activityArg.action.replace(/_/g, ' ')}</strong> - ${activityArg.description}</div><span class="feed-meta">${this.formatTimeAgo(activityArg.timestamp)}</span></div>
|
|
`) : this.renderStateCard('No org activity yet', 'Organisation-related activity will appear here when the backend reports matching activity events.', 'activity')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderOrgSettings(): TemplateResult {
|
|
const org = this.currentOrg;
|
|
this.syncOrgSettingsState();
|
|
const transferCandidates = this.orgMembers.filter((memberArg) => !memberArg.isCurrentUser);
|
|
const transferConfirmText = `transfer ${org.slug}`;
|
|
const deleteConfirmText = `delete ${org.slug}`;
|
|
const identityChanged = this.orgNameDraft.trim() !== org.name || this.orgSlugDraft.trim().toLowerCase() !== org.slug;
|
|
return html`
|
|
${this.renderPageHeader('Organisation Settings', 'Audited configuration and owner-controlled destructive operations.')}
|
|
<div class="body narrow-body">
|
|
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Settings action blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
|
|
${this.renderSectionCard('Organisation identity', `Type ${org.slug} to confirm name or slug changes. The backend verifies this confirmation before saving.`, html`
|
|
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px"><span class="org-avatar" style="width:48px;height:48px;font-size:17px">${org.name.slice(0, 2).toUpperCase()}</span><div><div style="font-size:16px;font-weight:600;color:var(--idp-fg)">${org.name}</div><div class="mono">idp.global/org/${org.slug}</div></div></div>
|
|
${this.renderFormRow('Organisation name', '', html`<input class="input" .value=${this.orgNameDraft} @input=${(eventArg: Event) => this.setOrgSettingsField('name', eventArg)} />`, true)}
|
|
${this.renderFormRow('URL slug', "Used in your org's public URLs.", html`<div class="input-group"><span class="input-prefix">idp.global/org/</span><input class="input" .value=${this.orgSlugDraft} @input=${(eventArg: Event) => this.setOrgSettingsField('slug', eventArg)} /></div>`)}
|
|
${this.renderFormRow('Confirmation', `Type ${org.slug}`, html`<input class="input" .value=${this.orgSettingsConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('settingsConfirmation', eventArg)} />`)}
|
|
<div class="divider"></div>
|
|
<button class="plain-button primary" ?disabled=${!identityChanged} @click=${() => this.submitOrgSettingsUpdate()}>Apply identity changes</button>
|
|
`)}
|
|
${this.renderSectionCard('Organisation ID', 'Use this identifier when making API calls.', this.renderCodeBlock(org.id))}
|
|
${this.renderSectionCard('Transfer ownership', `Owner-only operation. Type ${transferConfirmText} to confirm.`, html`
|
|
${transferCandidates.length ? html`
|
|
${this.renderFormRow('New owner', 'The target user must already be an organisation member.', html`
|
|
<select class="select" .value=${this.transferOwnerId} @change=${(eventArg: Event) => this.setOrgSettingsField('transferOwnerId', eventArg)}>
|
|
<option value="">Select a member</option>
|
|
${transferCandidates.map((memberArg) => html`<option value=${memberArg.userId}>${memberArg.name || memberArg.email} (${memberArg.email})</option>`)}
|
|
</select>
|
|
`, true)}
|
|
${this.renderFormRow('Confirmation', `Type ${transferConfirmText}`, html`<input class="input" .value=${this.transferConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('transferConfirmation', eventArg)} />`)}
|
|
<div class="divider"></div>
|
|
<button class="plain-button outline" @click=${() => this.submitOrgTransfer()}>Transfer ownership</button>
|
|
` : this.renderStateCard('No transfer candidates loaded', 'Load organisation members before transferring ownership.', 'users')}
|
|
`)}
|
|
${this.renderSectionCard('Delete organisation', `Owner-only destructive operation. Type ${deleteConfirmText} to permanently remove this organisation, its memberships, pending invitations, billing records, and app connections.`, html`
|
|
${this.renderFormRow('Confirmation', `Type ${deleteConfirmText}`, html`<input class="input" .value=${this.deleteConfirmation} @input=${(eventArg: Event) => this.setOrgSettingsField('deleteConfirmation', eventArg)} />`)}
|
|
<div class="divider"></div>
|
|
<button class="plain-button destructive" @click=${() => this.submitOrgDelete()}>Delete organisation</button>
|
|
`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderOrgMembers(): TemplateResult {
|
|
const customRoleRows = this.orgRoleDefinitions.map((roleArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(roleArg.name)}</span>
|
|
<div>
|
|
<div class="identity-primary">${roleArg.name}</div>
|
|
<div class="identity-secondary">${roleArg.description || 'Custom organisation role'}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
roleArg.key,
|
|
html`
|
|
<div class="cell-actions">
|
|
<button class="table-action" @click=${() => this.openRoleUpsertDialog(roleArg)}>Edit</button>
|
|
<button class="table-action destructive" @click=${() => this.openRoleDeleteDialog(roleArg)}>Delete</button>
|
|
</div>
|
|
`,
|
|
],
|
|
}));
|
|
const memberRows = this.orgMembers.map((memberArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(memberArg.name || memberArg.email)}</span>
|
|
<div>
|
|
<div class="identity-primary">${memberArg.name || memberArg.email}</div>
|
|
<div class="identity-secondary">${memberArg.email}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
html`<div class="chip-row">${memberArg.roles.map((roleArg) => html`<idp-badge variant=${this.roleVariant(roleArg)}>${roleArg}</idp-badge>`)}</div>`,
|
|
memberArg.isCurrentUser ? html`<idp-badge variant="accent">You</idp-badge>` : html`<idp-badge variant="outline">Member</idp-badge>`,
|
|
memberArg.roles.includes('owner') || memberArg.isCurrentUser
|
|
? html`<div class="cell-actions"><button class="table-action" @click=${() => this.openMemberRolesDialog(memberArg)}>Edit roles</button></div>`
|
|
: html`<div class="cell-actions"><button class="table-action" @click=${() => this.openMemberRolesDialog(memberArg)}>Edit roles</button><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminMemberEventDetail>('idp-admin-member-remove', { userId: memberArg.userId })}>Remove</button></div>`,
|
|
],
|
|
}));
|
|
const invitationRows = this.orgInvitations.map((inviteArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(inviteArg.email)}</span>
|
|
<div>
|
|
<div class="identity-primary">${inviteArg.email}</div>
|
|
<div class="identity-secondary">Invited ${this.formatTimeAgo(inviteArg.invitedAt)}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
html`<div class="chip-row">${inviteArg.roles.map((roleArg) => html`<idp-badge variant=${this.roleVariant(roleArg)}>${roleArg}</idp-badge>`)}</div>`,
|
|
new Date(inviteArg.expiresAt).toLocaleDateString(),
|
|
html`
|
|
<div class="cell-actions">
|
|
<button class="table-action" @click=${() => this.dispatchShellEvent<IIdpAdminInvitationEventDetail>('idp-admin-invitation-resend', { invitationId: inviteArg.id })}>Resend</button>
|
|
<button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminInvitationEventDetail>('idp-admin-invitation-cancel', { invitationId: inviteArg.id })}>Cancel</button>
|
|
</div>
|
|
`,
|
|
],
|
|
}));
|
|
|
|
return html`
|
|
${this.renderPageHeader('Members', `${this.orgMembers.length} members - ${this.orgInvitations.length} pending invitations`, html`<button class="plain-button primary" @click=${() => this.dispatchShellEvent('idp-admin-member-invite', { orgId: this.currentOrg.id })}><idp-icon name="plus" size="12"></idp-icon>Invite member</button>`)}
|
|
<div class="body wide-body">
|
|
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">Role action blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
|
|
<idp-data-table
|
|
title="Custom organisation roles"
|
|
subtitle="Business roles defined by this organisation. Platform roles like owner/admin remain protected."
|
|
badge=${`${this.orgRoleDefinitions.length} custom`}
|
|
empty-title="No custom roles"
|
|
empty-description="Create custom roles such as Finance, Support Lead, or Contractor, then assign them to members."
|
|
.columns=${[
|
|
{ label: 'Role' },
|
|
{ label: 'Key', mono: true },
|
|
{ label: 'Action', align: 'right' },
|
|
]}
|
|
.rows=${customRoleRows}
|
|
></idp-data-table>
|
|
<div><button class="plain-button outline" @click=${() => this.openRoleUpsertDialog()}><idp-icon name="plus" size="12"></idp-icon>Add custom role</button></div>
|
|
<idp-data-table
|
|
title="Members"
|
|
badge=${`${this.orgMembers.length} total`}
|
|
empty-title="No members loaded"
|
|
empty-description="Members will appear here after organisation membership data is loaded."
|
|
.columns=${[
|
|
{ label: 'User' },
|
|
{ label: 'Roles', hideBelow: 'mobile' },
|
|
{ label: 'Status' },
|
|
{ label: 'Action', align: 'right' },
|
|
]}
|
|
.rows=${memberRows}
|
|
></idp-data-table>
|
|
${this.orgInvitations.length ? html`
|
|
<idp-data-table
|
|
title="Pending invitations"
|
|
badge=${`${this.orgInvitations.length} total`}
|
|
.columns=${[
|
|
{ label: 'Invitee' },
|
|
{ label: 'Roles', hideBelow: 'mobile' },
|
|
{ label: 'Expires', mono: true },
|
|
{ label: 'Action', align: 'right' },
|
|
]}
|
|
.rows=${invitationRows}
|
|
></idp-data-table>
|
|
` : html``}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderOrgApps(): TemplateResult {
|
|
const appRows = this.orgApps.map((appArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(appArg.name)}</span>
|
|
<div>
|
|
<div class="identity-primary">${appArg.name}</div>
|
|
<div class="identity-secondary">${appArg.description || appArg.appUrl || 'Global app'}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
appArg.clientId || '-',
|
|
html`<div class="chip-row">${(appArg.scopes || []).slice(0, 4).map((scopeArg) => html`<idp-badge variant="outline">${scopeArg}</idp-badge>`)}</div>`,
|
|
html`<idp-badge variant=${appArg.isConnected ? 'ok' : 'outline'}>${appArg.isConnected ? 'connected' : 'available'}</idp-badge>`,
|
|
html`<idp-badge variant=${appArg.roleMappings?.length ? 'accent' : 'outline'}>${appArg.roleMappings?.length || 0} mappings</idp-badge>`,
|
|
html`<div class="cell-actions">${appArg.isConnected ? html`<button class="table-action" @click=${() => this.openAppRoleMappingsDialog(appArg)}>Map roles</button>` : html``}<button class="table-action ${appArg.isConnected ? '' : 'primary'}" @click=${() => this.dispatchShellEvent<IIdpAdminAppToggleEventDetail>('idp-admin-app-toggle', { appId: appArg.id, connected: !appArg.isConnected })}>${appArg.isConnected ? 'Disconnect' : 'Connect'}</button></div>`,
|
|
],
|
|
}));
|
|
|
|
return html`
|
|
${this.renderPageHeader('Apps', "Global apps connected to this organisation.")}
|
|
<div class="body wide-body">
|
|
${this.orgSettingsError ? html`<div class="notice-card" style="border-color:var(--idp-error-border);background:var(--idp-error-bg)"><idp-icon name="alert" size="16" style="color:var(--idp-error)"></idp-icon><div><div class="section-title" style="color:var(--idp-error)">App mapping blocked</div><div class="muted">${this.orgSettingsError}</div></div></div>` : html``}
|
|
<idp-data-table
|
|
title="OAuth apps"
|
|
badge=${`${this.orgApps.length} total`}
|
|
empty-title="No apps available"
|
|
empty-description="Global apps will appear here after app catalogue data is loaded."
|
|
.columns=${[
|
|
{ label: 'App' },
|
|
{ label: 'Client ID', mono: true, hideBelow: 'tablet' },
|
|
{ label: 'Scopes', hideBelow: 'mobile' },
|
|
{ label: 'Status' },
|
|
{ label: 'Role mappings' },
|
|
{ label: 'Action', align: 'right' },
|
|
]}
|
|
.rows=${appRows}
|
|
></idp-data-table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSupport(): TemplateResult {
|
|
const services = [
|
|
['Account Recovery', 'Lost access to your account or locked out of your organisation? Our team will verify your identity and restore access securely.', 'EUR149', 'per incident', 'key', '1-2 business days'],
|
|
['Organisation Recovery', 'All owners have lost access to your organisation. We can verify ownership and restore admin access.', 'EUR249', 'per incident', 'building', '2-3 business days'],
|
|
['Data Export & Migration', 'Full export of your org data - users, sessions, app connections - for migration or compliance.', 'EUR199', 'per request', 'box', '3-5 business days'],
|
|
['Identity & SSO Consulting', 'Architecture review, OIDC guidance, and custom SSO setup for your organisation stack.', 'EUR190', 'per hour', 'globe', 'Scheduled session'],
|
|
['Security Review', 'Audit of connected apps, active sessions, passkey policies, and role assignments.', 'EUR390', 'per review', 'shield', '5-7 business days'],
|
|
];
|
|
return html`
|
|
${this.renderPageHeader('Support', 'idp.global is free for everyone. Paid options cover hands-on recovery and consulting work.')}
|
|
<div class="body narrow-body"><div class="row-card" style="background:var(--idp-accent-soft);border-color:var(--idp-info-border)"><idp-icon name="shield" size="16" style="color:var(--idp-accent)"></idp-icon><div><div class="section-title" style="color:var(--idp-accent)">idp.global is free, forever</div><div class="muted">All platform features - authentication, passkeys, OIDC apps, team management - are included at no cost.</div></div></div><div class="list-stack">${services.map((serviceArg) => html`<div class="row-card"><span class="icon-tile"><idp-icon name=${serviceArg[4] as any} size="16"></idp-icon></span><div style="flex:1"><div style="display:flex;gap:8px;align-items:baseline"><span class="section-title">${serviceArg[0]}</span><span class="muted">- ${serviceArg[5]}</span></div><div class="muted">${serviceArg[1]}</div><div style="display:flex;align-items:center;justify-content:space-between;margin-top:10px"><div><span style="font-size:16px;font-weight:700;color:var(--idp-fg)">${serviceArg[2]}</span> <span class="muted">${serviceArg[3]}</span></div><button class="plain-button outline" disabled>Request flow pending</button></div></div></div>`)}</div></div>
|
|
`;
|
|
}
|
|
|
|
private renderGAUsers(): TemplateResult {
|
|
return html`${this.renderPageHeader('All Users', 'Platform-wide user administration.')}<div class="body wide-body">${this.renderDataState('User directory not connected', 'The shell is ready for global user data, but no platform user-list endpoint is wired into this app yet. No demo users are shown in production mode.', 'users')}</div>`;
|
|
}
|
|
|
|
private renderGAOrgs(): TemplateResult {
|
|
const orgRows = this.orgs.map((orgArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(orgArg.name)}</span>
|
|
<div>
|
|
<div class="identity-primary">${orgArg.name}</div>
|
|
<div class="identity-secondary">${orgArg.slug ? `idp.global/org/${orgArg.slug}` : 'No slug'}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
orgArg.slug || '-',
|
|
html`<idp-badge variant=${this.roleVariant(orgArg.myRole || 'member')}>${orgArg.myRole || 'member'}</idp-badge>`,
|
|
orgArg.id,
|
|
],
|
|
}));
|
|
|
|
return html`
|
|
${this.renderPageHeader('All Organisations', `${this.orgs.length} organisations visible to this admin session`)}
|
|
<div class="body wide-body">
|
|
<idp-data-table
|
|
title="Organisations"
|
|
badge=${`${this.orgs.length} total`}
|
|
empty-title="No organisations"
|
|
empty-description="No organisations are visible to this admin session."
|
|
.columns=${[
|
|
{ label: 'Organisation' },
|
|
{ label: 'Slug', mono: true, hideBelow: 'mobile' },
|
|
{ label: 'Role' },
|
|
{ label: 'Identifier', mono: true, hideBelow: 'tablet' },
|
|
]}
|
|
.rows=${orgRows}
|
|
></idp-data-table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderGAApps(): TemplateResult {
|
|
const apps = this.adminApps.length ? this.adminApps : this.orgApps;
|
|
const appRows = apps.map((appArg) => ({
|
|
cells: [
|
|
html`
|
|
<div class="identity-cell">
|
|
<span class="identity-avatar">${this.getInitials(appArg.name)}</span>
|
|
<div>
|
|
<div class="identity-primary">${appArg.name}</div>
|
|
<div class="identity-secondary">${appArg.description || appArg.appUrl || 'Platform app'}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
html`<idp-badge variant=${appArg.type === 'global' ? 'accent' : 'outline'}>${appArg.type || 'global'}</idp-badge>`,
|
|
appArg.category || '-',
|
|
appArg.connectionCount ?? '-',
|
|
html`<idp-badge variant=${appArg.status === 'suspended' ? 'error' : appArg.status === 'pending_review' ? 'warn' : 'ok'}>${appArg.status || 'active'}</idp-badge>`,
|
|
],
|
|
}));
|
|
|
|
return html`
|
|
${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}
|
|
<div class="body wide-body">
|
|
<idp-data-table
|
|
title="Platform apps"
|
|
badge=${`${apps.length} total`}
|
|
empty-title="No platform apps"
|
|
empty-description="Global app administration data is not available for this session."
|
|
.columns=${[
|
|
{ label: 'App' },
|
|
{ label: 'Type' },
|
|
{ label: 'Category', hideBelow: 'mobile' },
|
|
{ label: 'Connections', align: 'right', mono: true, hideBelow: 'mobile' },
|
|
{ label: 'Status' },
|
|
]}
|
|
.rows=${appRows}
|
|
></idp-data-table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderMainContent(): TemplateResult {
|
|
const renderers: Record<TIdpAdminPage, () => TemplateResult> = {
|
|
overview: () => this.renderOverview(),
|
|
profile: () => this.renderProfile(),
|
|
security: () => this.renderSecurity(),
|
|
sessions: () => this.renderSessions(),
|
|
apps: () => this.renderAccountApps(),
|
|
'org-general': () => this.renderOrgGeneral(),
|
|
'org-settings': () => this.renderOrgSettings(),
|
|
'org-members': () => this.renderOrgMembers(),
|
|
'org-apps': () => this.renderOrgApps(),
|
|
support: () => this.renderSupport(),
|
|
'ga-users': () => this.renderGAUsers(),
|
|
'ga-orgs': () => this.renderGAOrgs(),
|
|
'ga-apps': () => this.renderGAApps(),
|
|
};
|
|
return (renderers[this.page] || renderers.overview)();
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
return html`
|
|
<div class="shell">
|
|
${this.renderSidebar()}
|
|
<main>${this.renderMainContent()}</main>
|
|
</div>
|
|
${this.renderDialog()}
|
|
`;
|
|
}
|
|
}
|