feat(tokens): Add support for organization-owned API tokens and org-level token management

This commit is contained in:
2025-11-28 12:57:17 +00:00
parent 93ae998e3f
commit dface47942
9 changed files with 354 additions and 54 deletions

View File

@@ -39,11 +39,21 @@ export interface IPackage {
updatedAt: string;
}
export interface ITokenScope {
protocol: string;
organizationId?: string;
repositoryId?: string;
actions: string[];
}
export interface IToken {
id: string;
name: string;
tokenPrefix: string;
protocols: string[];
scopes?: ITokenScope[];
organizationId?: string;
createdById?: string;
expiresAt?: string;
lastUsedAt?: string;
usageCount: number;
@@ -179,14 +189,21 @@ export class ApiService {
}
// Tokens
getTokens(): Observable<{ tokens: IToken[] }> {
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> {
let httpParams = new HttpParams();
if (organizationId) {
httpParams = httpParams.set('organizationId', organizationId);
}
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, {
params: httpParams,
});
}
createToken(data: {
name: string;
organizationId?: string;
protocols: string[];
scopes: { protocol: string; actions: string[] }[];
scopes: ITokenScope[];
expiresInDays?: number;
}): Observable<IToken & { token: string }> {
return this.http.post<IToken & { token: string }>(

View File

@@ -1,9 +1,15 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService, type IToken } from '../../core/services/api.service';
import { ApiService, type IToken, type ITokenScope, type IOrganization } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
interface IScopeEntry {
protocol: string;
actions: string[];
organizationId?: string;
}
@Component({
selector: 'app-tokens',
standalone: true,
@@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service';
@for (token of tokens(); track token.id) {
<li class="px-6 py-4 hover:bg-muted/30 transition-colors">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
@for (protocol of token.protocols.slice(0, 3); track protocol) {
<span class="badge-accent">{{ protocol }}</span>
}
@if (token.protocols.length > 3) {
<span class="badge-default">+{{ token.protocols.length - 3 }}</span>
@if (token.organizationId) {
<span class="badge-primary">Org Token</span>
} @else {
<span class="badge-default">Personal</span>
}
</div>
<p class="font-mono text-sm text-muted-foreground mt-1">
<!-- Scope summary -->
<div class="flex flex-wrap gap-1.5 mt-2">
@for (scope of token.scopes?.slice(0, 4) || []; track $index) {
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-muted border border-border">
<span class="font-semibold">{{ scope.protocol === '*' ? 'All' : scope.protocol }}</span>
<span class="text-muted-foreground">{{ formatActions(scope.actions) }}</span>
</span>
}
@if ((token.scopes?.length || 0) > 4) {
<span class="badge-default text-xs">+{{ (token.scopes?.length || 0) - 4 }} more</span>
}
</div>
<p class="font-mono text-sm text-muted-foreground mt-2">
<code>{{ token.tokenPrefix }}...</code>
@if (token.expiresAt) {
<span class="mx-2">·</span>
@@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service';
· {{ token.usageCount }} uses
</p>
</div>
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10">
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10 ml-4">
Revoke
</button>
</div>
@@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div class="card w-full max-w-lg mx-4">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8">
<div class="card w-full max-w-2xl mx-4">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
@@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div class="card-content space-y-5">
<!-- Token Name -->
<div>
<label class="label block mb-1.5">Token Name</label>
<input
@@ -108,27 +126,134 @@ import { ToastService } from '../../core/services/toast.service';
placeholder="my-ci-token"
/>
</div>
<!-- Token Type -->
<div>
<label class="label block mb-1.5">Protocols</label>
<div class="flex flex-wrap gap-2">
@for (protocol of availableProtocols; track protocol) {
<label class="label block mb-1.5">Token Type</label>
<div class="flex gap-3">
<label
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
[ngClass]="{
'bg-primary/10 border-primary': !newToken.organizationId,
'border-border': newToken.organizationId
}">
<input
type="radio"
name="tokenType"
[checked]="!newToken.organizationId"
(change)="newToken.organizationId = undefined"
class="sr-only"
/>
<div>
<div class="font-mono text-sm font-medium text-foreground">Personal</div>
<div class="font-mono text-xs text-muted-foreground">For your personal use</div>
</div>
</label>
@if (organizations().length > 0) {
<label
class="flex items-center gap-2 px-3 py-1.5 border cursor-pointer hover:bg-muted/30 transition-colors font-mono text-sm"
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
[ngClass]="{
'bg-primary/10 border-primary text-primary': newToken.protocols.includes(protocol),
'border-border text-foreground': !newToken.protocols.includes(protocol)
'bg-primary/10 border-primary': newToken.organizationId,
'border-border': !newToken.organizationId
}">
<input
type="checkbox"
[checked]="newToken.protocols.includes(protocol)"
(change)="toggleProtocol(protocol)"
type="radio"
name="tokenType"
[checked]="newToken.organizationId"
(change)="newToken.organizationId = organizations()[0]?.id"
class="sr-only"
/>
<span>{{ protocol }}</span>
<div>
<div class="font-mono text-sm font-medium text-foreground">Organization</div>
<div class="font-mono text-xs text-muted-foreground">Shared with org members</div>
</div>
</label>
}
</div>
@if (newToken.organizationId) {
<select [(ngModel)]="newToken.organizationId" class="input mt-2">
@for (org of organizations(); track org.id) {
<option [value]="org.id">{{ org.displayName || org.name }}</option>
}
</select>
}
</div>
<!-- Scopes -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="label">Scopes</label>
<button (click)="addScope()" class="btn-ghost btn-sm text-primary">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Scope
</button>
</div>
@if (newToken.scopes.length === 0) {
<div class="p-4 border border-dashed border-border text-center">
<p class="font-mono text-sm text-muted-foreground">No scopes defined. Add at least one scope.</p>
</div>
} @else {
<div class="space-y-3">
@for (scope of newToken.scopes; track $index; let i = $index) {
<div class="p-4 border border-border bg-muted/20">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-3">
<!-- Protocol -->
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Protocol</label>
<select [(ngModel)]="scope.protocol" class="input flex-1">
<option value="*">All Protocols</option>
@for (protocol of availableProtocols; track protocol) {
<option [value]="protocol">{{ protocol }}</option>
}
</select>
</div>
<!-- Actions -->
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Actions</label>
<div class="flex gap-4">
@for (action of availableActions; track action) {
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
[checked]="scope.actions.includes(action)"
(change)="toggleAction(scope, action)"
class="form-checkbox"
/>
<span class="font-mono text-sm text-foreground capitalize">{{ action }}</span>
</label>
}
</div>
</div>
<!-- Limit to Org -->
@if (organizations().length > 0 && !newToken.organizationId) {
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Limit to</label>
<select [(ngModel)]="scope.organizationId" class="input flex-1">
<option [ngValue]="undefined">Any Organization</option>
@for (org of organizations(); track org.id) {
<option [value]="org.id">{{ org.displayName || org.name }}</option>
}
</select>
</div>
}
</div>
<button (click)="removeScope(i)" class="btn-ghost btn-sm p-1 text-muted-foreground hover:text-destructive">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
}
</div>
}
</div>
<!-- Expiration -->
<div>
<label class="label block mb-1.5">Expiration (optional)</label>
<select [(ngModel)]="newToken.expiresInDays" class="input">
@@ -142,7 +267,10 @@ import { ToastService } from '../../core/services/toast.service';
</div>
<div class="card-footer flex justify-end gap-3">
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button>
<button (click)="createToken()" [disabled]="creating() || !newToken.name || newToken.protocols.length === 0" class="btn-primary btn-md">
<button
(click)="createToken()"
[disabled]="creating() || !newToken.name || newToken.scopes.length === 0 || !hasValidScopes()"
class="btn-primary btn-md">
@if (creating()) {
Creating...
} @else {
@@ -196,54 +324,86 @@ export class TokensComponent implements OnInit {
private toastService = inject(ToastService);
tokens = signal<IToken[]>([]);
organizations = signal<IOrganization[]>([]);
loading = signal(true);
showCreateModal = signal(false);
creating = signal(false);
createdToken = signal<string | null>(null);
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
availableActions = ['read', 'write', 'delete'];
newToken = {
newToken: {
name: string;
organizationId?: string;
scopes: IScopeEntry[];
expiresInDays: number | null;
} = {
name: '',
protocols: [] as string[],
expiresInDays: null as number | null,
organizationId: undefined,
scopes: [],
expiresInDays: null,
};
ngOnInit(): void {
this.loadTokens();
this.loadData();
}
private async loadTokens(): Promise<void> {
private async loadData(): Promise<void> {
this.loading.set(true);
try {
const response = await this.apiService.getTokens().toPromise();
this.tokens.set(response?.tokens || []);
const [tokensRes, orgsRes] = await Promise.all([
this.apiService.getTokens().toPromise(),
this.apiService.getOrganizations().toPromise(),
]);
this.tokens.set(tokensRes?.tokens || []);
this.organizations.set(orgsRes?.organizations || []);
} catch (error) {
this.toastService.error('Failed to load tokens');
this.toastService.error('Failed to load data');
} finally {
this.loading.set(false);
}
}
toggleProtocol(protocol: string): void {
if (this.newToken.protocols.includes(protocol)) {
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol);
addScope(): void {
this.newToken.scopes = [
...this.newToken.scopes,
{ protocol: '*', actions: ['read', 'write'] },
];
}
removeScope(index: number): void {
this.newToken.scopes = this.newToken.scopes.filter((_, i) => i !== index);
}
toggleAction(scope: IScopeEntry, action: string): void {
if (scope.actions.includes(action)) {
scope.actions = scope.actions.filter((a) => a !== action);
} else {
this.newToken.protocols = [...this.newToken.protocols, protocol];
scope.actions = [...scope.actions, action];
}
}
hasValidScopes(): boolean {
return this.newToken.scopes.every((s) => s.protocol && s.actions.length > 0);
}
async createToken(): Promise<void> {
if (!this.newToken.name || this.newToken.protocols.length === 0) return;
if (!this.newToken.name || this.newToken.scopes.length === 0 || !this.hasValidScopes()) return;
this.creating.set(true);
try {
// Build protocols array from scopes
const protocols = [...new Set(this.newToken.scopes.map((s) => s.protocol))];
const response = await this.apiService.createToken({
name: this.newToken.name,
protocols: this.newToken.protocols,
scopes: this.newToken.protocols.map((p) => ({
protocol: p,
actions: ['read', 'write'],
organizationId: this.newToken.organizationId,
protocols,
scopes: this.newToken.scopes.map((s) => ({
protocol: s.protocol,
actions: s.actions,
organizationId: s.organizationId,
})),
expiresInDays: this.newToken.expiresInDays || undefined,
}).toPromise();
@@ -252,7 +412,7 @@ export class TokensComponent implements OnInit {
this.createdToken.set(response.token);
this.tokens.update((tokens) => [response, ...tokens]);
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
this.resetNewToken();
}
} catch (error) {
this.toastService.error('Failed to create token');
@@ -275,7 +435,16 @@ export class TokensComponent implements OnInit {
closeCreateModal(): void {
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
this.resetNewToken();
}
private resetNewToken(): void {
this.newToken = {
name: '',
organizationId: undefined,
scopes: [],
expiresInDays: null,
};
}
copyToken(): void {
@@ -289,4 +458,9 @@ export class TokensComponent implements OnInit {
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatActions(actions: string[]): string {
if (actions.includes('*')) return 'full';
return actions.join(', ');
}
}