Files

1688 lines
66 KiB
TypeScript
Raw Permalink Normal View History

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';
declare global {
interface HTMLElementTagNameMap {
'idp-admin-shell': IdpAdminShell;
}
}
type TNavItem = {
id: string;
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;
};
type TApproval = {
user: string;
email: string;
hue: string;
action: string;
device: string;
status: 'ok' | 'warn' | 'error' | 'accent';
label: string;
when: string;
};
type TFeedItem = {
dot: 'bl' | 'ok' | 'wn';
title: string;
detail: string;
meta: string;
};
type TAdminPage =
| 'overview'
| 'profile'
| 'security'
| 'sessions'
| 'apps'
| 'org-general'
| 'org-members'
| 'org-apps'
| 'support'
| 'ga-users'
| 'ga-orgs'
| 'ga-apps';
type TOrg = {
id: string;
name: string;
slug: string;
myRole: string;
};
type TConnectedApp = [name: string, scopes: string[], meta: string];
type TOAuthApp = [name: string, clientId: string, grants: string[], description: string];
@customElement('idp-admin-shell')
export class IdpAdminShell extends DeesElement {
public static demo = () => html`<idp-admin-shell></idp-admin-shell>`;
public static demoGroups = ['idp.global v3 full pages'];
public static styles = [
...idpElementStyles,
css`
:host {
display: block;
color: var(--idp-fg);
}
.shell {
min-height: 900px;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--idp-border);
background: var(--idp-bg);
}
.sidebar {
min-height: 900px;
display: flex;
flex-direction: column;
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;
padding: 10px 8px;
}
.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,
.table-avatar,
.region-code {
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 {
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: #fff;
}
.plain-button.ghost {
color: var(--idp-fg);
}
.plain-button.warn-outline {
border: 1px solid var(--idp-error-border);
color: var(--idp-error);
}
.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;
}
.danger-zone {
overflow: hidden;
border: 1px solid var(--idp-error-border);
border-radius: var(--idp-radius);
}
.danger-head {
padding: 10px 16px;
border-bottom: 1px solid var(--idp-error-border);
background: var(--idp-error-bg);
color: var(--idp-error);
font-size: 13px;
font-weight: 600;
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--idp-error-border);
background: var(--idp-card);
}
.danger-item:last-child {
border-bottom: 0;
}
.avatar,
.app-avatar,
.icon-tile {
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);
}
.chart-card .card-head {
gap: 12px;
padding: 14px 18px;
}
.card-title {
color: var(--idp-fg);
font-size: 13px;
font-weight: 600;
}
.card-subtitle {
margin-top: 2px;
color: var(--idp-muted-fg);
font-size: 11.5px;
}
.legend {
display: flex;
align-items: center;
gap: 14px;
margin-left: auto;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--idp-fg-2);
font-size: 11.5px;
}
.legend i {
width: 8px;
height: 2px;
border-radius: 1px;
}
.chart {
padding: 12px 14px 6px;
}
.chart svg {
width: 100%;
height: auto;
display: block;
}
.view-all {
margin-left: auto;
color: var(--idp-muted-fg);
font-size: 11.5px;
}
.attention {
display: grid;
grid-template-columns: 8px 1fr auto;
gap: 12px;
align-items: center;
padding: 13px 16px;
border-bottom: 1px solid var(--idp-border-soft);
}
.attention-dot,
.feed-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 4px;
background: var(--dot-color);
box-shadow: var(--dot-glow, none);
}
.attention-title {
color: var(--idp-fg);
font-size: 12.5px;
font-weight: 550;
}
.attention-meta {
margin-top: 2px;
color: var(--idp-muted-fg);
font-family: var(--idp-mono);
font-size: 10.5px;
}
.mini-action {
padding: 4px 8px;
border: 1px solid var(--idp-border);
border-radius: 6px;
background: transparent;
color: var(--idp-fg);
font-family: inherit;
font-size: 11px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--idp-border-soft);
text-align: left;
}
th {
padding: 9px 16px;
background: var(--idp-muted);
color: var(--idp-fg-3);
font-family: var(--idp-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
td {
padding: 10px 16px;
color: var(--idp-fg-2);
font-size: 12.5px;
}
tbody tr:hover td {
background: var(--idp-bg-2);
}
.row-user {
display: flex;
align-items: center;
gap: 8px;
}
.table-avatar {
width: 22px;
height: 22px;
border: 1px solid var(--idp-border);
border-radius: 50%;
background: var(--idp-muted);
color: var(--avatar-color);
font-size: 9.5px;
font-weight: 600;
}
.row-name {
color: var(--idp-fg);
font-size: 12.5px;
font-weight: 500;
}
.row-email,
.row-mono,
.feed-meta {
color: var(--idp-muted-fg);
font-family: var(--idp-mono);
font-size: 11.5px;
}
.side-tabs {
display: flex;
gap: 2px;
margin-left: auto;
}
.side-tab {
padding: 4px 10px;
border-radius: 4px;
color: var(--idp-muted-fg);
font-size: 11px;
}
.side-tab.active {
background: var(--idp-muted);
color: var(--idp-fg);
}
.feed-item {
display: grid;
grid-template-columns: 14px 1fr auto;
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;
}
.geo-list {
padding: 8px 0;
}
.region {
display: grid;
grid-template-columns: 24px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 16px;
}
.region-code {
border: 1px solid var(--region-border, var(--idp-border));
border-radius: 3px;
background: var(--region-bg, transparent);
color: var(--region-color, var(--idp-fg-3));
font-size: 10px;
}
.region-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: var(--idp-fg);
font-size: 12.5px;
}
.bar-track {
height: 4px;
overflow: hidden;
border-radius: 2px;
background: var(--idp-muted);
}
.bar-fill {
width: var(--share);
height: 100%;
background: var(--bar-color, var(--idp-accent));
opacity: 0.85;
}
.region-count {
min-width: 44px;
color: var(--idp-fg);
font-family: var(--idp-mono);
font-size: 11.5px;
text-align: right;
}
@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,
.legend {
flex-wrap: wrap;
}
.kpis {
grid-template-columns: 1fr;
}
th:nth-child(3),
td:nth-child(3),
th:nth-child(5),
td:nth-child(5) {
display: none;
}
}
`,
];
@property({ type: String })
public accessor page: TAdminPage = 'overview';
@state()
private accessor orgMenuOpen = false;
@state()
private accessor selectedOrg = 'org_foss';
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-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 orgs: TOrg[] = [
{ id: 'org_foss', name: 'Lossless GmbH', slug: 'lossless', myRole: 'owner' },
{ id: 'org_task', name: 'Task VC', slug: 'task', myRole: 'admin' },
{ id: 'org_demo', name: 'Demo Sandbox', slug: 'demo', myRole: 'viewer' },
{ id: 'org_oss', name: 'OpenSource Coop', slug: 'oss-coop', myRole: 'editor' },
{ id: 'org_ext', name: 'External Client', slug: 'ext-client', myRole: 'guest' },
];
private heroKpis: TKpi[] = [
{
label: 'Identities',
value: '2,847',
delta: '↑ 12% wk',
deltaKind: 'up',
spark: [10, 12, 11, 14, 13, 16, 15, 18, 19],
sparkColor: 'var(--idp-spark-up)',
accent: 'var(--idp-chart-1)',
sub: '142 added this week',
},
{
label: 'Active devices',
value: '9,140',
delta: '↑ 4.2%',
deltaKind: 'up',
spark: [12, 13, 11, 14, 13, 15, 14, 16, 17],
sparkColor: 'var(--idp-spark-up)',
accent: 'var(--idp-chart-2)',
sub: '3.2 avg / identity',
},
{
label: 'Avg approval',
value: '0.8',
unit: 's',
delta: '↓ 60ms faster',
deltaKind: 'up',
spark: [16, 14, 17, 12, 15, 13, 11, 9, 7],
sparkColor: 'var(--idp-spark-info)',
accent: 'var(--idp-chart-5)',
sub: 'p95 - all regions',
},
{
label: 'Cardano anchors',
value: '12,408',
delta: 'synced 4s ago',
deltaKind: 'live',
spark: [8, 9, 11, 10, 13, 12, 15, 16, 18],
sparkColor: 'var(--idp-spark-up)',
accent: 'var(--idp-info)',
sub: 'block #9 841 222',
},
];
private approvals: TApproval[] = [
{ user: 'Jane Doe', email: 'jane@lossless.com', hue: 'var(--idp-chart-1)', action: 'OAuth - GitHub', device: 'iPhone 15 Pro', status: 'ok', label: 'approved', when: '2 min ago' },
{ user: 'Alex Brown', email: 'alex@lossless.com', hue: 'var(--idp-chart-4)', action: 'CLI login', device: 'MacBook Pro', status: 'warn', label: 'pending', when: 'just now' },
{ user: 'Sam Chen', email: 'sam@lossless.com', hue: 'var(--idp-chart-5)', action: 'NFC tap - door 4F', device: 'iPhone 14', status: 'ok', label: 'approved', when: '12 min ago' },
{ user: 'Unknown device', email: 'Lagos - NG', hue: 'var(--idp-chart-3)', action: 'Web login', device: 'Chrome 132', status: 'error', label: 'denied', when: '1 hr ago' },
{ user: 'Maria K.', email: 'maria@lossless.com', hue: 'var(--idp-chart-5)', action: 'Key rotation', device: 'Apple Watch', status: 'accent', label: 'on-chain', when: '3 hr ago' },
];
private attention = [
{ sev: 'error', title: 'Repeated denied logins', meta: '5 attempts - Lagos, NG - last 1h', action: 'Block IP', color: 'var(--idp-error)' },
{ sev: 'warning', title: 'Stale device key', meta: 'sam@ - Apple Watch - 9 mo old', action: 'Rotate', color: 'var(--idp-warn)' },
{ sev: 'warning', title: 'OAuth scope expanding', meta: 'github - repo:write requested', action: 'Review', color: 'var(--idp-warn)' },
{ sev: 'info', title: 'Pending key rotations', meta: '3 identities - Cardano awaiting', action: 'Run', color: 'var(--idp-info)' },
];
private feed: TFeedItem[] = [
{ dot: 'bl', title: 'Identity created', detail: 'did:idp:0x9b12...f034', meta: 'block #9 841 222' },
{ dot: 'ok', title: 'Anchor confirmed', detail: '12 blocks deep', meta: '2m' },
{ dot: 'bl', title: 'Key rotation', detail: 'did:idp:0x4a3f...c819', meta: 'block #9 841 221' },
{ dot: 'ok', title: 'OAuth scope updated', detail: 'github - repo:read', meta: '5m' },
{ dot: 'wn', title: 'Device registered', detail: 'MacBook Pro - pending', meta: '7m' },
{ dot: 'bl', title: 'Anchor submitted', detail: 'awaiting confirmation', meta: '8m' },
];
private get currentOrg(): TOrg {
return this.orgs.find((orgArg) => orgArg.id === this.selectedOrg) || this.orgs[0];
}
private setPage(pageArg: TAdminPage) {
this.page = pageArg;
this.orgMenuOpen = false;
}
private selectOrg(orgIdArg: string) {
this.selectedOrg = orgIdArg;
this.orgMenuOpen = false;
this.page = 'org-general';
}
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 as TAdminPage)}>
<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.orgs.map((orgArg) => html`
<button class="org-menu-item ${orgArg.id === this.selectedOrg ? '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}</span></span>
${orgArg.id === this.selectedOrg ? html`<idp-icon name="check" size="11"></idp-icon>` : html``}
</button>
`)}
<div class="org-menu-divider"></div>
<button class="org-create" @click=${() => this.setPage('org-general')}><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>
<div class="nav-section"><div class="nav-title">Global Admin</div>${this.renderNavGroup(this.adminNav, this.page)}</div>
</div>
<div class="user-footer">
<span class="user-avatar">AM</span>
<div class="user-meta"><div class="user-name">Alex Mercer</div><div class="user-email">alex@lossless.com</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 renderApprovalsChart(): TemplateResult {
return html`
<svg viewBox="0 0 720 220">
<defs>
<linearGradient id="ovw-fill-1" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--idp-chart-1)" stop-opacity="0.18"></stop>
<stop offset="100%" stop-color="var(--idp-chart-1)" stop-opacity="0"></stop>
</linearGradient>
<linearGradient id="ovw-fill-2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--idp-chart-2)" stop-opacity="0.16"></stop>
<stop offset="100%" stop-color="var(--idp-chart-2)" stop-opacity="0"></stop>
</linearGradient>
</defs>
${[0, 20, 40, 60, 80].map((tickArg, indexArg) => {
const y = 194 - (tickArg / 80) * 182;
return html`<g><line x1="36" x2="708" y1=${y} y2=${y} stroke="var(--idp-border)" stroke-width="1" stroke-dasharray=${indexArg === 0 ? '' : '2 3'} opacity=${indexArg === 0 ? '1' : '0.7'}></line><text x="28" y=${y + 3} text-anchor="end" font-size="9.5" fill="var(--idp-fg-3)" font-family="Intel One Mono, monospace">${tickArg}</text></g>`;
})}
${['00', '04', '08', '12', '16', '20', '23'].map((labelArg, indexArg) => {
const x = 36 + (indexArg / 6) * 672;
return html`<text x=${x} y="212" text-anchor="middle" font-size="9.5" fill="var(--idp-fg-3)" font-family="Intel One Mono, monospace">${labelArg}</text>`;
})}
<path d="M36,194 L36,143.95 L65.2,153.05 L94.4,162.15 L123.65,171.25 L152.86,175.8 L182.08,178.08 L211.3,173.53 L240.52,162.15 L269.73,143.95 L298.95,112.1 L328.17,80.25 L357.39,48.4 L386.6,32.48 L415.82,25.65 L445.04,34.75 L474.26,46.13 L503.47,62.05 L532.69,75.7 L561.91,91.63 L591.13,107.55 L620.34,121.2 L649.56,130.3 L678.78,137.13 L708,141.68 L708,194 Z" fill="url(#ovw-fill-1)"></path>
<path d="M36,194 L36,157.6 L65.2,166.7 L94.4,173.53 L123.65,178.08 L152.86,180.35 L182.08,182.63 L211.3,178.08 L240.52,168.98 L269.73,153.05 L298.95,125.75 L328.17,98.45 L357.39,80.25 L386.6,66.6 L415.82,62.05 L445.04,66.6 L474.26,75.7 L503.47,87.08 L532.69,103 L561.91,112.1 L591.13,125.75 L620.34,134.85 L649.56,141.68 L678.78,148.5 L708,153.05 L708,194 Z" fill="url(#ovw-fill-2)"></path>
<path d="M36,157.6 L65.2,166.7 L94.4,173.53 L123.65,178.08 L152.86,180.35 L182.08,182.63 L211.3,178.08 L240.52,168.98 L269.73,153.05 L298.95,125.75 L328.17,98.45 L357.39,80.25 L386.6,66.6 L415.82,62.05 L445.04,66.6 L474.26,75.7 L503.47,87.08 L532.69,103 L561.91,112.1 L591.13,125.75 L620.34,134.85 L649.56,141.68 L678.78,148.5 L708,153.05" fill="none" stroke="var(--idp-chart-2)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.85"></path>
<path d="M36,143.95 L65.2,153.05 L94.4,162.15 L123.65,171.25 L152.86,175.8 L182.08,178.08 L211.3,173.53 L240.52,162.15 L269.73,143.95 L298.95,112.1 L328.17,80.25 L357.39,48.4 L386.6,32.48 L415.82,25.65 L445.04,34.75 L474.26,46.13 L503.47,62.05 L532.69,75.7 L561.91,91.63 L591.13,107.55 L620.34,121.2 L649.56,130.3 L678.78,137.13 L708,141.68" fill="none" stroke="var(--idp-chart-1)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></path>
<line x1="405.6" x2="405.6" y1="12" y2="194" stroke="var(--idp-accent)" stroke-width="1" stroke-dasharray="3 3" opacity="0.5"></line>
<circle cx="405.6" cy="25.65" r="3.5" fill="var(--idp-accent)"></circle>
<circle cx="405.6" cy="25.65" r="6" fill="var(--idp-accent)" opacity="0.2"></circle>
</svg>
`;
}
private renderChartCard(): TemplateResult {
return html`
<div class="card chart-card">
<div class="card-head">
<div><div class="card-title">Approval activity</div><div class="card-subtitle">Hourly - last 24 hours</div></div>
<div class="legend"><span><i style="background:var(--idp-chart-1)"></i>Approvals</span><span><i style="background:var(--idp-chart-2)"></i>OAuth grants</span></div>
</div>
<div class="chart">${this.renderApprovalsChart()}</div>
</div>
`;
}
private renderThreatsCard(): TemplateResult {
return html`
<div class="card">
<div class="card-head"><span class="card-title">Needs attention</span><idp-badge variant="error">4 open</idp-badge><span class="view-all">View all →</span></div>
${this.attention.map((itemArg) => html`
<div class="attention">
<span class="attention-dot" style="--dot-color:${itemArg.color}"></span>
<div><div class="attention-title">${itemArg.title}</div><div class="attention-meta">${itemArg.meta}</div></div>
<button class="mini-action">${itemArg.action}</button>
</div>
`)}
</div>
`;
}
private renderApprovalsTable(): TemplateResult {
return html`
<div class="card">
<div class="card-head"><span class="card-title">Recent approvals</span><idp-badge>142 total</idp-badge><div class="side-tabs"><span class="side-tab active">All</span><span class="side-tab">Pending</span><span class="side-tab">Denied</span></div></div>
<table>
<thead><tr><th>User</th><th>Action</th><th>Device</th><th>Status</th><th style="text-align:right">When</th></tr></thead>
<tbody>
${this.approvals.map((approvalArg) => html`
<tr>
<td><div class="row-user"><span class="table-avatar" style="--avatar-color:${approvalArg.hue}">${approvalArg.user.split(' ').map((partArg) => partArg[0]).slice(0, 2).join('').toUpperCase()}</span><div><div class="row-name">${approvalArg.user}</div><div class="row-email">${approvalArg.email}</div></div></div></td>
<td>${approvalArg.action}</td>
<td><span class="row-mono">${approvalArg.device}</span></td>
<td><idp-badge variant=${approvalArg.status}>${approvalArg.label}</idp-badge></td>
<td style="text-align:right"><span class="row-mono">${approvalArg.when}</span></td>
</tr>
`)}
</tbody>
</table>
</div>
`;
}
private feedDotStyle(kind: TFeedItem['dot']): string {
if (kind === 'ok') return '--dot-color:var(--idp-ok);--dot-glow:0 0 6px var(--idp-ok-border)';
if (kind === 'wn') return '--dot-color:var(--idp-warn)';
return '--dot-color:var(--idp-info);--dot-glow:0 0 6px var(--idp-info-border)';
}
private renderFeedCard(): TemplateResult {
return html`
<div class="card">
<div class="card-head"><span class="card-title">Cardano feed</span><idp-badge variant="accent">live - #9 841 222</idp-badge></div>
${this.feed.map((itemArg) => html`
<div class="feed-item"><span class="feed-dot" style=${this.feedDotStyle(itemArg.dot)}></span><div class="feed-text"><strong>${itemArg.title}</strong> - <span class="mono">${itemArg.detail}</span></div><span class="feed-meta">${itemArg.meta}</span></div>
`)}
</div>
`;
}
private renderGeoCard(): TemplateResult {
const regions = [
{ name: 'Berlin, DE', code: 'DE', count: '1,284', share: '100%', kind: 'home' },
{ name: 'Amsterdam, NL', code: 'NL', count: '642', share: '50%' },
{ name: 'San Francisco, US', code: 'US', count: '521', share: '41%' },
{ name: 'London, GB', code: 'GB', count: '384', share: '30%' },
{ name: 'Tokyo, JP', code: 'JP', count: '218', share: '17%' },
{ name: 'Lagos, NG', code: 'NG', count: '12', share: '1%', kind: 'risk' },
];
return html`
<div class="card">
<div class="card-head"><span class="card-title">Sign-ins by region</span><span class="view-all">last 24h</span></div>
<div class="geo-list">
${regions.map((regionArg) => {
const style = regionArg.kind === 'risk'
? '--region-color:var(--idp-error);--region-border:var(--idp-error-border);--region-bg:var(--idp-error-bg);--bar-color:var(--idp-error)'
: regionArg.kind === 'home'
? '--region-color:var(--idp-accent);--region-border:var(--idp-info-border);--region-bg:var(--idp-accent-soft);--bar-color:var(--idp-accent)'
: '';
return html`
<div class="region" style=${style}>
<span class="region-code">${regionArg.code}</span>
<div><div class="region-title">${regionArg.name}${regionArg.kind === 'risk' ? html`<idp-badge variant="error">flagged</idp-badge>` : regionArg.kind === 'home' ? html`<idp-badge variant="accent">primary</idp-badge>` : html``}</div><div class="bar-track"><div class="bar-fill" style="--share:${regionArg.share}"></div></div></div>
<span class="region-count">${regionArg.count}</span>
</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 renderDangerZone(itemsArg: Array<{ title: string; description: string; action: string }>): TemplateResult {
return html`
<section class="danger-zone">
<div class="danger-head">Danger Zone</div>
${itemsArg.map((itemArg) => html`
<div class="danger-item"><div><div class="section-title">${itemArg.title}</div><div class="section-description">${itemArg.description}</div></div><button class="plain-button warn-outline">${itemArg.action}</button></div>
`)}
</section>
`;
}
private renderOverview(): TemplateResult {
return html`
<header class="page-head">
<div>
<div class="eyebrow-row"><span class="eyebrow">Workspace - Overview</span><span class="live-pill"><span class="live-dot"></span>live</span></div>
<h1>Good morning, Aegir.</h1>
<div class="lead">Identity activity across <code>@lossless</code> - 142 identities, 9.1k devices online</div>
</div>
<div class="page-actions"><div class="tabs"><span class="tab">24h</span><span class="tab active">7d</span><span class="tab">30d</span><span class="tab">90d</span></div><button class="plain-button outline">Export</button><button class="plain-button primary">+ New identity</button></div>
</header>
<div class="body">
<div class="kpis">${this.heroKpis.map((kpiArg) => this.renderKpi(kpiArg))}</div>
<div class="primary-grid">${this.renderChartCard()}${this.renderThreatsCard()}</div>
<div class="secondary-grid">${this.renderApprovalsTable()}${this.renderFeedCard()}</div>
${this.renderGeoCard()}
</div>
`;
}
private renderProfile(): TemplateResult {
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">AM</span><div><button class="plain-button outline">Upload photo</button><div class="muted" style="margin-top:5px">JPG, PNG, GIF up to 2 MB</div></div></div>`)}
${this.renderSectionCard('Personal information', '', html`
${this.renderFormRow('Full name', '', html`<input class="input" value="Alex Mercer" />`, 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="alexmercer" /></div>`)}
${this.renderFormRow('Email', 'Primary address for login and notifications', html`<input class="input" value="alex@lossless.com" />`)}
${this.renderFormRow('Mobile number', 'Used for SMS verification', html`<input class="input" value="+49 170 555 0192" />`)}
<div style="padding-top:14px;display:flex;justify-content:flex-end"><button class="plain-button primary">Save changes</button></div>
`)}
${this.renderSectionCard('Account status', '', html`<div class="split-row"><div><div class="section-title">Status</div><div class="muted">Your account is currently active.</div></div><idp-badge variant="ok">Active</idp-badge></div><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>`)}
${this.renderDangerZone([{ title: 'Delete account', description: 'Permanently delete your account and all associated data. This cannot be undone.', action: 'Delete account' }])}
</div>
`;
}
private renderSecurity(): TemplateResult {
const passkeys = ['MacBook Pro - Touch ID', 'iPhone 15 Pro - Face ID'];
return html`
${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')}
<div class="body narrow-body">
${this.renderSectionCard('Passkeys', 'Biometric or hardware-key authentication - phishing-resistant and passwordless.', html`${passkeys.map((passkeyArg, indexArg) => html`<div class="row-card" style="border:0;border-radius:0;padding:10px 0;${indexArg ? 'border-top:1px solid var(--idp-border)' : ''}"><span class="icon-tile" style="width:34px;height:34px"><idp-icon name="key" size="15"></idp-icon></span><div style="flex:1"><div class="section-title">${passkeyArg}</div><div class="muted">Added ${indexArg ? '3 days ago' : '10 days ago'} - Last used ${indexArg ? '1h ago' : '5m ago'}</div></div><button class="plain-button ghost" style="color:var(--idp-error)">Remove</button></div>`)}`, html`<button class="plain-button outline"><idp-icon name="plus" size="12"></idp-icon>Add passkey</button>`)}
${this.renderSectionCard('Password', 'Update your login password. Use a strong, unique password.', html`
${this.renderFormRow('Current password', '', html`<input class="input" type="password" value="password" />`)}
${this.renderFormRow('New password', 'Minimum 8 characters', html`<input class="input" type="password" value="password" />`)}
${this.renderFormRow('Confirm password', '', html`<input class="input" type="password" value="password" />`)}
<div style="padding-top:14px;display:flex;justify-content:flex-end"><button class="plain-button primary">Update password</button></div>
`)}
${this.renderSectionCard('Two-factor authentication', '', html`<div class="split-row"><div><div class="section-title">Authenticator app (TOTP)</div><div class="muted">Generate one-time codes with an authenticator app.</div></div><div style="display:flex;align-items:center;gap:10px"><idp-badge variant="ok">Enabled</idp-badge><span class="switch"></span></div></div>`)}
</div>
`;
}
private renderSessions(): TemplateResult {
const sessions = [
['MacBook Pro 16"', 'Chrome 124 - macOS 14.4', '185.220.101.42', 'Current session'],
['iPhone 15 Pro', 'Safari 17 - iOS 17.4', '185.220.101.42', ''],
['iPad Air', 'Safari 17 - iPadOS 17.3', '91.64.18.227', ''],
['Windows PC', 'Firefox 125 - Windows 11', '194.31.186.5', ''],
];
return html`
${this.renderPageHeader('Sessions & Devices', 'Active login sessions across all your devices.', html`<button class="plain-button warn-outline">Revoke all others</button>`)}
<div class="body narrow-body"><div class="list-stack">${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[0]}</span>${sessionArg[3] ? html`<idp-badge variant="ok">${sessionArg[3]}</idp-badge>` : html``}</div><div class="muted">${sessionArg[1]} - IP: <span class="mono">${sessionArg[2]}</span></div><div class="muted">Started 5m ago - Active just now</div></div>${sessionArg[3] ? html`` : html`<button class="plain-button ghost" style="color:var(--idp-error)">Revoke</button>`}</div>`)}</div></div>
`;
}
private renderAccountApps(): TemplateResult {
const apps: TConnectedApp[] = [
['foss.global', ['Identity', 'Profile', 'Email'], 'Authorized 3 Apr 2026 - Last used 2h ago'],
['task.vc', ['Identity', 'Profile', 'Email', 'Orgs (read)'], 'Authorized 19 Apr 2026 - Last used 1d ago'],
['Acme HR Portal', ['Identity', 'Email'], 'Authorized 4 Mar 2026 - Last used 7d ago'],
];
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.map((appArg) => html`<div class="row-card"><span class="app-avatar">${appArg[0].slice(0, 2).toUpperCase()}</span><div style="flex:1"><div class="section-title" style="margin-bottom:4px">${appArg[0]}</div><div class="chip-row">${(appArg[1] as string[]).map((scopeArg) => html`<idp-badge variant="outline">${scopeArg}</idp-badge>`)}</div><div class="muted" style="margin-top:6px">${appArg[2]}</div></div><button class="plain-button ghost" style="color:var(--idp-error)">Revoke</button></div>`)}</div></div>
`;
}
private renderOrgGeneral(): TemplateResult {
const org = this.currentOrg;
return html`
${this.renderPageHeader('Organisation Settings', 'General configuration for your organisation.')}
<div class="body narrow-body">
${this.renderSectionCard('', '', 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.slug}</div></div></div>${this.renderFormRow('Organisation name', '', html`<input class="input" value=${org.name} />`, true)}${this.renderFormRow('URL slug', "Used in your org's public URLs. Changing this may break existing links.", html`<div class="input-group"><span class="input-prefix">idp.global/org/</span><input class="input" value=${org.slug} /></div>`)}<div style="padding-top:14px;display:flex;justify-content:flex-end"><button class="plain-button primary">Save changes</button></div>`)}
${this.renderSectionCard('Organisation ID', 'Use this identifier when making API calls.', this.renderCodeBlock(org.id))}
${this.renderDangerZone([{ title: 'Transfer ownership', description: 'Transfer this organisation to another user. You will lose admin access.', action: 'Transfer' }, { title: 'Delete organisation', description: 'Permanently deletes this organisation, its members, apps and billing. Cannot be undone.', action: 'Delete org' }])}
</div>
`;
}
private renderOrgMembers(): TemplateResult {
const members: Array<[name: string, email: string, role: string, joined: string]> = [
['Alex Mercer', 'alex@lossless.com', 'owner', 'Joined 120d ago'],
['Jordan Kim', 'jordan@lossless.com', 'admin', 'Joined 90d ago'],
['Sam Rivera', 'sam@lossless.com', 'editor', 'Joined 45d ago'],
['Casey Novak', 'casey@lossless.com', 'viewer', 'Joined 10d ago'],
['Riley Chen', 'riley@external.io', 'guest', 'Joined 2d ago'],
];
const invites: Array<[email: string, role: string, meta: string]> = [
['devops@partner.io', 'editor', 'Invited 3d ago - Expires 30 Jul 2026'],
['audit@consulting.de', 'viewer', 'Invited 1d ago - Expires 1 Aug 2026'],
];
return html`
${this.renderPageHeader('Members', '5 members - 2 pending invitations', html`<button class="plain-button primary"><idp-icon name="plus" size="12"></idp-icon>Invite member</button>`)}
<div class="body wide-body">
<div class="sub-tabs"><span class="sub-tab active">Members (5)</span><span class="sub-tab">Pending (2)</span></div>
${this.renderSectionCard('', '', html`${members.map((memberArg, indexArg) => html`
<div class="split-row" style="padding:11px 14px;${indexArg ? 'border-top:1px solid var(--idp-border)' : ''}">
<div style="display:flex;align-items:center;gap:12px">
<span class="avatar" style="width:32px;height:32px;font-size:11px">${memberArg[0].split(' ').map((partArg) => partArg[0]).join('')}</span>
<div><div class="section-title">${memberArg[0]}</div><div class="muted">${memberArg[1]}</div></div>
</div>
<span class="muted">${memberArg[3]}</span>
<idp-badge variant=${memberArg[2] === 'owner' ? 'accent' : memberArg[2] === 'admin' ? 'warn' : 'outline'}>${memberArg[2]}</idp-badge>
</div>
`)}`)}
${this.renderSectionCard('Pending invitations', '', html`${invites.map((inviteArg, indexArg) => html`
<div class="split-row" style="padding:11px 0;${indexArg ? 'border-top:1px solid var(--idp-border)' : ''}">
<div style="display:flex;align-items:center;gap:12px">
<span class="icon-tile" style="width:32px;height:32px"><idp-icon name="mail" size="13"></idp-icon></span>
<div><div class="section-title">${inviteArg[0]}</div><div class="muted">${inviteArg[2]}</div></div>
</div>
<idp-badge variant="outline">${inviteArg[1]}</idp-badge>
<button class="plain-button ghost" style="color:var(--idp-error)">Cancel</button>
</div>
`)}`)}
</div>
`;
}
private renderOrgApps(): TemplateResult {
const apps: TOAuthApp[] = [
['Internal Dev Portal', 'ci_lossless_devportal_7Xa9', ['auth code'], 'OAuth client for our internal developer tools.'],
['CI Pipeline Auth', 'ci_lossless_ci_4Kp2', ['client credentials'], 'Machine-to-machine auth for deployment pipelines.'],
];
return html`
${this.renderPageHeader('OAuth Apps', "Custom OIDC clients for your organisation's own apps and services.", html`<button class="plain-button primary"><idp-icon name="plus" size="12"></idp-icon>New app</button>`)}
<div class="body narrow-body"><div class="list-stack">${apps.map((appArg) => html`<div class="row-card"><span class="app-avatar">${appArg[0].slice(0, 2).toUpperCase()}</span><div style="flex:1"><div class="section-title">${appArg[0]}</div><div class="muted">${appArg[3]}</div><div class="mono" style="margin-top:4px">${appArg[1]}</div></div><div class="chip-row">${(appArg[2] as string[]).map((grantArg) => html`<idp-badge variant="outline">${grantArg}</idp-badge>`)}</div><idp-icon name="chevron-right" size="14"></idp-icon></div>`)}</div>${this.renderSectionCard('OAuth credentials', 'Use these to configure your application.', html`<div style="display:grid;gap:10px"><div><div class="form-label" style="margin-bottom:5px">Client ID</div>${this.renderCodeBlock('ci_lossless_devportal_7Xa9')}</div><div><div class="form-label" style="margin-bottom:5px">Redirect URI</div>${this.renderCodeBlock('https://dev.lossless.com/auth/callback')}</div></div>`)}</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">Request this service</button></div></div></div>`)}</div></div>
`;
}
private renderGAUsers(): TemplateResult {
const users = [
['Alex Mercer', 'alex@lossless.com', '2', 'active', 'Admin', '200d ago'],
['Jordan Kim', 'jordan@lossless.com', '1', 'active', '', '100d ago'],
['Sam Rivera', 'sam@lossless.com', '1', 'active', '', '45d ago'],
['Dana Walsh', 'dana@suspended.de', '0', 'suspended', '', '60d ago'],
['Morgan Lee', 'morgan@newuser.com', '0', 'new', '', '1d ago'],
];
return html`${this.renderPageHeader('All Users', '6 users on the platform')}<div class="body wide-body"><input class="input" value="" placeholder="Search users..." /><div class="card"><table><thead><tr><th>User</th><th>Email</th><th>Orgs</th><th>Status</th><th>Admin</th><th>Joined</th><th></th></tr></thead><tbody>${users.map((userArg) => html`<tr><td><div class="row-user"><span class="avatar" style="width:28px;height:28px;font-size:10px">${userArg[0].split(' ').map((partArg) => partArg[0]).join('')}</span><span class="row-name">${userArg[0]}</span></div></td><td>${userArg[1]}</td><td>${userArg[2]}</td><td><idp-badge variant=${userArg[3] === 'suspended' ? 'error' : userArg[3] === 'new' ? 'warn' : 'ok'}>${userArg[3]}</idp-badge></td><td>${userArg[4] ? html`<idp-badge variant="accent">${userArg[4]}</idp-badge>` : html`<span class="muted">-</span>`}</td><td>${userArg[5]}</td><td><button class="plain-button ghost">${userArg[3] === 'suspended' ? 'Unsuspend' : 'Suspend'}</button></td></tr>`)}</tbody></table></div></div>`;
}
private renderGAOrgs(): TemplateResult {
const orgs = [['Lossless GmbH', 'lossless', '5', 'Pro', 'active', '200d ago'], ['Task VC', 'task', '3', 'Pro', 'active', '140d ago'], ['Demo Org', 'demo', '1', 'FairUsageFree', 'active', '5d ago'], ['Suspended Co.', 'suspended', '2', 'Pro', 'suspended', '80d ago']];
return html`${this.renderPageHeader('All Organisations', '4 organisations')}<div class="body wide-body"><div class="card"><table><thead><tr><th>Organisation</th><th>Slug</th><th>Members</th><th>Plan</th><th>Status</th><th>Created</th><th></th></tr></thead><tbody>${orgs.map((orgArg) => html`<tr><td><div class="row-user"><span class="org-avatar" style="width:28px;height:28px;font-size:10px">${orgArg[0].slice(0,2).toUpperCase()}</span><span class="row-name">${orgArg[0]}</span></div></td><td><span class="mono">${orgArg[1]}</span></td><td>${orgArg[2]}</td><td><idp-badge variant=${orgArg[3] === 'Pro' ? 'accent' : 'outline'}>${orgArg[3]}</idp-badge></td><td><idp-badge variant=${orgArg[4] === 'suspended' ? 'error' : 'ok'}>${orgArg[4]}</idp-badge></td><td>${orgArg[5]}</td><td><button class="plain-button ghost">${orgArg[4] === 'suspended' ? 'Unsuspend' : 'Suspend'}</button></td></tr>`)}</tbody></table></div></div>`;
}
private renderGAApps(): TemplateResult {
const apps = [['foss.global', 'global', 'Productivity', '-', 'active'], ['task.vc', 'global', 'Productivity', '-', 'active'], ['Acme HR', 'partner', 'HR', '412', 'approved'], ['DevOps Suite', 'partner', 'DevOps', '87', 'pending_review']];
return html`${this.renderPageHeader('Platform Apps', 'Global and partner apps across the platform.')}<div class="body wide-body"><div class="card"><table><thead><tr><th>App</th><th>Type</th><th>Category</th><th>Installs</th><th>Status</th><th></th></tr></thead><tbody>${apps.map((appArg) => html`<tr><td><div class="row-user"><span class="app-avatar" style="width:28px;height:28px;border-radius:7px;font-size:11px">${appArg[0].slice(0,2).toUpperCase()}</span><span class="row-name">${appArg[0]}</span></div></td><td><idp-badge variant=${appArg[1] === 'global' ? 'accent' : 'outline'}>${appArg[1]}</idp-badge></td><td>${appArg[2]}</td><td>${appArg[3]}</td><td><idp-badge variant=${appArg[4] === 'pending_review' ? 'warn' : 'ok'}>${appArg[4].replace('_', ' ')}</idp-badge></td><td><button class="plain-button ghost">${appArg[4] === 'pending_review' ? 'Approve' : 'Suspend'}</button></td></tr>`)}</tbody></table></div></div>`;
}
private renderMainContent(): TemplateResult {
const renderers: Record<TAdminPage, () => TemplateResult> = {
overview: () => this.renderOverview(),
profile: () => this.renderProfile(),
security: () => this.renderSecurity(),
sessions: () => this.renderSessions(),
apps: () => this.renderAccountApps(),
'org-general': () => this.renderOrgGeneral(),
'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" theme="dark">
${this.renderSidebar()}
<main>${this.renderMainContent()}</main>
</div>
`;
}
}