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();
}
}