import { Component, inject, signal, OnInit, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; import { ToastService } from '../../core/services/toast.service'; import { IRegistryToken, ICreateTokenRequest, IService } from '../../core/types/api.types'; import { CardComponent, CardHeaderComponent, CardTitleComponent, CardDescriptionComponent, CardContentComponent, } from '../../ui/card/card.component'; import { ButtonComponent } from '../../ui/button/button.component'; import { InputComponent } from '../../ui/input/input.component'; import { LabelComponent } from '../../ui/label/label.component'; import { BadgeComponent } from '../../ui/badge/badge.component'; import { TableComponent, TableHeaderComponent, TableBodyComponent, TableRowComponent, TableHeadComponent, TableCellComponent, } from '../../ui/table/table.component'; import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; import { DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, } from '../../ui/dialog/dialog.component'; @Component({ selector: 'app-tokens', standalone: true, imports: [ FormsModule, RouterLink, CardComponent, CardHeaderComponent, CardTitleComponent, CardDescriptionComponent, CardContentComponent, ButtonComponent, InputComponent, LabelComponent, BadgeComponent, TableComponent, TableHeaderComponent, TableBodyComponent, TableRowComponent, TableHeadComponent, TableCellComponent, SkeletonComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, ], template: `

Registry Tokens

Manage authentication tokens for the Onebox registry

Global Tokens Tokens that can push images to multiple services @if (loading() && globalTokens().length === 0) {
@for (_ of [1,2]; track $index) { }
} @else if (globalTokens().length === 0) {

No global tokens created

} @else { Name Scope Expires Last Used Created By Actions @for (token of globalTokens(); track token.id) { {{ token.name }} {{ token.scopeDisplay }} @if (token.isExpired) { Expired } @else if (token.expiresAt) { {{ formatExpiry(token.expiresAt) }} } @else { Never } @if (token.lastUsedAt) { {{ formatRelativeTime(token.lastUsedAt) }} } @else { Never } {{ token.createdBy }} } }
CI Tokens (Service-specific) Tokens tied to individual services for CI/CD pipelines @if (loading() && ciTokens().length === 0) {
@for (_ of [1,2]; track $index) { }
} @else if (ciTokens().length === 0) {

No CI tokens created

} @else { Name Service Expires Last Used Created By Actions @for (token of ciTokens(); track token.id) { {{ token.name }} {{ token.scopeDisplay }} @if (token.isExpired) { Expired } @else if (token.expiresAt) { {{ formatExpiry(token.expiresAt) }} } @else { Never } @if (token.lastUsedAt) { {{ formatRelativeTime(token.lastUsedAt) }} } @else { Never } {{ token.createdBy }} } }
Create Registry Token Create a new token for pushing images to the Onebox registry
@if (createForm.type === 'global') {
@if (!scopeAll) {
@for (service of services(); track service.name) { } @if (services().length === 0) {

No services available

}
}
} @else {
}
Token Created Copy this token now. You won't be able to see it again!
{{ createdPlainToken() }}
Delete Token Are you sure you want to delete "{{ tokenToDelete()?.name }}"? This action cannot be undone. `, }) export class TokensComponent implements OnInit { private api = inject(ApiService); private toast = inject(ToastService); tokens = signal([]); services = signal([]); loading = signal(false); creating = signal(false); createDialogOpen = signal(false); tokenCreatedDialogOpen = signal(false); deleteDialogOpen = signal(false); tokenToDelete = signal(null); createdPlainToken = signal(''); // Form state createForm: ICreateTokenRequest = { name: '', type: 'global', scope: 'all', expiresIn: '90d', }; scopeAll = true; selectedServices = signal([]); selectedSingleService = ''; // Computed signals for filtered tokens globalTokens = computed(() => this.tokens().filter(t => t.type === 'global')); ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci')); ngOnInit(): void { this.loadTokens(); this.loadServices(); } async loadTokens(): Promise { this.loading.set(true); try { const response = await this.api.getRegistryTokens(); if (response.success && response.data) { this.tokens.set(response.data); } } catch { this.toast.error('Failed to load tokens'); } finally { this.loading.set(false); } } async loadServices(): Promise { try { const response = await this.api.getServices(); if (response.success && response.data) { this.services.set(response.data); } } catch { // Silent fail - services list is optional } } openCreateDialog(type?: 'global' | 'ci'): void { this.createForm = { name: '', type: type || 'global', scope: 'all', expiresIn: '90d', }; this.scopeAll = true; this.selectedServices.set([]); this.selectedSingleService = ''; this.createDialogOpen.set(true); } toggleService(serviceName: string): void { const current = this.selectedServices(); if (current.includes(serviceName)) { this.selectedServices.set(current.filter(s => s !== serviceName)); } else { this.selectedServices.set([...current, serviceName]); } } async createToken(): Promise { if (!this.createForm.name) { this.toast.error('Please enter a token name'); return; } // Build scope based on type let scope: 'all' | string[]; if (this.createForm.type === 'global') { if (this.scopeAll) { scope = 'all'; } else { if (this.selectedServices().length === 0) { this.toast.error('Please select at least one service'); return; } scope = this.selectedServices(); } } else { if (!this.selectedSingleService) { this.toast.error('Please select a service'); return; } scope = [this.selectedSingleService]; } this.creating.set(true); try { const response = await this.api.createRegistryToken({ ...this.createForm, scope, }); if (response.success && response.data) { this.createdPlainToken.set(response.data.plainToken); this.createDialogOpen.set(false); this.tokenCreatedDialogOpen.set(true); this.loadTokens(); } else { this.toast.error(response.error || 'Failed to create token'); } } catch { this.toast.error('Failed to create token'); } finally { this.creating.set(false); } } copyToken(): void { navigator.clipboard.writeText(this.createdPlainToken()); this.toast.success('Token copied to clipboard'); } confirmDelete(token: IRegistryToken): void { this.tokenToDelete.set(token); this.deleteDialogOpen.set(true); } async deleteToken(): Promise { const token = this.tokenToDelete(); if (!token) return; try { const response = await this.api.deleteRegistryToken(token.id); if (response.success) { this.toast.success('Token deleted'); this.loadTokens(); } else { this.toast.error(response.error || 'Failed to delete token'); } } catch { this.toast.error('Failed to delete token'); } finally { this.deleteDialogOpen.set(false); this.tokenToDelete.set(null); } } formatExpiry(timestamp: number): string { const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24)); if (days < 0) return 'Expired'; if (days === 0) return 'Today'; if (days === 1) return 'Tomorrow'; if (days < 30) return `${days} days`; return new Date(timestamp).toLocaleDateString(); } formatRelativeTime(timestamp: number): string { const diff = Date.now() - timestamp; const minutes = Math.floor(diff / (1000 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60)); const days = Math.floor(diff / (1000 * 60 * 60 * 24)); if (minutes < 1) return 'Just now'; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 30) return `${days}d ago`; return new Date(timestamp).toLocaleDateString(); } }