feat(tokens): Add support for organization-owned API tokens and org-level token management
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,6 +50,7 @@ coverage/
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
stories/
|
||||
|
||||
# Package manager locks (keep pnpm-lock.yaml)
|
||||
package-lock.json
|
||||
|
||||
11
changelog.md
11
changelog.md
@@ -1,5 +1,16 @@
|
||||
# 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)
|
||||
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.1.0',
|
||||
version: '1.2.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class TokenApi {
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(tokenService: TokenService) {
|
||||
constructor(tokenService: TokenService, permissionService?: PermissionService) {
|
||||
this.tokenService = tokenService;
|
||||
this.permissionService = permissionService || new PermissionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tokens
|
||||
* Query params:
|
||||
* - organizationId: list org tokens (requires org admin)
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -22,7 +27,20 @@ export class TokenApi {
|
||||
}
|
||||
|
||||
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 {
|
||||
status: 200,
|
||||
@@ -33,6 +51,8 @@ export class TokenApi {
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols,
|
||||
scopes: t.scopes,
|
||||
organizationId: t.organizationId,
|
||||
createdById: t.createdById,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
usageCount: t.usageCount,
|
||||
@@ -48,6 +68,12 @@ export class TokenApi {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -56,8 +82,9 @@ export class TokenApi {
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, protocols, scopes, expiresInDays } = body as {
|
||||
const { name, organizationId, protocols, scopes, expiresInDays } = body as {
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
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({
|
||||
userId: ctx.actor.userId,
|
||||
organizationId,
|
||||
createdById: ctx.actor.userId,
|
||||
name,
|
||||
protocols,
|
||||
scopes,
|
||||
@@ -108,6 +145,7 @@ export class TokenApi {
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols,
|
||||
scopes: result.token.scopes,
|
||||
organizationId: result.token.organizationId,
|
||||
expiresAt: result.token.expiresAt,
|
||||
createdAt: result.token.createdAt,
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
@@ -121,6 +159,7 @@ export class TokenApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tokens/:id
|
||||
* Allows revoking personal tokens or org tokens (if org admin)
|
||||
*/
|
||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -130,12 +169,27 @@ export class TokenApi {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get the token to verify ownership
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
const token = tokens.find((t) => t.id === id);
|
||||
// First check if it's a personal token
|
||||
const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
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) {
|
||||
// Either doesn't exist or doesn't belong to user
|
||||
return { status: 404, body: { error: 'Token not found' } };
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*';
|
||||
export interface IApiToken {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
createdById?: string; // Who created the token (for audit)
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
@@ -276,6 +278,7 @@ export interface ICreateRepositoryDto {
|
||||
|
||||
export interface ICreateTokenDto {
|
||||
name: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresAt?: Date;
|
||||
|
||||
@@ -15,6 +15,13 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
@plugins.smartdata.index()
|
||||
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()
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface ICreateTokenOptions {
|
||||
userId: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
createdById?: string; // Who created the token (defaults to userId)
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
@@ -52,6 +54,8 @@ export class TokenService {
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = options.userId;
|
||||
token.organizationId = options.organizationId;
|
||||
token.createdById = options.createdById || options.userId;
|
||||
token.name = options.name;
|
||||
token.tokenHash = tokenHash;
|
||||
token.tokenPrefix = tokenPrefix;
|
||||
@@ -150,6 +154,13 @@ export class TokenService {
|
||||
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
|
||||
*/
|
||||
@@ -175,6 +186,18 @@ export class TokenService {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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 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 = undefined"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span>{{ protocol }}</span>
|
||||
<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-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 = organizations()[0]?.id"
|
||||
class="sr-only"
|
||||
/>
|
||||
<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);
|
||||
} else {
|
||||
this.newToken.protocols = [...this.newToken.protocols, 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 {
|
||||
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(', ');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user