282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
|
|
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`
|
||
|
|
<ops-sectionheading>API Tokens</ops-sectionheading>
|
||
|
|
|
||
|
|
<div class="apiTokensContainer">
|
||
|
|
<dees-table
|
||
|
|
.heading1=${'API Tokens'}
|
||
|
|
.heading2=${'Manage programmatic access tokens'}
|
||
|
|
.data=${apiTokens}
|
||
|
|
.dataName=${'token'}
|
||
|
|
.searchable=${true}
|
||
|
|
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||
|
|
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,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
]}
|
||
|
|
></dees-table>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
|
||
|
|
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
|
||
|
|
(s) => html`<span class="scopePill">${s}</span>`,
|
||
|
|
)}</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
|
||
|
|
if (!token.enabled) {
|
||
|
|
return html`<span class="statusBadge disabled">Disabled</span>`;
|
||
|
|
}
|
||
|
|
if (token.expiresAt && token.expiresAt < Date.now()) {
|
||
|
|
return html`<span class="statusBadge expired">Expired</span>`;
|
||
|
|
}
|
||
|
|
return html`<span class="statusBadge active">Active</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
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`
|
||
|
|
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
|
||
|
|
The token value will be shown once after creation. Copy it immediately.
|
||
|
|
</div>
|
||
|
|
<dees-form>
|
||
|
|
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
|
||
|
|
<dees-input-tags
|
||
|
|
.key=${'scopes'}
|
||
|
|
.label=${'Token Scopes'}
|
||
|
|
.value=${['routes:read', 'routes:write']}
|
||
|
|
.suggestions=${allScopes}
|
||
|
|
.required=${true}
|
||
|
|
></dees-input-tags>
|
||
|
|
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||
|
|
</dees-form>
|
||
|
|
`,
|
||
|
|
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`
|
||
|
|
<div style="color: #ccc; padding: 8px 0;">
|
||
|
|
<p>Copy this token now. It will not be shown again.</p>
|
||
|
|
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||
|
|
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`,
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|