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

1
.gitignore vendored
View File

@@ -50,6 +50,7 @@ coverage/
# Claude # Claude
CLAUDE.md CLAUDE.md
stories/
# Package manager locks (keep pnpm-lock.yaml) # Package manager locks (keep pnpm-lock.yaml)
package-lock.json package-lock.json

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## 2025-11-28 - 1.2.0 - feat(tokens)
Add support for organization-owned API tokens and org-level token management
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and new static getOrgTokens method
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate
- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks org management permissions
- Router: PermissionService instantiated and passed to TokenApi
- UI: api.service types and methods updated — IToken and ITokenScope include organizationId/createdById; getTokens and createToken now support an organizationId parameter and scoped scopes
- .gitignore: added stories/ to ignore
## 2025-11-28 - 1.1.0 - feat(registry) ## 2025-11-28 - 1.1.0 - feat(registry)
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@stack.gallery/registry', name: '@stack.gallery/registry',
version: '1.1.0', version: '1.2.0',
description: 'Enterprise-grade multi-protocol package registry' description: 'Enterprise-grade multi-protocol package registry'
} }

View File

@@ -4,17 +4,22 @@
import type { IApiContext, IApiResponse } from '../router.ts'; import type { IApiContext, IApiResponse } from '../router.ts';
import { TokenService } from '../../services/token.service.ts'; import { TokenService } from '../../services/token.service.ts';
import { PermissionService } from '../../services/permission.service.ts';
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts'; import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
export class TokenApi { export class TokenApi {
private tokenService: TokenService; private tokenService: TokenService;
private permissionService: PermissionService;
constructor(tokenService: TokenService) { constructor(tokenService: TokenService, permissionService?: PermissionService) {
this.tokenService = tokenService; this.tokenService = tokenService;
this.permissionService = permissionService || new PermissionService();
} }
/** /**
* GET /api/v1/tokens * GET /api/v1/tokens
* Query params:
* - organizationId: list org tokens (requires org admin)
*/ */
public async list(ctx: IApiContext): Promise<IApiResponse> { public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -22,7 +27,20 @@ export class TokenApi {
} }
try { try {
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId); const url = new URL(ctx.request.url);
const organizationId = url.searchParams.get('organizationId');
let tokens;
if (organizationId) {
// Check if user can manage org
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
}
tokens = await this.tokenService.getOrgTokens(organizationId);
} else {
tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
}
return { return {
status: 200, status: 200,
@@ -33,6 +51,8 @@ export class TokenApi {
tokenPrefix: t.tokenPrefix, tokenPrefix: t.tokenPrefix,
protocols: t.protocols, protocols: t.protocols,
scopes: t.scopes, scopes: t.scopes,
organizationId: t.organizationId,
createdById: t.createdById,
expiresAt: t.expiresAt, expiresAt: t.expiresAt,
lastUsedAt: t.lastUsedAt, lastUsedAt: t.lastUsedAt,
usageCount: t.usageCount, usageCount: t.usageCount,
@@ -48,6 +68,12 @@ export class TokenApi {
/** /**
* POST /api/v1/tokens * POST /api/v1/tokens
* Body:
* - name: token name
* - organizationId: (optional) create org token instead of personal
* - protocols: array of protocols
* - scopes: array of scope objects
* - expiresInDays: (optional) token expiry
*/ */
public async create(ctx: IApiContext): Promise<IApiResponse> { public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -56,8 +82,9 @@ export class TokenApi {
try { try {
const body = await ctx.request.json(); const body = await ctx.request.json();
const { name, protocols, scopes, expiresInDays } = body as { const { name, organizationId, protocols, scopes, expiresInDays } = body as {
name: string; name: string;
organizationId?: string;
protocols: TRegistryProtocol[]; protocols: TRegistryProtocol[];
scopes: ITokenScope[]; scopes: ITokenScope[];
expiresInDays?: number; expiresInDays?: number;
@@ -90,8 +117,18 @@ export class TokenApi {
} }
} }
// If creating org token, verify permission
if (organizationId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
}
}
const result = await this.tokenService.createToken({ const result = await this.tokenService.createToken({
userId: ctx.actor.userId, userId: ctx.actor.userId,
organizationId,
createdById: ctx.actor.userId,
name, name,
protocols, protocols,
scopes, scopes,
@@ -108,6 +145,7 @@ export class TokenApi {
tokenPrefix: result.token.tokenPrefix, tokenPrefix: result.token.tokenPrefix,
protocols: result.token.protocols, protocols: result.token.protocols,
scopes: result.token.scopes, scopes: result.token.scopes,
organizationId: result.token.organizationId,
expiresAt: result.token.expiresAt, expiresAt: result.token.expiresAt,
createdAt: result.token.createdAt, createdAt: result.token.createdAt,
warning: 'Store this token securely. It will not be shown again.', warning: 'Store this token securely. It will not be shown again.',
@@ -121,6 +159,7 @@ export class TokenApi {
/** /**
* DELETE /api/v1/tokens/:id * DELETE /api/v1/tokens/:id
* Allows revoking personal tokens or org tokens (if org admin)
*/ */
public async revoke(ctx: IApiContext): Promise<IApiResponse> { public async revoke(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -130,12 +169,27 @@ export class TokenApi {
const { id } = ctx.params; const { id } = ctx.params;
try { try {
// Get the token to verify ownership // First check if it's a personal token
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId); const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId);
const token = tokens.find((t) => t.id === id); let token = userTokens.find((t) => t.id === id);
if (!token) {
// Check if it's an org token and user can manage org
const { ApiToken } = await import('../../models/index.ts');
const anyToken = await ApiToken.getInstance({ id, isRevoked: false });
if (anyToken?.organizationId) {
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
anyToken.organizationId
);
if (canManage) {
token = anyToken;
}
}
}
if (!token) { if (!token) {
// Either doesn't exist or doesn't belong to user
return { status: 404, body: { error: 'Token not found' } }; return { status: 404, body: { error: 'Token not found' } };
} }

View File

@@ -143,6 +143,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*';
export interface IApiToken { export interface IApiToken {
id: string; id: string;
userId: string; userId: string;
organizationId?: string; // For org-owned tokens
createdById?: string; // Who created the token (for audit)
name: string; name: string;
tokenHash: string; tokenHash: string;
tokenPrefix: string; tokenPrefix: string;
@@ -276,6 +278,7 @@ export interface ICreateRepositoryDto {
export interface ICreateTokenDto { export interface ICreateTokenDto {
name: string; name: string;
organizationId?: string; // For org-owned tokens
protocols: TRegistryProtocol[]; protocols: TRegistryProtocol[];
scopes: ITokenScope[]; scopes: ITokenScope[];
expiresAt?: Date; expiresAt?: Date;

View File

@@ -15,6 +15,13 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
@plugins.smartdata.index() @plugins.smartdata.index()
public userId: string = ''; public userId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId?: string; // For org-owned tokens
@plugins.smartdata.svDb()
public createdById?: string; // Who created the token (for audit)
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public name: string = ''; public name: string = '';
@@ -90,6 +97,16 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
}); });
} }
/**
* Get all tokens for an organization
*/
public static async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
return await ApiToken.getInstances({
organizationId,
isRevoked: false,
});
}
/** /**
* Check if token is valid (not expired, not revoked) * Check if token is valid (not expired, not revoked)
*/ */

View File

@@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts';
export interface ICreateTokenOptions { export interface ICreateTokenOptions {
userId: string; userId: string;
organizationId?: string; // For org-owned tokens
createdById?: string; // Who created the token (defaults to userId)
name: string; name: string;
protocols: TRegistryProtocol[]; protocols: TRegistryProtocol[];
scopes: ITokenScope[]; scopes: ITokenScope[];
@@ -52,6 +54,8 @@ export class TokenService {
const token = new ApiToken(); const token = new ApiToken();
token.id = await ApiToken.getNewId(); token.id = await ApiToken.getNewId();
token.userId = options.userId; token.userId = options.userId;
token.organizationId = options.organizationId;
token.createdById = options.createdById || options.userId;
token.name = options.name; token.name = options.name;
token.tokenHash = tokenHash; token.tokenHash = tokenHash;
token.tokenPrefix = tokenPrefix; token.tokenPrefix = tokenPrefix;
@@ -150,6 +154,13 @@ export class TokenService {
return await ApiToken.getUserTokens(userId); return await ApiToken.getUserTokens(userId);
} }
/**
* Get all tokens for an organization
*/
public async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
return await ApiToken.getOrgTokens(organizationId);
}
/** /**
* Revoke a token * Revoke a token
*/ */
@@ -175,6 +186,18 @@ export class TokenService {
return tokens.length; return tokens.length;
} }
/**
* Revoke all tokens for an organization
*/
public async revokeAllOrgTokens(organizationId: string, reason?: string): Promise<number> {
const tokens = await ApiToken.getOrgTokens(organizationId);
for (const token of tokens) {
await token.revoke(reason);
await this.auditService.logTokenRevoked(token.id, token.name);
}
return tokens.length;
}
/** /**
* Check if token has permission for a specific action * Check if token has permission for a specific action
*/ */

View File

@@ -39,11 +39,21 @@ export interface IPackage {
updatedAt: string; updatedAt: string;
} }
export interface ITokenScope {
protocol: string;
organizationId?: string;
repositoryId?: string;
actions: string[];
}
export interface IToken { export interface IToken {
id: string; id: string;
name: string; name: string;
tokenPrefix: string; tokenPrefix: string;
protocols: string[]; protocols: string[];
scopes?: ITokenScope[];
organizationId?: string;
createdById?: string;
expiresAt?: string; expiresAt?: string;
lastUsedAt?: string; lastUsedAt?: string;
usageCount: number; usageCount: number;
@@ -179,14 +189,21 @@ export class ApiService {
} }
// Tokens // Tokens
getTokens(): Observable<{ tokens: IToken[] }> { getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> {
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`); let httpParams = new HttpParams();
if (organizationId) {
httpParams = httpParams.set('organizationId', organizationId);
}
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, {
params: httpParams,
});
} }
createToken(data: { createToken(data: {
name: string; name: string;
organizationId?: string;
protocols: string[]; protocols: string[];
scopes: { protocol: string; actions: string[] }[]; scopes: ITokenScope[];
expiresInDays?: number; expiresInDays?: number;
}): Observable<IToken & { token: string }> { }): Observable<IToken & { token: string }> {
return this.http.post<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 { Component, inject, signal, OnInit } from '@angular/core';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms'; 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'; import { ToastService } from '../../core/services/toast.service';
interface IScopeEntry {
protocol: string;
actions: string[];
organizationId?: string;
}
@Component({ @Component({
selector: 'app-tokens', selector: 'app-tokens',
standalone: true, standalone: true,
@@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service';
@for (token of tokens(); track token.id) { @for (token of tokens(); track token.id) {
<li class="px-6 py-4 hover:bg-muted/30 transition-colors"> <li class="px-6 py-4 hover:bg-muted/30 transition-colors">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 flex-wrap">
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3> <h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
@for (protocol of token.protocols.slice(0, 3); track protocol) { @if (token.organizationId) {
<span class="badge-accent">{{ protocol }}</span> <span class="badge-primary">Org Token</span>
} } @else {
@if (token.protocols.length > 3) { <span class="badge-default">Personal</span>
<span class="badge-default">+{{ token.protocols.length - 3 }}</span>
} }
</div> </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> <code>{{ token.tokenPrefix }}...</code>
@if (token.expiresAt) { @if (token.expiresAt) {
<span class="mx-2">·</span> <span class="mx-2">·</span>
@@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service';
· {{ token.usageCount }} uses · {{ token.usageCount }} uses
</p> </p>
</div> </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 Revoke
</button> </button>
</div> </div>
@@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Create Modal --> <!-- Create Modal -->
@if (showCreateModal()) { @if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <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-lg mx-4"> <div class="card w-full max-w-2xl mx-4">
<div class="card-header flex items-center justify-between"> <div class="card-header flex items-center justify-between">
<div class="section-header"> <div class="section-header">
<div class="section-indicator"></div> <div class="section-indicator"></div>
@@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
</svg> </svg>
</button> </button>
</div> </div>
<div class="card-content space-y-4"> <div class="card-content space-y-5">
<!-- Token Name -->
<div> <div>
<label class="label block mb-1.5">Token Name</label> <label class="label block mb-1.5">Token Name</label>
<input <input
@@ -108,27 +126,134 @@ import { ToastService } from '../../core/services/toast.service';
placeholder="my-ci-token" placeholder="my-ci-token"
/> />
</div> </div>
<!-- Token Type -->
<div> <div>
<label class="label block mb-1.5">Protocols</label> <label class="label block mb-1.5">Token Type</label>
<div class="flex flex-wrap gap-2"> <div class="flex gap-3">
@for (protocol of availableProtocols; track protocol) { <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 <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]="{ [ngClass]="{
'bg-primary/10 border-primary text-primary': newToken.protocols.includes(protocol), 'bg-primary/10 border-primary': newToken.organizationId,
'border-border text-foreground': !newToken.protocols.includes(protocol) 'border-border': !newToken.organizationId
}"> }">
<input <input
type="checkbox" type="radio"
[checked]="newToken.protocols.includes(protocol)" name="tokenType"
(change)="toggleProtocol(protocol)" [checked]="newToken.organizationId"
(change)="newToken.organizationId = organizations()[0]?.id"
class="sr-only" 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> </label>
} }
</div> </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> </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> <div>
<label class="label block mb-1.5">Expiration (optional)</label> <label class="label block mb-1.5">Expiration (optional)</label>
<select [(ngModel)]="newToken.expiresInDays" class="input"> <select [(ngModel)]="newToken.expiresInDays" class="input">
@@ -142,7 +267,10 @@ import { ToastService } from '../../core/services/toast.service';
</div> </div>
<div class="card-footer flex justify-end gap-3"> <div class="card-footer flex justify-end gap-3">
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button> <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()) { @if (creating()) {
Creating... Creating...
} @else { } @else {
@@ -196,54 +324,86 @@ export class TokensComponent implements OnInit {
private toastService = inject(ToastService); private toastService = inject(ToastService);
tokens = signal<IToken[]>([]); tokens = signal<IToken[]>([]);
organizations = signal<IOrganization[]>([]);
loading = signal(true); loading = signal(true);
showCreateModal = signal(false); showCreateModal = signal(false);
creating = signal(false); creating = signal(false);
createdToken = signal<string | null>(null); createdToken = signal<string | null>(null);
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems']; availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
availableActions = ['read', 'write', 'delete'];
newToken = { newToken: {
name: string;
organizationId?: string;
scopes: IScopeEntry[];
expiresInDays: number | null;
} = {
name: '', name: '',
protocols: [] as string[], organizationId: undefined,
expiresInDays: null as number | null, scopes: [],
expiresInDays: null,
}; };
ngOnInit(): void { ngOnInit(): void {
this.loadTokens(); this.loadData();
} }
private async loadTokens(): Promise<void> { private async loadData(): Promise<void> {
this.loading.set(true); this.loading.set(true);
try { try {
const response = await this.apiService.getTokens().toPromise(); const [tokensRes, orgsRes] = await Promise.all([
this.tokens.set(response?.tokens || []); this.apiService.getTokens().toPromise(),
this.apiService.getOrganizations().toPromise(),
]);
this.tokens.set(tokensRes?.tokens || []);
this.organizations.set(orgsRes?.organizations || []);
} catch (error) { } catch (error) {
this.toastService.error('Failed to load tokens'); this.toastService.error('Failed to load data');
} finally { } finally {
this.loading.set(false); this.loading.set(false);
} }
} }
toggleProtocol(protocol: string): void { addScope(): void {
if (this.newToken.protocols.includes(protocol)) { this.newToken.scopes = [
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol); ...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 { } 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> { 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); this.creating.set(true);
try { try {
// Build protocols array from scopes
const protocols = [...new Set(this.newToken.scopes.map((s) => s.protocol))];
const response = await this.apiService.createToken({ const response = await this.apiService.createToken({
name: this.newToken.name, name: this.newToken.name,
protocols: this.newToken.protocols, organizationId: this.newToken.organizationId,
scopes: this.newToken.protocols.map((p) => ({ protocols,
protocol: p, scopes: this.newToken.scopes.map((s) => ({
actions: ['read', 'write'], protocol: s.protocol,
actions: s.actions,
organizationId: s.organizationId,
})), })),
expiresInDays: this.newToken.expiresInDays || undefined, expiresInDays: this.newToken.expiresInDays || undefined,
}).toPromise(); }).toPromise();
@@ -252,7 +412,7 @@ export class TokensComponent implements OnInit {
this.createdToken.set(response.token); this.createdToken.set(response.token);
this.tokens.update((tokens) => [response, ...tokens]); this.tokens.update((tokens) => [response, ...tokens]);
this.showCreateModal.set(false); this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null }; this.resetNewToken();
} }
} catch (error) { } catch (error) {
this.toastService.error('Failed to create token'); this.toastService.error('Failed to create token');
@@ -275,7 +435,16 @@ export class TokensComponent implements OnInit {
closeCreateModal(): void { closeCreateModal(): void {
this.showCreateModal.set(false); 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 { copyToken(): void {
@@ -289,4 +458,9 @@ export class TokensComponent implements OnInit {
formatDate(dateStr: string): string { formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(); return new Date(dateStr).toLocaleDateString();
} }
formatActions(actions: string[]): string {
if (actions.includes('*')) return 'full';
return actions.join(', ');
}
} }