Initial commit: scaffold stack.gallery catalog frontend
This commit is contained in:
495
ts_web/elements/sg-tokens-view.ts
Normal file
495
ts_web/elements/sg-tokens-view.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user