feat(admin): Add global admin functionality: backend admin APIs, model fields and UI integration

This commit is contained in:
2025-12-01 09:44:37 +00:00
parent fd089b2cee
commit af0c24f7ca
15 changed files with 1163 additions and 5 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.6.0',
version: '1.7.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+10
View File
@@ -149,6 +149,16 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/admin', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the admin page');
await cleanupViews();
viewcontainer.append(new views.AdminView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter._handleRouteState();
this.registerGarbageFunction(async () => {
+33
View File
@@ -6,6 +6,7 @@ import {
cssManager,
unsafeCSS,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@@ -24,6 +25,9 @@ declare global {
@customElement('lele-accountnavigation')
export class LeleAccountNavigation extends DeesElement {
@state()
accessor isGlobalAdmin: boolean = false;
constructor() {
super();
}
@@ -252,12 +256,34 @@ export class LeleAccountNavigation extends DeesElement {
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
Billing
</div>
${this.renderAdminLink()}
</div>
<div class="commitinfo">v${commitinfo.version}</div>
`;
}
private renderAdminLink(): TemplateResult | null {
if (!this.isGlobalAdmin) {
return null;
}
return html`
<div class="divider"></div>
<div class="navigationGroupLabel">Platform</div>
<div
class="navigationOption"
@click=${async () => {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('/admin');
}}
>
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Global Admin
</div>
`;
}
public firstUpdated() {
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
@@ -286,5 +312,12 @@ export class LeleAccountNavigation extends DeesElement {
.subscribe((selectedOrgArg) => {
deesInputDropdown.selectedOption = selectedOrgArg;
});
// Check if user is global admin
states.accountState
.select((stateArg) => stateArg.user)
.subscribe((user) => {
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
});
}
}
+759
View File
@@ -0,0 +1,759 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { IdpState } from '../../../states/idp.state.js';
import { accountDesignTokens } from '../sharedstyles.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-admin': AdminView;
}
}
interface IAppWithStats {
app: plugins.idpInterfaces.data.IGlobalApp;
connectionCount: number;
}
@customElement('lele-accountview-admin')
export class AdminView extends DeesElement {
@state()
accessor apps: IAppWithStats[] = [];
@state()
accessor loading: boolean = true;
@state()
accessor showCreateDialog: boolean = false;
@state()
accessor editingApp: plugins.idpInterfaces.data.IGlobalApp | null = null;
@state()
accessor newClientSecret: string | null = null;
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #71717a;
}
.apps-section {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.section-title {
font-size: 18px;
font-weight: 600;
}
.app-list {
padding: 0;
}
.app-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #27272a;
}
.app-item:last-child {
border-bottom: none;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.app-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-logo dees-icon {
font-size: 24px;
opacity: 0.7;
}
.app-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.app-details {
font-size: 13px;
color: #71717a;
display: flex;
gap: 16px;
}
.app-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.app-status.active {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.app-status.inactive {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.app-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: #27272a;
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
.empty-state {
text-align: center;
padding: 48px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
/* Dialog styles */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
}
.dialog-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.dialog-body {
padding: 24px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: #a1a1aa;
}
.form-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #27272a;
background: #0a0a0a;
color: #fafafa;
font-size: 14px;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
.secret-display {
background: #0a0a0a;
border: 1px solid #27272a;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.secret-label {
font-size: 12px;
color: #71717a;
margin-bottom: 8px;
}
.secret-value {
font-family: 'Geist Mono', monospace;
font-size: 13px;
word-break: break-all;
color: #22c55e;
}
.secret-warning {
font-size: 12px;
color: #f59e0b;
margin-top: 12px;
display: flex;
align-items: center;
gap: 6px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="container">
<div class="header">
<div>
<h1>Global Admin</h1>
<p class="subtitle">Manage platform-wide settings and global apps</p>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${this.apps.length}</div>
<div class="stat-label">Total Global Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.filter(a => a.app.data.isActive).length}</div>
<div class="stat-label">Active Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.reduce((sum, a) => sum + a.connectionCount, 0)}</div>
<div class="stat-label">Total Connections</div>
</div>
</div>
<div class="apps-section">
<div class="section-header">
<span class="section-title">Global Apps</span>
<dees-button
@clicked=${() => this.showCreateDialog = true}
>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create App
</dees-button>
</div>
${this.loading ? this.renderLoading() : this.renderAppList()}
</div>
</div>
${this.showCreateDialog ? this.renderCreateDialog() : null}
${this.editingApp ? this.renderEditDialog() : null}
${this.newClientSecret ? this.renderSecretDialog() : null}
`;
}
private renderLoading(): TemplateResult {
return html`
<div class="loading">
<span>Loading apps...</span>
</div>
`;
}
private renderAppList(): TemplateResult {
if (this.apps.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:box'}></dees-icon>
<h3>No Global Apps</h3>
<p>Create your first global app to get started.</p>
</div>
`;
}
return html`
<div class="app-list">
${this.apps.map(({ app, connectionCount }) => html`
<div class="app-item">
<div class="app-logo">
${app.data.logoUrl
? html`<img src="${app.data.logoUrl}" alt="${app.data.name}" />`
: html`<dees-icon .icon=${'lucide:box'}></dees-icon>`
}
</div>
<div class="app-info">
<div class="app-name">${app.data.name}</div>
<div class="app-details">
<span>${app.data.category}</span>
<span>${connectionCount} connections</span>
<span>${app.data.appUrl}</span>
</div>
</div>
<span class="app-status ${app.data.isActive ? 'active' : 'inactive'}">
${app.data.isActive ? 'Active' : 'Inactive'}
</span>
<div class="app-actions">
<button class="action-btn" @click=${() => this.editingApp = app}>
Edit
</button>
<button class="action-btn" @click=${() => this.regenerateCredentials(app.id)}>
Regenerate
</button>
<button class="action-btn danger" @click=${() => this.deleteApp(app.id)}>
Delete
</button>
</div>
</div>
`)}
</div>
`;
}
private renderCreateDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.showCreateDialog = false;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Create Global App</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="app-name" placeholder="e.g., foss.global" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="app-description" placeholder="Describe what this app does..."></textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="app-url" placeholder="https://app.example.com" />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="app-logo" placeholder="https://example.com/logo.png" />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="app-category" placeholder="e.g., Productivity" />
</div>
<div class="form-group">
<label class="form-label">Redirect URIs (comma-separated)</label>
<input type="text" class="form-input" id="app-redirects" placeholder="https://app.example.com/callback" />
</div>
<div class="form-group">
<label class="form-label">Allowed Scopes (comma-separated)</label>
<input type="text" class="form-input" id="app-scopes" placeholder="openid, profile, email" />
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.showCreateDialog = false}>
Cancel
</dees-button>
<dees-button @clicked=${this.createApp}>
Create App
</dees-button>
</div>
</div>
</div>
`;
}
private renderEditDialog(): TemplateResult {
const app = this.editingApp!;
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.editingApp = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Edit ${app.data.name}</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="edit-name" .value=${app.data.name} />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="edit-description">${app.data.description}</textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="edit-url" .value=${app.data.appUrl} />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="edit-logo" .value=${app.data.logoUrl} />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="edit-category" .value=${app.data.category} />
</div>
<div class="form-group">
<label class="form-label">Status</label>
<dees-input-checkbox
.label=${'App is active'}
.value=${app.data.isActive}
id="edit-active"
></dees-input-checkbox>
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.editingApp = null}>
Cancel
</dees-button>
<dees-button @clicked=${this.updateApp}>
Save Changes
</dees-button>
</div>
</div>
</div>
`;
}
private renderSecretDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.newClientSecret = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Client Secret Generated</h2>
</div>
<div class="dialog-body">
<p>Your new client secret has been generated. Copy it now - you won't be able to see it again.</p>
<div class="secret-display">
<div class="secret-label">Client Secret</div>
<div class="secret-value">${this.newClientSecret}</div>
</div>
<div class="secret-warning">
<dees-icon .icon=${'lucide:alert-triangle'}></dees-icon>
This secret will only be shown once. Store it securely.
</div>
</div>
<div class="dialog-footer">
<dees-button @clicked=${() => {
navigator.clipboard.writeText(this.newClientSecret!);
}}>
Copy to Clipboard
</dees-button>
<dees-button type="secondary" @clicked=${() => this.newClientSecret = null}>
Close
</dees-button>
</div>
</div>
</div>
`;
}
public async firstUpdated() {
await this.loadApps();
}
private async loadApps() {
this.loading = true;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'/typedrequest',
'getGlobalAppStats'
);
const response = await typedRequest.fire({ jwt });
this.apps = response?.apps ?? [];
} catch (error) {
console.error('Error loading apps:', error);
} finally {
this.loading = false;
}
}
private async createApp() {
const nameInput = this.shadowRoot!.querySelector('#app-name') as HTMLInputElement;
const descInput = this.shadowRoot!.querySelector('#app-description') as HTMLTextAreaElement;
const urlInput = this.shadowRoot!.querySelector('#app-url') as HTMLInputElement;
const logoInput = this.shadowRoot!.querySelector('#app-logo') as HTMLInputElement;
const categoryInput = this.shadowRoot!.querySelector('#app-category') as HTMLInputElement;
const redirectsInput = this.shadowRoot!.querySelector('#app-redirects') as HTMLInputElement;
const scopesInput = this.shadowRoot!.querySelector('#app-scopes') as HTMLInputElement;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'/typedrequest',
'createGlobalApp'
);
const response = await typedRequest.fire({
jwt,
name: nameInput.value,
description: descInput.value,
appUrl: urlInput.value,
logoUrl: logoInput.value,
category: categoryInput.value,
redirectUris: redirectsInput.value.split(',').map(s => s.trim()).filter(Boolean),
allowedScopes: scopesInput.value.split(',').map(s => s.trim()).filter(Boolean),
});
this.showCreateDialog = false;
this.newClientSecret = response.clientSecret;
await this.loadApps();
} catch (error) {
console.error('Error creating app:', error);
alert('Failed to create app');
}
}
private async updateApp() {
const app = this.editingApp!;
const nameInput = this.shadowRoot!.querySelector('#edit-name') as HTMLInputElement;
const descInput = this.shadowRoot!.querySelector('#edit-description') as HTMLTextAreaElement;
const urlInput = this.shadowRoot!.querySelector('#edit-url') as HTMLInputElement;
const logoInput = this.shadowRoot!.querySelector('#edit-logo') as HTMLInputElement;
const categoryInput = this.shadowRoot!.querySelector('#edit-category') as HTMLInputElement;
const activeCheckbox = this.shadowRoot!.querySelector('#edit-active') as any;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'/typedrequest',
'updateGlobalApp'
);
await typedRequest.fire({
jwt,
appId: app.id,
updates: {
name: nameInput.value,
description: descInput.value,
appUrl: urlInput.value,
logoUrl: logoInput.value,
category: categoryInput.value,
isActive: activeCheckbox.value,
},
});
this.editingApp = null;
await this.loadApps();
} catch (error) {
console.error('Error updating app:', error);
alert('Failed to update app');
}
}
private async regenerateCredentials(appId: string) {
if (!confirm('Are you sure you want to regenerate credentials? The current credentials will stop working.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'/typedrequest',
'regenerateAppCredentials'
);
const response = await typedRequest.fire({ jwt, appId });
this.newClientSecret = response.clientSecret;
} catch (error) {
console.error('Error regenerating credentials:', error);
alert('Failed to regenerate credentials');
}
}
private async deleteApp(appId: string) {
if (!confirm('Are you sure you want to delete this app? All organizations will be disconnected.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'/typedrequest',
'deleteGlobalApp'
);
const response = await typedRequest.fire({ jwt, appId });
if (response.disconnectedOrganizations > 0) {
alert(`App deleted. ${response.disconnectedOrganizations} organizations were disconnected.`);
}
await this.loadApps();
} catch (error) {
console.error('Error deleting app:', error);
alert('Failed to delete app');
}
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './adminview.js';
export * from './appsview.js';
export * from './baseview.js';
export * from './orgsetup.js';
+5
View File
@@ -46,6 +46,11 @@ export const getOrganizationsAction = accountState.createAction<void>(
const response = await idpState.idpClient.getRolesAndOrganizations();
currentState.organizations = response.organizations;
currentState.roles = response.roles;
// Also fetch user data for admin checks
const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null);
if (whoIsResponse?.user) {
currentState.user = whoIsResponse.user;
}
return currentState;
}
);