1688 lines
66 KiB
TypeScript
1688 lines
66 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';
|
||
|
|
|
||
|
|
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>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|