import * as appstate from '../appstate.js'; import * as interfaces from '../../dist_ts_interfaces/index.js'; import { viewHostCss } from './shared/css.js'; import { DeesElement, css, cssManager, customElement, html, state, type TemplateResult, } from '@design.estate/dees-element'; type TApiTokenScope = interfaces.data.TApiTokenScope; @customElement('ops-view-apitokens') export class OpsViewApiTokens extends DeesElement { @state() accessor routeState: appstate.IRouteManagementState = { mergedRoutes: [], warnings: [], apiTokens: [], isLoading: false, error: null, lastUpdated: 0, }; constructor() { super(); const sub = appstate.routeManagementStatePart .select((s) => s) .subscribe((routeState) => { this.routeState = routeState; }); this.rxSubscriptions.push(sub); // Re-fetch tokens when user logs in (fixes race condition where // the view is created before authentication completes) const loginSub = appstate.loginStatePart .select((s) => s.isLoggedIn) .subscribe((isLoggedIn) => { if (isLoggedIn) { appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); } }); this.rxSubscriptions.push(loginSub); } public static styles = [ cssManager.defaultStyles, viewHostCss, css` .apiTokensContainer { display: flex; flex-direction: column; gap: 24px; } .scopePill { display: inline-flex; align-items: center; padding: 2px 6px; border-radius: 3px; font-size: 11px; background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')}; color: ${cssManager.bdTheme('#0369a1', '#0af')}; margin-right: 4px; margin-bottom: 2px; } .statusBadge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; } .statusBadge.active { background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; color: ${cssManager.bdTheme('#166534', '#4ade80')}; } .statusBadge.disabled { background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; color: ${cssManager.bdTheme('#991b1b', '#f87171')}; } .statusBadge.expired { background: ${cssManager.bdTheme('#f3f4f6', '#374151')}; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; } `, ]; public render(): TemplateResult { const { apiTokens } = this.routeState; return html` API Tokens
({ name: token.name, scopes: this.renderScopePills(token.scopes), status: this.renderStatusBadge(token), created: new Date(token.createdAt).toLocaleDateString(), expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never', lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never', })} .dataActions=${[ { name: 'Create Token', iconName: 'lucide:plus', type: ['header'], actionFunc: async () => { await this.showCreateTokenDialog(); }, }, { name: 'Enable', iconName: 'lucide:play', type: ['inRow', 'contextmenu'] as any, actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, actionFunc: async (actionData: any) => { const token = actionData.item as interfaces.data.IApiTokenInfo; await appstate.routeManagementStatePart.dispatchAction( appstate.toggleApiTokenAction, { id: token.id, enabled: true }, ); }, }, { name: 'Disable', iconName: 'lucide:pause', type: ['inRow', 'contextmenu'] as any, actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, actionFunc: async (actionData: any) => { const token = actionData.item as interfaces.data.IApiTokenInfo; await appstate.routeManagementStatePart.dispatchAction( appstate.toggleApiTokenAction, { id: token.id, enabled: false }, ); }, }, { name: 'Revoke', iconName: 'lucide:trash2', type: ['inRow', 'contextmenu'] as any, actionFunc: async (actionData: any) => { const token = actionData.item as interfaces.data.IApiTokenInfo; await appstate.routeManagementStatePart.dispatchAction( appstate.revokeApiTokenAction, token.id, ); }, }, ]} >
`; } private renderScopePills(scopes: TApiTokenScope[]): TemplateResult { return html`
${scopes.map( (s) => html`${s}`, )}
`; } private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult { if (!token.enabled) { return html`Disabled`; } if (token.expiresAt && token.expiresAt < Date.now()) { return html`Expired`; } return html`Active`; } private async showCreateTokenDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); const allScopes: TApiTokenScope[] = [ 'routes:read', 'routes:write', 'config:read', 'tokens:read', 'tokens:manage', ]; await DeesModal.createAndShow({ heading: 'Create API Token', content: html`
The token value will be shown once after creation. Copy it immediately.
`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy(), }, { name: 'Create', iconName: 'lucide:key', action: async (modalArg: any) => { const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); if (!form) return; const formData = await form.collectFormData(); if (!formData.name) return; // dees-input-tags returns string[] directly const scopes = (formData.scopes || []) .filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[]; const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null; await modalArg.destroy(); try { const response = await appstate.createApiToken(formData.name, scopes, expiresInDays); if (response.success && response.tokenValue) { // Refresh the list first so it's ready when user dismisses the modal await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); // Show the token value in a new modal await DeesModal.createAndShow({ heading: 'Token Created', content: html`

Copy this token now. It will not be shown again.

${response.tokenValue}
`, menuOptions: [ { name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy(), }, ], }); } } catch (error) { console.error('Failed to create token:', error); } }, }, ], }); } async firstUpdated() { await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); } }