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
|
||||||
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
|
||||||
|
|||||||
11
changelog.md
11
changelog.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 }>(
|
||||||
|
|||||||
@@ -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
|
<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 = undefined"
|
||||||
class="sr-only"
|
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>
|
</label>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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,
|
||||||
} else {
|
{ protocol: '*', actions: ['read', 'write'] },
|
||||||
this.newToken.protocols = [...this.newToken.protocols, protocol];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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(', ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user