Files
catalog/ts_web/elements/sg-tokens-view.ts

496 lines
14 KiB
TypeScript

import {
DeesElement,
customElement,
html,
css,
cssManager,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import type { ISgToken, ISgOrganization, TSgProtocol } from '../interfaces.js';
declare global {
interface HTMLElementTagNameMap {
'sg-tokens-view': SgTokensView;
}
}
const ALL_PROTOCOLS: TSgProtocol[] = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
@customElement('sg-tokens-view')
export class SgTokensView extends DeesElement {
public static demo = () => html`
<div style="padding: 24px; max-width: 1000px; background: #09090b;">
<sg-tokens-view
.tokens=${[
{ id: 't1', name: 'CI/CD Pipeline', tokenPrefix: 'sg_abc', protocols: ['npm', 'oci'], scopes: [{ protocol: '*', actions: ['read', 'write'] }], expiresAt: '2027-01-01', lastUsedAt: '2026-03-19', usageCount: 245, createdAt: '2026-01-15' },
{ id: 't2', name: 'Read-only Mirror', tokenPrefix: 'sg_def', protocols: ['npm'], scopes: [{ protocol: 'npm', actions: ['read'] }], lastUsedAt: '2026-03-18', usageCount: 1230, createdAt: '2025-11-01' },
{ id: 't3', name: 'Deploy Token', tokenPrefix: 'sg_ghi', protocols: ['oci'], scopes: [{ protocol: 'oci', actions: ['read', 'write'] }], organizationId: 'org1', expiresAt: '2026-06-01', usageCount: 56, createdAt: '2026-02-20' },
]}
.organizations=${[
{ id: 'org1', name: 'myorg', displayName: 'My Organization', isPublic: true, memberCount: 8, createdAt: '2025-06-01' },
]}
></sg-tokens-view>
</div>
`;
public static demoGroups = ['Auth'];
@property({ type: Array })
public accessor tokens: ISgToken[] = [];
@property({ type: Array })
public accessor organizations: ISgOrganization[] = [];
private showCreateForm = false;
private createName = '';
private createProtocols: TSgProtocol[] = [];
private createOrgId = '';
private createExpiryDays = 0;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
color: ${cssManager.bdTheme('#111', '#fff')};
}
.container {
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: ${cssManager.bdTheme('#111', '#fff')};
border: none;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#fff', '#111')};
cursor: pointer;
transition: opacity 150ms ease;
}
.create-btn:hover {
opacity: 0.85;
}
/* Create form */
.create-form {
background: ${cssManager.bdTheme('#fff', '#111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#111', '#ddd')};
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-input {
padding: 10px 12px;
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
font-size: 14px;
color: ${cssManager.bdTheme('#111', '#fff')};
outline: none;
font-family: inherit;
}
.form-input:focus {
border-color: ${cssManager.bdTheme('#111', '#fff')};
}
.form-select {
padding: 10px 12px;
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
font-size: 14px;
color: ${cssManager.bdTheme('#111', '#fff')};
outline: none;
font-family: inherit;
}
.protocol-selector {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.protocol-chip {
padding: 6px 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#666', '#999')};
cursor: pointer;
transition: all 150ms ease;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.protocol-chip:hover {
border-color: ${cssManager.bdTheme('#999', '#666')};
}
.protocol-chip.selected {
background: ${cssManager.bdTheme('#111', '#fff')};
color: ${cssManager.bdTheme('#fff', '#111')};
border-color: ${cssManager.bdTheme('#111', '#fff')};
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.form-submit {
padding: 8px 20px;
background: ${cssManager.bdTheme('#111', '#fff')};
border: none;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#fff', '#111')};
cursor: pointer;
transition: opacity 150ms ease;
}
.form-submit:hover {
opacity: 0.85;
}
.form-cancel {
padding: 8px 20px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
font-size: 13px;
color: ${cssManager.bdTheme('#666', '#999')};
cursor: pointer;
transition: all 150ms ease;
}
.form-cancel:hover {
border-color: ${cssManager.bdTheme('#999', '#666')};
color: ${cssManager.bdTheme('#111', '#fff')};
}
/* Token list */
.token-list {
display: flex;
flex-direction: column;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
}
.token-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: ${cssManager.bdTheme('#fff', '#111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
}
.token-row:last-child {
border-bottom: none;
}
.token-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.token-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.token-name {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111', '#fff')};
}
.token-prefix {
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: ${cssManager.bdTheme('#888', '#777')};
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
padding: 1px 6px;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
}
.token-protocols {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.token-protocol {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
padding: 1px 6px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
color: ${cssManager.bdTheme('#666', '#aaa')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
}
.token-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: ${cssManager.bdTheme('#888', '#777')};
flex-wrap: wrap;
}
.token-expired {
color: #ef4444;
font-weight: 600;
}
.token-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.revoke-btn {
padding: 6px 14px;
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
font-size: 12px;
font-weight: 500;
color: #ef4444;
cursor: pointer;
transition: all 150ms ease;
}
.revoke-btn:hover {
background: rgba(239, 68, 68, 0.15);
}
.empty-state {
text-align: center;
padding: 48px 32px;
font-size: 14px;
color: ${cssManager.bdTheme('#888', '#777')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
background: ${cssManager.bdTheme('#fff', '#111')};
}
`,
];
public render(): TemplateResult {
return html`
<div class="container">
<div class="header">
<div class="page-title">API Tokens</div>
<button class="create-btn" @click=${() => { this.showCreateForm = true; this.requestUpdate(); }}>
+ New Token
</button>
</div>
${this.showCreateForm ? this.renderCreateForm() : ''}
${this.tokens.length > 0
? html`
<div class="token-list">
${this.tokens.map((token) => this.renderToken(token))}
</div>
`
: html`<div class="empty-state">No API tokens created yet. Create one to authenticate with the registry.</div>`}
</div>
`;
}
private renderCreateForm(): TemplateResult {
return html`
<div class="create-form">
<div class="form-title">Create New Token</div>
<div class="form-group">
<label class="form-label">Token Name</label>
<input
type="text"
class="form-input"
placeholder="e.g., CI/CD Pipeline"
@input=${(e: InputEvent) => { this.createName = (e.target as HTMLInputElement).value; }}
>
</div>
<div class="form-group">
<label class="form-label">Protocols</label>
<div class="protocol-selector">
${ALL_PROTOCOLS.map(
(proto) => html`
<button
class="protocol-chip ${this.createProtocols.includes(proto) ? 'selected' : ''}"
@click=${() => this.toggleProtocol(proto)}
>${proto}</button>
`
)}
</div>
</div>
${this.organizations.length > 0
? html`
<div class="form-group">
<label class="form-label">Organization (optional)</label>
<select
class="form-select"
@change=${(e: Event) => { this.createOrgId = (e.target as HTMLSelectElement).value; }}
>
<option value="">No organization (personal token)</option>
${this.organizations.map(
(org) => html`<option value=${org.id}>${org.displayName || org.name}</option>`
)}
</select>
</div>
`
: ''}
<div class="form-group">
<label class="form-label">Expires In (days, 0 = never)</label>
<input
type="number"
class="form-input"
placeholder="0"
min="0"
@input=${(e: InputEvent) => { this.createExpiryDays = parseInt((e.target as HTMLInputElement).value) || 0; }}
>
</div>
<div class="form-actions">
<button class="form-submit" @click=${this.handleCreate}>Create Token</button>
<button class="form-cancel" @click=${() => { this.showCreateForm = false; this.requestUpdate(); }}>Cancel</button>
</div>
</div>
`;
}
private renderToken(token: ISgToken): TemplateResult {
const isExpired = token.expiresAt && new Date(token.expiresAt) < new Date();
return html`
<div class="token-row">
<div class="token-info">
<div class="token-name-row">
<span class="token-name">${token.name}</span>
<span class="token-prefix">${token.tokenPrefix}...</span>
</div>
<div class="token-protocols">
${token.protocols.map(
(p) => html`<span class="token-protocol">${p}</span>`
)}
</div>
<div class="token-meta">
<span>Created ${this.formatDate(token.createdAt)}</span>
${token.lastUsedAt ? html`<span>Last used ${this.formatDate(token.lastUsedAt)}</span>` : ''}
<span>${token.usageCount} uses</span>
${token.expiresAt
? isExpired
? html`<span class="token-expired">Expired</span>`
: html`<span>Expires ${this.formatDate(token.expiresAt)}</span>`
: html`<span>No expiry</span>`}
${token.organizationId ? html`<span>Org-scoped</span>` : ''}
</div>
</div>
<div class="token-actions">
<button class="revoke-btn" @click=${() => this.handleRevoke(token.id)}>Revoke</button>
</div>
</div>
`;
}
private toggleProtocol(proto: TSgProtocol) {
if (this.createProtocols.includes(proto)) {
this.createProtocols = this.createProtocols.filter((p) => p !== proto);
} else {
this.createProtocols = [...this.createProtocols, proto];
}
this.requestUpdate();
}
private handleCreate() {
if (!this.createName.trim()) return;
this.dispatchEvent(
new CustomEvent('create', {
detail: {
name: this.createName.trim(),
protocols: this.createProtocols,
scopes: [{ protocol: '*' as const, actions: ['read', 'write'] as const }],
organizationId: this.createOrgId || undefined,
expiresInDays: this.createExpiryDays || undefined,
},
bubbles: true,
composed: true,
})
);
this.showCreateForm = false;
this.createName = '';
this.createProtocols = [];
this.createOrgId = '';
this.createExpiryDays = 0;
this.requestUpdate();
}
private handleRevoke(tokenId: string) {
this.dispatchEvent(
new CustomEvent('revoke', {
detail: { tokenId },
bubbles: true,
composed: true,
})
);
}
private formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return dateStr;
}
}
}