feat: implement account settings and API tokens management
- Added SettingsComponent for user profile management, including display name and password change functionality. - Introduced TokensComponent for managing API tokens, including creation and revocation. - Created LayoutComponent for consistent application layout with navigation and user information. - Established main application structure in index.html and main.ts. - Integrated Tailwind CSS for styling and responsive design. - Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
46
deno.json
Normal file
46
deno.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.0.0",
|
||||
"exports": "./mod.ts",
|
||||
"tasks": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"test": "deno test --allow-all",
|
||||
"build": "cd ui && pnpm run build"
|
||||
},
|
||||
"imports": {
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^5.0.0",
|
||||
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.0.0",
|
||||
"@push.rocks/smartenv": "npm:@push.rocks/smartenv@^5.0.0",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^5.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.0.0",
|
||||
"@push.rocks/smartstring": "npm:@push.rocks/smartstring@^4.0.0",
|
||||
"@push.rocks/smartcrypto": "npm:@push.rocks/smartcrypto@^2.0.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.0.0",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.0",
|
||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.0",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.0",
|
||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.0",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.0.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||
"@std/http": "jsr:@std/http@^1.0.0"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"lib": ["deno.window", "dom"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "npm:react"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": ["no-explicit-any"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"singleQuote": true,
|
||||
"lineWidth": 100
|
||||
}
|
||||
}
|
||||
11
mod.ts
Normal file
11
mod.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env -S deno run --allow-all
|
||||
|
||||
/**
|
||||
* Stack.Gallery Registry
|
||||
* Enterprise-grade multi-protocol package registry
|
||||
*/
|
||||
|
||||
import { runCli } from './ts/cli.ts';
|
||||
|
||||
// Run CLI
|
||||
await runCli();
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"build": "cd ui && pnpm run build",
|
||||
"test": "deno test --allow-all"
|
||||
},
|
||||
"keywords": [
|
||||
"registry",
|
||||
"npm",
|
||||
"docker",
|
||||
"oci",
|
||||
"maven",
|
||||
"cargo",
|
||||
"pypi",
|
||||
"rubygems",
|
||||
"composer"
|
||||
],
|
||||
"author": "Stack.Gallery",
|
||||
"license": "MIT"
|
||||
}
|
||||
109
ts/api/handlers/audit.api.ts
Normal file
109
ts/api/handlers/audit.api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Audit API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditLog } from '../../models/auditlog.ts';
|
||||
import type { TAuditAction, TAuditResourceType } from '../../interfaces/audit.interfaces.ts';
|
||||
|
||||
export class AuditApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit
|
||||
*/
|
||||
public async query(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse query parameters
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
|
||||
const actionsParam = ctx.url.searchParams.get('actions');
|
||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||
const success = ctx.url.searchParams.has('success')
|
||||
? ctx.url.searchParams.get('success') === 'true'
|
||||
: undefined;
|
||||
const startDateParam = ctx.url.searchParams.get('startDate');
|
||||
const endDateParam = ctx.url.searchParams.get('endDate');
|
||||
const startDate = startDateParam ? new Date(startDateParam) : undefined;
|
||||
const endDate = endDateParam ? new Date(endDateParam) : undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '100', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// Check permissions
|
||||
// Users can view audit logs for:
|
||||
// 1. Their own actions (actorId = userId)
|
||||
// 2. Organizations they manage
|
||||
// 3. System admins can view all
|
||||
|
||||
let actorId: string | undefined;
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
// System admins can see all
|
||||
actorId = ctx.url.searchParams.get('actorId') || undefined;
|
||||
} else if (organizationId) {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
} else {
|
||||
// Non-admins without org filter can only see their own actions
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
|
||||
const result = await AuditLog.query({
|
||||
actorId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
resourceType,
|
||||
action: actions,
|
||||
success,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
logs: result.logs.map((log) => ({
|
||||
id: log.id,
|
||||
actorId: log.actorId,
|
||||
actorType: log.actorType,
|
||||
action: log.action,
|
||||
resourceType: log.resourceType,
|
||||
resourceId: log.resourceId,
|
||||
resourceName: log.resourceName,
|
||||
organizationId: log.organizationId,
|
||||
repositoryId: log.repositoryId,
|
||||
success: log.success,
|
||||
errorCode: log.errorCode,
|
||||
timestamp: log.timestamp,
|
||||
metadata: log.metadata,
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuditApi] Query error:', error);
|
||||
return { status: 500, body: { error: 'Failed to query audit logs' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
184
ts/api/handlers/auth.api.ts
Normal file
184
ts/api/handlers/auth.api.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Auth API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
|
||||
export class AuthApi {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor(authService: AuthService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public async login(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.login(email, password, {
|
||||
userAgent: ctx.userAgent,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: result.user!.id,
|
||||
email: result.user!.email,
|
||||
username: result.user!.username,
|
||||
displayName: result.user!.displayName,
|
||||
isSystemAdmin: result.user!.isSystemAdmin,
|
||||
},
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionId: result.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Login error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh
|
||||
*/
|
||||
public async refresh(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { refreshToken } = body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Refresh token is required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.refresh(refreshToken);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Refresh error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Token refresh failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
*/
|
||||
public async logout(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json().catch(() => ({}));
|
||||
const { sessionId, all } = body;
|
||||
|
||||
if (all) {
|
||||
const count = await this.authService.logoutAll(ctx.actor.userId, {
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: `Logged out from ${count} sessions` },
|
||||
};
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
await this.authService.logout(sessionId, {
|
||||
userId: ctx.actor.userId,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Logged out successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Logout error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Logout failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/me
|
||||
*/
|
||||
public async me(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId || !ctx.actor.user) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
const user = ctx.actor.user;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
11
ts/api/handlers/index.ts
Normal file
11
ts/api/handlers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API handler exports
|
||||
*/
|
||||
|
||||
export { AuthApi } from './auth.api.ts';
|
||||
export { UserApi } from './user.api.ts';
|
||||
export { OrganizationApi } from './organization.api.ts';
|
||||
export { RepositoryApi } from './repository.api.ts';
|
||||
export { PackageApi } from './package.api.ts';
|
||||
export { TokenApi } from './token.api.ts';
|
||||
export { AuditApi } from './audit.api.ts';
|
||||
494
ts/api/handlers/organization.api.ts
Normal file
494
ts/api/handlers/organization.api.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Organization API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||||
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class OrganizationApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// System admins see all orgs, others see only their orgs
|
||||
let organizations: Organization[];
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
organizations: organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
createdAt: org.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list organizations' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
|
||||
usedStorageBytes: org.usedStorageBytes,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, isPublic } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if name is taken
|
||||
const existing = await Organization.findByName(name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Organization name already taken' } };
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const org = new Organization();
|
||||
org.id = await Organization.getNewId();
|
||||
org.name = name;
|
||||
org.displayName = displayName || name;
|
||||
org.description = description;
|
||||
org.isPublic = isPublic ?? false;
|
||||
org.memberCount = 1;
|
||||
org.createdAt = new Date();
|
||||
org.createdById = ctx.actor.userId;
|
||||
|
||||
await org.save();
|
||||
|
||||
// Add creator as owner
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = ctx.actor.userId;
|
||||
membership.role = 'owner';
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).logOrganizationCreated(org.id, org.name);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
isPublic: org.isPublic,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) org.displayName = displayName;
|
||||
if (description !== undefined) org.description = description;
|
||||
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
|
||||
if (website !== undefined) org.website = website;
|
||||
if (isPublic !== undefined) org.isPublic = isPublic;
|
||||
|
||||
// Only system admins can change settings
|
||||
if (settings && ctx.actor.user?.isSystemAdmin) {
|
||||
org.settings = { ...org.settings, ...settings };
|
||||
}
|
||||
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Organization deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const user = await User.findById(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
addedAt: m.addedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { members: membersWithUsers },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List members error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list members' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
if (!userId || !role) {
|
||||
return { status: 400, body: { error: 'userId and role are required' } };
|
||||
}
|
||||
|
||||
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Invalid role' } };
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
addedAt: membership.addedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Add member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to add member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
membership.role = role;
|
||||
await membership.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Member removed successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Remove member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to remove member' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
321
ts/api/handlers/package.api.ts
Normal file
321
ts/api/handlers/package.api.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Package API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { Package, Repository } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class PackageApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages (search)
|
||||
*/
|
||||
public async search(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const query = ctx.url.searchParams.get('q') || '';
|
||||
const protocol = ctx.url.searchParams.get('protocol') as TRegistryProtocol | undefined;
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// For authenticated users, search includes private packages they have access to
|
||||
// For anonymous users, only search public packages
|
||||
const isPrivate = ctx.actor?.userId ? undefined : false;
|
||||
|
||||
const packages = await Package.search(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// Filter out packages user doesn't have access to
|
||||
const accessiblePackages = [];
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPrivate) {
|
||||
accessiblePackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ctx.actor?.userId) {
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packages: accessiblePackages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
updatedAt: pkg.updatedAt,
|
||||
})),
|
||||
total: accessiblePackages.length,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Search error:', error);
|
||||
return { status: 500, body: { error: 'Failed to search packages' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
distTags: pkg.distTags,
|
||||
versions: Object.keys(pkg.versions),
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
starCount: pkg.starCount,
|
||||
storageBytes: pkg.storageBytes,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id/versions
|
||||
*/
|
||||
public async listVersions(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
const versions = Object.entries(pkg.versions).map(([version, data]) => ({
|
||||
version,
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
distTags: pkg.distTags,
|
||||
versions,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] List versions error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list versions' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Delete the package
|
||||
await pkg.delete();
|
||||
|
||||
// Update repository package count
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||
repo.storageBytes -= pkg.storageBytes;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Package deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id/versions/:version
|
||||
*/
|
||||
public async deleteVersion(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, version } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
const versionData = pkg.versions[version];
|
||||
if (!versionData) {
|
||||
return { status: 404, body: { error: 'Version not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Check if this is the only version
|
||||
if (Object.keys(pkg.versions).length === 1) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete the only version. Delete the entire package instead.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Remove version
|
||||
const sizeReduction = versionData.size;
|
||||
delete pkg.versions[version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
|
||||
if (tagVersion === version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// Set new latest if needed
|
||||
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
||||
const versions = Object.keys(pkg.versions).sort();
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update repository storage
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.storageBytes -= sizeReduction;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Version deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete version error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete version' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
293
ts/api/handlers/repository.api.ts
Normal file
293
ts/api/handlers/repository.api.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Repository API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Repository, Organization } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get accessible repositories
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
createdAt: repo.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list repositories' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/repositories/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!repo.isPublic && ctx.actor?.userId) {
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: ctx.actor.userId,
|
||||
organizationId: repo.organizationId,
|
||||
repositoryId: repo.id,
|
||||
});
|
||||
|
||||
if (!permissions.canRead) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, orgId);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Repository name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check org exists
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check if name is taken in this org
|
||||
const existing = await Repository.findByName(orgId, name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = orgId;
|
||||
repo.name = name;
|
||||
repo.displayName = displayName || name;
|
||||
repo.description = description;
|
||||
repo.protocols = protocols || ['npm'];
|
||||
repo.isPublic = isPublic ?? false;
|
||||
repo.settings = settings || {
|
||||
allowOverwrite: false,
|
||||
immutableTags: false,
|
||||
retentionDays: 0,
|
||||
};
|
||||
repo.createdAt = new Date();
|
||||
repo.createdById = ctx.actor.userId;
|
||||
|
||||
await repo.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
organizationId: orgId,
|
||||
}).logRepositoryCreated(repo.id, repo.name, orgId);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/repositories/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) repo.displayName = displayName;
|
||||
if (description !== undefined) repo.description = description;
|
||||
if (protocols !== undefined) repo.protocols = protocols;
|
||||
if (isPublic !== undefined) repo.isPublic = isPublic;
|
||||
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
|
||||
|
||||
await repo.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/repositories/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
// Check for packages
|
||||
if (repo.packageCount > 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete repository with packages. Remove all packages first.' },
|
||||
};
|
||||
}
|
||||
|
||||
await repo.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Repository deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete repository' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
157
ts/api/handlers/token.api.ts
Normal file
157
ts/api/handlers/token.api.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Token API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class TokenApi {
|
||||
private tokenService: TokenService;
|
||||
|
||||
constructor(tokenService: TokenService) {
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tokens
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
tokens: tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols,
|
||||
scopes: t.scopes,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
usageCount: t.usageCount,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list tokens' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/tokens
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, protocols, scopes, expiresInDays } = body as {
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Token name is required' } };
|
||||
}
|
||||
|
||||
if (!protocols || protocols.length === 0) {
|
||||
return { status: 400, body: { error: 'At least one protocol is required' } };
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return { status: 400, body: { error: 'At least one scope is required' } };
|
||||
}
|
||||
|
||||
// Validate protocols
|
||||
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
|
||||
for (const protocol of protocols) {
|
||||
if (!validProtocols.includes(protocol)) {
|
||||
return { status: 400, body: { error: `Invalid protocol: ${protocol}` } };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
for (const scope of scopes) {
|
||||
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||
return { status: 400, body: { error: 'Invalid scope configuration' } };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.tokenService.createToken({
|
||||
userId: ctx.actor.userId,
|
||||
name,
|
||||
protocols,
|
||||
scopes,
|
||||
expiresInDays,
|
||||
createdIp: ctx.ip,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: result.token.id,
|
||||
name: result.token.name,
|
||||
token: result.rawToken, // Only returned once!
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols,
|
||||
scopes: result.token.scopes,
|
||||
expiresAt: result.token.expiresAt,
|
||||
createdAt: result.token.createdAt,
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create token' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tokens/:id
|
||||
*/
|
||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!token) {
|
||||
// Either doesn't exist or doesn't belong to user
|
||||
return { status: 404, body: { error: 'Token not found' } };
|
||||
}
|
||||
|
||||
const success = await this.tokenService.revokeToken(id, 'user_revoked');
|
||||
|
||||
if (!success) {
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Token revoked successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Revoke error:', error);
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
260
ts/api/handlers/user.api.ts
Normal file
260
ts/api/handlers/user.api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* User API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
|
||||
export class UserApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can list all users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await User.getInstances({});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isSystemAdmin: u.isSystemAdmin,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list users' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can view their own profile, admins can view any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can create users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, username, password, displayName, isSystemAdmin } = body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email, username, and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await User.findByEmail(email);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Email already in use' } };
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await User.findByUsername(username);
|
||||
if (existingUsername) {
|
||||
return { status: 409, body: { error: 'Username already in use' } };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await AuthService.hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = email;
|
||||
user.username = username;
|
||||
user.passwordHash = passwordHash;
|
||||
user.displayName = displayName || username;
|
||||
user.isSystemAdmin = isSystemAdmin || false;
|
||||
user.isActive = true;
|
||||
user.createdAt = new Date();
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/users/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can update their own profile, admins can update any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, avatarUrl, password, isActive, isSystemAdmin } = body;
|
||||
|
||||
if (displayName !== undefined) user.displayName = displayName;
|
||||
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||
|
||||
// Only admins can change these
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.isActive = isActive;
|
||||
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (password) {
|
||||
user.passwordHash = await AuthService.hashPassword(password);
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can delete users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Cannot delete yourself
|
||||
if (id === ctx.actor.userId) {
|
||||
return { status: 400, body: { error: 'Cannot delete your own account' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.isActive = false;
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'User deactivated successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete user' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ts/api/index.ts
Normal file
6
ts/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* API exports
|
||||
*/
|
||||
|
||||
export { ApiRouter, type IApiContext, type IApiResponse } from './router.ts';
|
||||
export * from './handlers/index.ts';
|
||||
277
ts/api/router.ts
Normal file
277
ts/api/router.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* API Router - Routes REST API requests to appropriate handlers
|
||||
*/
|
||||
|
||||
import type { IStackGalleryActor } from '../providers/auth.provider.ts';
|
||||
import { AuthService } from '../services/auth.service.ts';
|
||||
import { TokenService } from '../services/token.service.ts';
|
||||
import { PermissionService } from '../services/permission.service.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
// Import API handlers
|
||||
import { AuthApi } from './handlers/auth.api.ts';
|
||||
import { UserApi } from './handlers/user.api.ts';
|
||||
import { OrganizationApi } from './handlers/organization.api.ts';
|
||||
import { RepositoryApi } from './handlers/repository.api.ts';
|
||||
import { PackageApi } from './handlers/package.api.ts';
|
||||
import { TokenApi } from './handlers/token.api.ts';
|
||||
import { AuditApi } from './handlers/audit.api.ts';
|
||||
|
||||
export interface IApiContext {
|
||||
request: Request;
|
||||
url: URL;
|
||||
path: string;
|
||||
method: string;
|
||||
params: Record<string, string>;
|
||||
actor?: IStackGalleryActor;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface IApiResponse {
|
||||
status: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: IApiContext) => Promise<IApiResponse>;
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
export class ApiRouter {
|
||||
private routes: IRoute[] = [];
|
||||
private authService: AuthService;
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
// API handlers
|
||||
private authApi: AuthApi;
|
||||
private userApi: UserApi;
|
||||
private organizationApi: OrganizationApi;
|
||||
private repositoryApi: RepositoryApi;
|
||||
private packageApi: PackageApi;
|
||||
private tokenApi: TokenApi;
|
||||
private auditApi: AuditApi;
|
||||
|
||||
constructor() {
|
||||
this.authService = new AuthService();
|
||||
this.tokenService = new TokenService();
|
||||
this.permissionService = new PermissionService();
|
||||
|
||||
// Initialize API handlers
|
||||
this.authApi = new AuthApi(this.authService);
|
||||
this.userApi = new UserApi(this.permissionService);
|
||||
this.organizationApi = new OrganizationApi(this.permissionService);
|
||||
this.repositoryApi = new RepositoryApi(this.permissionService);
|
||||
this.packageApi = new PackageApi(this.permissionService);
|
||||
this.tokenApi = new TokenApi(this.tokenService);
|
||||
this.auditApi = new AuditApi(this.permissionService);
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes
|
||||
*/
|
||||
private registerRoutes(): void {
|
||||
// Auth routes
|
||||
this.addRoute('POST', '/api/v1/auth/login', (ctx) => this.authApi.login(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/refresh', (ctx) => this.authApi.refresh(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/logout', (ctx) => this.authApi.logout(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/me', (ctx) => this.authApi.me(ctx));
|
||||
|
||||
// User routes
|
||||
this.addRoute('GET', '/api/v1/users', (ctx) => this.userApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/users/:id', (ctx) => this.userApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/users', (ctx) => this.userApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/users/:id', (ctx) => this.userApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/users/:id', (ctx) => this.userApi.delete(ctx));
|
||||
|
||||
// Organization routes
|
||||
this.addRoute('GET', '/api/v1/organizations', (ctx) => this.organizationApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id', (ctx) => this.organizationApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
|
||||
|
||||
// Repository routes
|
||||
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
||||
|
||||
// Package routes
|
||||
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
|
||||
|
||||
// Token routes
|
||||
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
|
||||
this.addRoute('POST', '/api/v1/tokens', (ctx) => this.tokenApi.create(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/tokens/:id', (ctx) => this.tokenApi.revoke(ctx));
|
||||
|
||||
// Audit routes
|
||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route
|
||||
*/
|
||||
private addRoute(method: string, path: string, handler: RouteHandler): void {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path.replace(/:(\w+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
const pattern = new RegExp(`^${patternStr}$`);
|
||||
|
||||
this.routes.push({ method, pattern, paramNames, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an API request
|
||||
*/
|
||||
public async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract params
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = match[i + 1];
|
||||
});
|
||||
|
||||
// Build context
|
||||
const ctx: IApiContext = {
|
||||
request,
|
||||
url,
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
};
|
||||
|
||||
// Authenticate request (except for login)
|
||||
if (!path.includes('/auth/login')) {
|
||||
ctx.actor = await this.authenticateRequest(request);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await route.handler(ctx);
|
||||
return this.buildResponse(result);
|
||||
} catch (error) {
|
||||
console.error('[ApiRouter] Handler error:', error);
|
||||
return this.buildResponse({
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildResponse({
|
||||
status: 404,
|
||||
body: { error: 'Not found' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate request from headers
|
||||
*/
|
||||
private async authenticateRequest(request: Request): Promise<IStackGalleryActor | undefined> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
// Try Bearer token (JWT)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Check if it's a JWT (for UI) or API token
|
||||
if (token.startsWith('srg_')) {
|
||||
// API token
|
||||
const result = await this.tokenService.validateToken(token, this.getClientIp(request));
|
||||
if (result.valid && result.token && result.user) {
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// JWT token
|
||||
const result = await this.authService.validateAccessToken(token);
|
||||
if (result) {
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request
|
||||
*/
|
||||
private getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP response from API response
|
||||
*/
|
||||
private buildResponse(result: IApiResponse): Response {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...result.headers,
|
||||
};
|
||||
|
||||
return new Response(result.body ? JSON.stringify(result.body) : null, {
|
||||
status: result.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
108
ts/cli.ts
Normal file
108
ts/cli.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* CLI entry point for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { StackGalleryRegistry, createRegistryFromEnv } from './registry.ts';
|
||||
import { initDb } from './models/db.ts';
|
||||
import { User, Organization, OrganizationMember, Repository } from './models/index.ts';
|
||||
import { AuthService } from './services/auth.service.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
const smartcliInstance = new plugins.smartcli.Smartcli();
|
||||
|
||||
// Server command
|
||||
smartcliInstance.addCommand('server').subscribe(async (argsParsed) => {
|
||||
console.log('Starting Stack.Gallery Registry...');
|
||||
|
||||
const registry = createRegistryFromEnv();
|
||||
await registry.start();
|
||||
|
||||
// Handle shutdown gracefully
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await registry.stop();
|
||||
Deno.exit(0);
|
||||
};
|
||||
|
||||
Deno.addSignalListener('SIGINT', shutdown);
|
||||
Deno.addSignalListener('SIGTERM', shutdown);
|
||||
});
|
||||
|
||||
// Status command
|
||||
smartcliInstance.addCommand('status').subscribe(async () => {
|
||||
console.log('Stack.Gallery Registry Status');
|
||||
console.log('=============================');
|
||||
// TODO: Implement status check
|
||||
console.log('Status check not yet implemented');
|
||||
});
|
||||
|
||||
// User commands
|
||||
smartcliInstance.addCommand('user').subscribe(async (argsParsed) => {
|
||||
const subCommand = argsParsed.commandArgs[0];
|
||||
|
||||
switch (subCommand) {
|
||||
case 'create':
|
||||
console.log('Creating user...');
|
||||
// TODO: Implement user creation
|
||||
break;
|
||||
case 'list':
|
||||
console.log('Listing users...');
|
||||
// TODO: Implement user listing
|
||||
break;
|
||||
default:
|
||||
console.log('Usage: user [create|list]');
|
||||
}
|
||||
});
|
||||
|
||||
// Organization commands
|
||||
smartcliInstance.addCommand('org').subscribe(async (argsParsed) => {
|
||||
const subCommand = argsParsed.commandArgs[0];
|
||||
|
||||
switch (subCommand) {
|
||||
case 'create':
|
||||
console.log('Creating organization...');
|
||||
// TODO: Implement org creation
|
||||
break;
|
||||
case 'list':
|
||||
console.log('Listing organizations...');
|
||||
// TODO: Implement org listing
|
||||
break;
|
||||
default:
|
||||
console.log('Usage: org [create|list]');
|
||||
}
|
||||
});
|
||||
|
||||
// Default/help command
|
||||
smartcliInstance.addCommand('help').subscribe(() => {
|
||||
console.log(`
|
||||
Stack.Gallery Registry - Enterprise Package Registry
|
||||
|
||||
Usage:
|
||||
registry <command> [options]
|
||||
|
||||
Commands:
|
||||
server [--ephemeral] [--monitor] Start the registry server
|
||||
status Check registry status
|
||||
user <subcommand> User management
|
||||
org <subcommand> Organization management
|
||||
help Show this help message
|
||||
|
||||
Options:
|
||||
--ephemeral Run in ephemeral mode (in-memory database)
|
||||
--monitor Enable performance monitoring
|
||||
|
||||
Environment Variables:
|
||||
MONGODB_URL MongoDB connection string
|
||||
S3_ENDPOINT S3-compatible storage endpoint
|
||||
S3_ACCESS_KEY S3 access key
|
||||
S3_SECRET_KEY S3 secret key
|
||||
S3_BUCKET S3 bucket name
|
||||
JWT_SECRET JWT signing secret
|
||||
PORT HTTP server port (default: 3000)
|
||||
`);
|
||||
});
|
||||
|
||||
// Parse CLI arguments
|
||||
smartcliInstance.startParse();
|
||||
}
|
||||
19
ts/index.ts
Normal file
19
ts/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Stack.Gallery Registry
|
||||
* Enterprise-grade multi-protocol package registry
|
||||
*/
|
||||
|
||||
// Export interfaces
|
||||
export * from './interfaces/index.ts';
|
||||
|
||||
// Export models
|
||||
export * from './models/index.ts';
|
||||
|
||||
// Export services
|
||||
export * from './services/index.ts';
|
||||
|
||||
// Export providers
|
||||
export * from './providers/index.ts';
|
||||
|
||||
// Export main registry class
|
||||
export { StackGalleryRegistry } from './registry.ts';
|
||||
152
ts/interfaces/audit.interfaces.ts
Normal file
152
ts/interfaces/audit.interfaces.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Audit logging interfaces
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Audit Action Types
|
||||
// =============================================================================
|
||||
|
||||
export type TAuditAction =
|
||||
// Authentication
|
||||
| 'AUTH_LOGIN'
|
||||
| 'AUTH_LOGOUT'
|
||||
| 'AUTH_FAILED'
|
||||
| 'AUTH_MFA_ENABLED'
|
||||
| 'AUTH_MFA_DISABLED'
|
||||
| 'AUTH_PASSWORD_CHANGED'
|
||||
| 'AUTH_PASSWORD_RESET'
|
||||
// API Tokens
|
||||
| 'TOKEN_CREATED'
|
||||
| 'TOKEN_USED'
|
||||
| 'TOKEN_REVOKED'
|
||||
| 'TOKEN_EXPIRED'
|
||||
// User Management
|
||||
| 'USER_CREATED'
|
||||
| 'USER_UPDATED'
|
||||
| 'USER_DELETED'
|
||||
| 'USER_SUSPENDED'
|
||||
| 'USER_ACTIVATED'
|
||||
// Organization Management
|
||||
| 'ORG_CREATED'
|
||||
| 'ORG_UPDATED'
|
||||
| 'ORG_DELETED'
|
||||
| 'ORG_MEMBER_ADDED'
|
||||
| 'ORG_MEMBER_REMOVED'
|
||||
| 'ORG_MEMBER_ROLE_CHANGED'
|
||||
// Team Management
|
||||
| 'TEAM_CREATED'
|
||||
| 'TEAM_UPDATED'
|
||||
| 'TEAM_DELETED'
|
||||
| 'TEAM_MEMBER_ADDED'
|
||||
| 'TEAM_MEMBER_REMOVED'
|
||||
// Repository Management
|
||||
| 'REPO_CREATED'
|
||||
| 'REPO_UPDATED'
|
||||
| 'REPO_DELETED'
|
||||
| 'REPO_VISIBILITY_CHANGED'
|
||||
| 'REPO_PERMISSION_GRANTED'
|
||||
| 'REPO_PERMISSION_REVOKED'
|
||||
// Package Operations
|
||||
| 'PACKAGE_PUSHED'
|
||||
| 'PACKAGE_PULLED'
|
||||
| 'PACKAGE_DELETED'
|
||||
| 'PACKAGE_DEPRECATED'
|
||||
// Security Events
|
||||
| 'SECURITY_SCAN_COMPLETED'
|
||||
| 'SECURITY_VULNERABILITY_FOUND'
|
||||
| 'SECURITY_IP_BLOCKED'
|
||||
| 'SECURITY_RATE_LIMITED';
|
||||
|
||||
export type TAuditResourceType =
|
||||
| 'user'
|
||||
| 'organization'
|
||||
| 'team'
|
||||
| 'repository'
|
||||
| 'package'
|
||||
| 'api_token'
|
||||
| 'session'
|
||||
| 'system';
|
||||
|
||||
// =============================================================================
|
||||
// Audit Log Entry
|
||||
// =============================================================================
|
||||
|
||||
export interface IAuditLog {
|
||||
id: string;
|
||||
actorId?: string;
|
||||
actorType: 'user' | 'api_token' | 'system' | 'anonymous';
|
||||
actorTokenId?: string;
|
||||
actorIp?: string;
|
||||
actorUserAgent?: string;
|
||||
action: TAuditAction;
|
||||
resourceType: TAuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
success: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Query Types
|
||||
// =============================================================================
|
||||
|
||||
export interface IAuditQuery {
|
||||
actorId?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
resourceType?: TAuditResourceType;
|
||||
action?: TAuditAction[];
|
||||
success?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IAuditQueryResult {
|
||||
logs: IAuditLog[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Event (for logging)
|
||||
// =============================================================================
|
||||
|
||||
export interface IAuditEvent {
|
||||
actorId?: string;
|
||||
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
|
||||
actorTokenId?: string;
|
||||
actorIp?: string;
|
||||
actorUserAgent?: string;
|
||||
action: TAuditAction;
|
||||
resourceType: TAuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Token Activity
|
||||
// =============================================================================
|
||||
|
||||
export interface ITokenActivitySummary {
|
||||
tokenId: string;
|
||||
totalActions: number;
|
||||
lastUsed?: Date;
|
||||
actionBreakdown: Record<string, number>;
|
||||
ipAddresses: string[];
|
||||
}
|
||||
282
ts/interfaces/auth.interfaces.ts
Normal file
282
ts/interfaces/auth.interfaces.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Authentication and authorization interfaces
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// User Types
|
||||
// =============================================================================
|
||||
|
||||
export type TUserStatus = 'active' | 'suspended' | 'pending_verification';
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
status: TUserStatus;
|
||||
emailVerified: boolean;
|
||||
mfaEnabled: boolean;
|
||||
mfaSecret?: string;
|
||||
lastLoginAt?: Date;
|
||||
lastLoginIp?: string;
|
||||
failedLoginAttempts: number;
|
||||
lockedUntil?: Date;
|
||||
isPlatformAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Types
|
||||
// =============================================================================
|
||||
|
||||
export type TOrganizationPlan = 'free' | 'team' | 'enterprise';
|
||||
export type TOrganizationRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface IOrganizationSettings {
|
||||
requireMfa: boolean;
|
||||
allowPublicRepositories: boolean;
|
||||
defaultRepositoryVisibility: TRepositoryVisibility;
|
||||
allowedProtocols: TRegistryProtocol[];
|
||||
}
|
||||
|
||||
export interface IOrganization {
|
||||
id: string;
|
||||
name: string; // URL-safe slug
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
plan: TOrganizationPlan;
|
||||
settings: IOrganizationSettings;
|
||||
billingEmail?: string;
|
||||
isVerified: boolean;
|
||||
verifiedDomains: string[];
|
||||
storageQuotaBytes: number;
|
||||
usedStorageBytes: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationMember {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
role: TOrganizationRole;
|
||||
invitedBy?: string;
|
||||
joinedAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Team Types
|
||||
// =============================================================================
|
||||
|
||||
export type TTeamRole = 'maintainer' | 'member';
|
||||
|
||||
export interface ITeam {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefaultTeam: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ITeamMember {
|
||||
id: string;
|
||||
teamId: string;
|
||||
userId: string;
|
||||
role: TTeamRole;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Repository Types
|
||||
// =============================================================================
|
||||
|
||||
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
|
||||
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
|
||||
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
|
||||
|
||||
export interface IRepository {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: TRegistryProtocol;
|
||||
visibility: TRepositoryVisibility;
|
||||
storageNamespace: string;
|
||||
downloadCount: number;
|
||||
starCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export interface IRepositoryPermission {
|
||||
id: string;
|
||||
repositoryId: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
role: TRepositoryRole;
|
||||
createdAt: Date;
|
||||
grantedById: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Token Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ITokenScope {
|
||||
protocol: TRegistryProtocol | '*';
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
actions: TTokenAction[];
|
||||
}
|
||||
|
||||
export type TTokenAction = 'read' | 'write' | 'delete' | '*';
|
||||
|
||||
export interface IApiToken {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresAt?: Date;
|
||||
lastUsedAt?: Date;
|
||||
lastUsedIp?: string;
|
||||
usageCount: number;
|
||||
isRevoked: boolean;
|
||||
revokedAt?: Date;
|
||||
revokedReason?: string;
|
||||
createdAt: Date;
|
||||
createdIp?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ISession {
|
||||
id: string;
|
||||
userId: string;
|
||||
userAgent: string;
|
||||
ipAddress: string;
|
||||
isValid: boolean;
|
||||
invalidatedAt?: Date;
|
||||
invalidatedReason?: string;
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JWT Types
|
||||
// =============================================================================
|
||||
|
||||
export interface IJwtPayload {
|
||||
sub: string; // User ID
|
||||
iss: string; // Issuer
|
||||
aud: string; // Audience
|
||||
exp: number; // Expiration
|
||||
iat: number; // Issued at
|
||||
nbf: number; // Not before
|
||||
type: 'access' | 'refresh';
|
||||
email: string;
|
||||
username: string;
|
||||
orgs: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: TOrganizationRole;
|
||||
}>;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Results
|
||||
// =============================================================================
|
||||
|
||||
export interface IAuthResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
export interface IValidatedToken {
|
||||
tokenId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
}
|
||||
|
||||
export interface IAuthorizationResult {
|
||||
authorized: boolean;
|
||||
reason?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Permission Types
|
||||
// =============================================================================
|
||||
|
||||
export type TPermissionAction =
|
||||
| 'repo:read'
|
||||
| 'repo:write'
|
||||
| 'repo:delete'
|
||||
| 'repo:admin'
|
||||
| 'team:read'
|
||||
| 'team:write'
|
||||
| 'team:admin'
|
||||
| 'org:read'
|
||||
| 'org:write'
|
||||
| 'org:admin'
|
||||
| 'token:create'
|
||||
| 'token:revoke';
|
||||
|
||||
export interface IResource {
|
||||
type: 'repository' | 'organization' | 'team' | 'user';
|
||||
id: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Create/Update DTOs
|
||||
// =============================================================================
|
||||
|
||||
export interface ICreateUserDto {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface ICreateOrganizationDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ICreateTeamDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ICreateRepositoryDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
}
|
||||
|
||||
export interface ICreateTokenDto {
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
7
ts/interfaces/index.ts
Normal file
7
ts/interfaces/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Type definitions for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
export * from './auth.interfaces.ts';
|
||||
export * from './package.interfaces.ts';
|
||||
export * from './audit.interfaces.ts';
|
||||
202
ts/interfaces/package.interfaces.ts
Normal file
202
ts/interfaces/package.interfaces.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Package and artifact interfaces
|
||||
*/
|
||||
|
||||
import type { TRegistryProtocol } from './auth.interfaces.ts';
|
||||
|
||||
// =============================================================================
|
||||
// Package Types
|
||||
// =============================================================================
|
||||
|
||||
export interface IPackage {
|
||||
id: string; // {protocol}:{org}:{name}
|
||||
organizationId: string;
|
||||
repositoryId: string;
|
||||
protocol: TRegistryProtocol;
|
||||
name: string;
|
||||
description?: string;
|
||||
versions: Record<string, IPackageVersion>;
|
||||
distTags: Record<string, string>; // npm dist-tags, e.g., { latest: "1.0.0" }
|
||||
metadata: IProtocolMetadata;
|
||||
isPrivate: boolean;
|
||||
storageBytes: number;
|
||||
downloadCount: number;
|
||||
starCount: number;
|
||||
cacheExpiresAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export interface IPackageVersion {
|
||||
version: string;
|
||||
digest?: string; // Content-addressable digest (sha256:...)
|
||||
size: number;
|
||||
publishedAt: Date;
|
||||
publishedById: string;
|
||||
deprecated?: boolean;
|
||||
deprecationMessage?: string;
|
||||
downloads: number;
|
||||
metadata: IVersionMetadata;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocol-Specific Metadata
|
||||
// =============================================================================
|
||||
|
||||
export type IProtocolMetadata =
|
||||
| INpmMetadata
|
||||
| IOciMetadata
|
||||
| IMavenMetadata
|
||||
| ICargoMetadata
|
||||
| IComposerMetadata
|
||||
| IPypiMetadata
|
||||
| IRubygemsMetadata;
|
||||
|
||||
export interface INpmMetadata {
|
||||
type: 'npm';
|
||||
scope?: string;
|
||||
keywords?: string[];
|
||||
license?: string;
|
||||
repository?: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
homepage?: string;
|
||||
bugs?: string;
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
maintainers?: Array<{ name: string; email?: string }>;
|
||||
}
|
||||
|
||||
export interface IOciMetadata {
|
||||
type: 'oci';
|
||||
mediaType: string;
|
||||
tags: string[];
|
||||
architecture?: string;
|
||||
os?: string;
|
||||
annotations?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IMavenMetadata {
|
||||
type: 'maven';
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
packaging: string;
|
||||
classifier?: string;
|
||||
parent?: {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICargoMetadata {
|
||||
type: 'cargo';
|
||||
features: Record<string, string[]>;
|
||||
dependencies: Array<{
|
||||
name: string;
|
||||
req: string;
|
||||
features: string[];
|
||||
optional: boolean;
|
||||
defaultFeatures: boolean;
|
||||
target?: string;
|
||||
kind: 'normal' | 'dev' | 'build';
|
||||
}>;
|
||||
keywords?: string[];
|
||||
categories?: string[];
|
||||
license?: string;
|
||||
links?: string;
|
||||
}
|
||||
|
||||
export interface IComposerMetadata {
|
||||
type: 'composer';
|
||||
vendor: string;
|
||||
packageType?: string;
|
||||
license?: string | string[];
|
||||
require?: Record<string, string>;
|
||||
requireDev?: Record<string, string>;
|
||||
autoload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IPypiMetadata {
|
||||
type: 'pypi';
|
||||
classifiers?: string[];
|
||||
requiresPython?: string;
|
||||
requiresDist?: string[];
|
||||
providesExtra?: string[];
|
||||
projectUrls?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IRubygemsMetadata {
|
||||
type: 'rubygems';
|
||||
platform?: string;
|
||||
requiredRubyVersion?: string;
|
||||
requiredRubygemsVersion?: string;
|
||||
dependencies?: Array<{
|
||||
name: string;
|
||||
requirements: string;
|
||||
type: 'runtime' | 'development';
|
||||
}>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Version Metadata
|
||||
// =============================================================================
|
||||
|
||||
export interface IVersionMetadata {
|
||||
readme?: string;
|
||||
changelog?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
engines?: Record<string, string>;
|
||||
files?: string[];
|
||||
checksum?: {
|
||||
sha256?: string;
|
||||
sha512?: string;
|
||||
md5?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search Types
|
||||
// =============================================================================
|
||||
|
||||
export interface IPackageSearchParams {
|
||||
query?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
organizationId?: string;
|
||||
visibility?: 'public' | 'private' | 'internal';
|
||||
sort?: 'downloads' | 'stars' | 'updated' | 'name';
|
||||
order?: 'asc' | 'desc';
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IPackageSearchResult {
|
||||
packages: IPackage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stats Types
|
||||
// =============================================================================
|
||||
|
||||
export interface IPackageStats {
|
||||
packageId: string;
|
||||
totalDownloads: number;
|
||||
downloadsByVersion: Record<string, number>;
|
||||
downloadsByDay: Array<{ date: string; count: number }>;
|
||||
downloadsByCountry?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface IOrganizationStats {
|
||||
organizationId: string;
|
||||
totalPackages: number;
|
||||
totalDownloads: number;
|
||||
storageUsedBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
packagesByProtocol: Record<TRegistryProtocol, number>;
|
||||
}
|
||||
167
ts/models/apitoken.ts
Normal file
167
ts/models/apitoken.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* ApiToken model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class ApiToken
|
||||
extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
|
||||
implements IApiToken
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public tokenHash: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tokenPrefix: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public protocols: TRegistryProtocol[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public scopes: ITokenScope[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public expiresAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastUsedAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastUsedIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public usageCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public isRevoked: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public revokedAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public revokedReason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdIp?: string;
|
||||
|
||||
/**
|
||||
* Find token by hash
|
||||
*/
|
||||
public static async findByHash(tokenHash: string): Promise<ApiToken | null> {
|
||||
return await ApiToken.getInstance({
|
||||
tokenHash,
|
||||
isRevoked: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find token by prefix (for listing)
|
||||
*/
|
||||
public static async findByPrefix(tokenPrefix: string): Promise<ApiToken | null> {
|
||||
return await ApiToken.getInstance({
|
||||
tokenPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tokens for a user
|
||||
*/
|
||||
public static async getUserTokens(userId: string): Promise<ApiToken[]> {
|
||||
return await ApiToken.getInstances({
|
||||
userId,
|
||||
isRevoked: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is valid (not expired, not revoked)
|
||||
*/
|
||||
public isValid(): boolean {
|
||||
if (this.isRevoked) return false;
|
||||
if (this.expiresAt && this.expiresAt < new Date()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage
|
||||
*/
|
||||
public async recordUsage(ip?: string): Promise<void> {
|
||||
this.lastUsedAt = new Date();
|
||||
this.lastUsedIp = ip;
|
||||
this.usageCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke token
|
||||
*/
|
||||
public async revoke(reason?: string): Promise<void> {
|
||||
this.isRevoked = true;
|
||||
this.revokedAt = new Date();
|
||||
this.revokedReason = reason;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has permission for protocol
|
||||
*/
|
||||
public hasProtocol(protocol: TRegistryProtocol): boolean {
|
||||
return this.protocols.includes(protocol) || this.protocols.includes('*' as TRegistryProtocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has permission for action on resource
|
||||
*/
|
||||
public hasScope(
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
): boolean {
|
||||
for (const scope of this.scopes) {
|
||||
// Check protocol
|
||||
if (scope.protocol !== '*' && scope.protocol !== protocol) continue;
|
||||
|
||||
// Check organization
|
||||
if (scope.organizationId && scope.organizationId !== organizationId) continue;
|
||||
|
||||
// Check repository
|
||||
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
|
||||
|
||||
// Check action
|
||||
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await ApiToken.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
171
ts/models/auditlog.ts
Normal file
171
ts/models/auditlog.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* AuditLog model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AuditLog
|
||||
extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog>
|
||||
implements IAuditLog
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public actorId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public actorType: 'user' | 'api_token' | 'system' | 'anonymous' = 'anonymous';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actorTokenId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actorIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actorUserAgent?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public action: TAuditAction = 'USER_CREATED';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public resourceType: TAuditResourceType = 'user';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public resourceId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public resourceName?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public repositoryId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata: Record<string, unknown> = {};
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public success: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public errorCode?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public errorMessage?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public durationMs?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public timestamp: Date = new Date();
|
||||
|
||||
/**
|
||||
* Create an audit log entry
|
||||
*/
|
||||
public static async log(data: {
|
||||
actorId?: string;
|
||||
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
|
||||
actorTokenId?: string;
|
||||
actorIp?: string;
|
||||
actorUserAgent?: string;
|
||||
action: TAuditAction;
|
||||
resourceType: TAuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}): Promise<AuditLog> {
|
||||
const log = new AuditLog();
|
||||
log.id = await AuditLog.getNewId();
|
||||
log.actorId = data.actorId;
|
||||
log.actorType = data.actorType || (data.actorId ? 'user' : 'anonymous');
|
||||
log.actorTokenId = data.actorTokenId;
|
||||
log.actorIp = data.actorIp;
|
||||
log.actorUserAgent = data.actorUserAgent;
|
||||
log.action = data.action;
|
||||
log.resourceType = data.resourceType;
|
||||
log.resourceId = data.resourceId;
|
||||
log.resourceName = data.resourceName;
|
||||
log.organizationId = data.organizationId;
|
||||
log.repositoryId = data.repositoryId;
|
||||
log.metadata = data.metadata || {};
|
||||
log.success = data.success ?? true;
|
||||
log.errorCode = data.errorCode;
|
||||
log.errorMessage = data.errorMessage;
|
||||
log.durationMs = data.durationMs;
|
||||
log.timestamp = new Date();
|
||||
await log.save();
|
||||
return log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit logs with filters
|
||||
*/
|
||||
public static async query(filters: {
|
||||
actorId?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
resourceType?: TAuditResourceType;
|
||||
action?: TAuditAction[];
|
||||
success?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ logs: AuditLog[]; total: number }> {
|
||||
const query: Record<string, unknown> = {};
|
||||
|
||||
if (filters.actorId) query.actorId = filters.actorId;
|
||||
if (filters.organizationId) query.organizationId = filters.organizationId;
|
||||
if (filters.repositoryId) query.repositoryId = filters.repositoryId;
|
||||
if (filters.resourceType) query.resourceType = filters.resourceType;
|
||||
if (filters.action) query.action = { $in: filters.action };
|
||||
if (filters.success !== undefined) query.success = filters.success;
|
||||
|
||||
if (filters.startDate || filters.endDate) {
|
||||
query.timestamp = {};
|
||||
if (filters.startDate) (query.timestamp as Record<string, unknown>).$gte = filters.startDate;
|
||||
if (filters.endDate) (query.timestamp as Record<string, unknown>).$lte = filters.endDate;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const allLogs = await AuditLog.getInstances(query);
|
||||
const total = allLogs.length;
|
||||
|
||||
// Apply pagination
|
||||
const offset = filters.offset || 0;
|
||||
const limit = filters.limit || 100;
|
||||
const logs = allLogs.slice(offset, offset + limit);
|
||||
|
||||
return { logs, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await AuditLog.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
57
ts/models/db.ts
Normal file
57
ts/models/db.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Database connection singleton
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
|
||||
let dbInstance: plugins.smartdata.SmartdataDb | null = null;
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
*/
|
||||
export async function initDb(config: {
|
||||
mongoDbUrl: string;
|
||||
mongoDbName?: string;
|
||||
}): Promise<plugins.smartdata.SmartdataDb> {
|
||||
if (dbInstance) {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
dbInstance = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: config.mongoDbUrl,
|
||||
mongoDbName: config.mongoDbName || 'stackregistry',
|
||||
});
|
||||
|
||||
await dbInstance.init();
|
||||
console.log('Database connected successfully');
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance (must call initDb first)
|
||||
*/
|
||||
export function getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!dbInstance) {
|
||||
throw new Error('Database not initialized. Call initDb() first.');
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
console.log('Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is connected
|
||||
*/
|
||||
export function isDbConnected(): boolean {
|
||||
return dbInstance !== null;
|
||||
}
|
||||
16
ts/models/index.ts
Normal file
16
ts/models/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Model exports
|
||||
*/
|
||||
|
||||
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
|
||||
export { User } from './user.ts';
|
||||
export { Organization } from './organization.ts';
|
||||
export { OrganizationMember } from './organization.member.ts';
|
||||
export { Team } from './team.ts';
|
||||
export { TeamMember } from './team.member.ts';
|
||||
export { Repository } from './repository.ts';
|
||||
export { RepositoryPermission } from './repository.permission.ts';
|
||||
export { Package } from './package.ts';
|
||||
export { ApiToken } from './apitoken.ts';
|
||||
export { Session } from './session.ts';
|
||||
export { AuditLog } from './auditlog.ts';
|
||||
109
ts/models/organization.member.ts
Normal file
109
ts/models/organization.member.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* OrganizationMember model - links users to organizations with roles
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class OrganizationMember
|
||||
extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember>
|
||||
implements IOrganizationMember
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public role: TOrganizationRole = 'member';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public invitedBy?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public joinedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Add a member to an organization
|
||||
*/
|
||||
public static async addMember(data: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
role: TOrganizationRole;
|
||||
invitedBy?: string;
|
||||
}): Promise<OrganizationMember> {
|
||||
// Check if member already exists
|
||||
const existing = await OrganizationMember.getInstance({
|
||||
organizationId: data.organizationId,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('User is already a member of this organization');
|
||||
}
|
||||
|
||||
const member = new OrganizationMember();
|
||||
member.id = await OrganizationMember.getNewId();
|
||||
member.organizationId = data.organizationId;
|
||||
member.userId = data.userId;
|
||||
member.role = data.role;
|
||||
member.invitedBy = data.invitedBy;
|
||||
member.joinedAt = new Date();
|
||||
member.createdAt = new Date();
|
||||
await member.save();
|
||||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find membership for user in organization
|
||||
*/
|
||||
public static async findMembership(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<OrganizationMember | null> {
|
||||
return await OrganizationMember.getInstance({
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of an organization
|
||||
*/
|
||||
public static async getOrgMembers(organizationId: string): Promise<OrganizationMember[]> {
|
||||
return await OrganizationMember.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all organizations a user belongs to
|
||||
*/
|
||||
public static async getUserOrganizations(userId: string): Promise<OrganizationMember[]> {
|
||||
return await OrganizationMember.getInstances({
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await OrganizationMember.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
138
ts/models/organization.ts
Normal file
138
ts/models/organization.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Organization model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IOrganization,
|
||||
IOrganizationSettings,
|
||||
TOrganizationPlan,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
const DEFAULT_SETTINGS: IOrganizationSettings = {
|
||||
requireMfa: false,
|
||||
allowPublicRepositories: true,
|
||||
defaultRepositoryVisibility: 'private',
|
||||
allowedProtocols: ['oci', 'npm', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class Organization
|
||||
extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
|
||||
implements IOrganization
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public name: string = ''; // URL-safe slug
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public displayName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public avatarUrl?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public plan: TOrganizationPlan = 'free';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public settings: IOrganizationSettings = DEFAULT_SETTINGS;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public billingEmail?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public isVerified: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public verifiedDomains: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public storageQuotaBytes: number = 5 * 1024 * 1024 * 1024; // 5GB default
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public usedStorageBytes: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdById: string = '';
|
||||
|
||||
/**
|
||||
* Create a new organization
|
||||
*/
|
||||
public static async createOrganization(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
createdById: string;
|
||||
}): Promise<Organization> {
|
||||
// Validate name (URL-safe)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens'
|
||||
);
|
||||
}
|
||||
|
||||
const org = new Organization();
|
||||
org.id = await Organization.getNewId();
|
||||
org.name = data.name.toLowerCase();
|
||||
org.displayName = data.displayName;
|
||||
org.description = data.description;
|
||||
org.createdById = data.createdById;
|
||||
org.settings = { ...DEFAULT_SETTINGS };
|
||||
org.createdAt = new Date();
|
||||
org.updatedAt = new Date();
|
||||
await org.save();
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by name (slug)
|
||||
*/
|
||||
public static async findByName(name: string): Promise<Organization | null> {
|
||||
return await Organization.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage quota is exceeded
|
||||
*/
|
||||
public hasStorageAvailable(additionalBytes: number): boolean {
|
||||
if (this.storageQuotaBytes < 0) return true; // Unlimited
|
||||
return this.usedStorageBytes + additionalBytes <= this.storageQuotaBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update storage usage
|
||||
*/
|
||||
public async updateStorageUsage(deltaBytes: number): Promise<void> {
|
||||
this.usedStorageBytes = Math.max(0, this.usedStorageBytes + deltaBytes);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Organization.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
195
ts/models/package.ts
Normal file
195
ts/models/package.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Package model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IPackage,
|
||||
IPackageVersion,
|
||||
IProtocolMetadata,
|
||||
} from '../interfaces/package.interfaces.ts';
|
||||
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package> implements IPackage {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = ''; // {protocol}:{org}:{name}
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public repositoryId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public protocol: TRegistryProtocol = 'npm';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public versions: Record<string, IPackageVersion> = {};
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public distTags: Record<string, string> = {}; // e.g., { latest: "1.0.0" }
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata: IProtocolMetadata = { type: 'npm' };
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public isPrivate: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public storageBytes: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public downloadCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public starCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public cacheExpiresAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdById: string = '';
|
||||
|
||||
/**
|
||||
* Generate package ID
|
||||
*/
|
||||
public static generateId(protocol: TRegistryProtocol, orgName: string, name: string): string {
|
||||
return `${protocol}:${orgName}:${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find package by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Package | null> {
|
||||
return await Package.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find package by protocol, org, and name
|
||||
*/
|
||||
public static async findByName(
|
||||
protocol: TRegistryProtocol,
|
||||
orgName: string,
|
||||
name: string
|
||||
): Promise<Package | null> {
|
||||
const id = Package.generateId(protocol, orgName, name);
|
||||
return await Package.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get packages in an organization
|
||||
*/
|
||||
public static async getOrgPackages(organizationId: string): Promise<Package[]> {
|
||||
return await Package.getInstances({ organizationId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search packages
|
||||
*/
|
||||
public static async search(
|
||||
query: string,
|
||||
options?: {
|
||||
protocol?: TRegistryProtocol;
|
||||
organizationId?: string;
|
||||
isPrivate?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<Package[]> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (options?.protocol) filter.protocol = options.protocol;
|
||||
if (options?.organizationId) filter.organizationId = options.organizationId;
|
||||
if (options?.isPrivate !== undefined) filter.isPrivate = options.isPrivate;
|
||||
|
||||
// Simple text search - in production, would use MongoDB text index
|
||||
const allPackages = await Package.getInstances(filter);
|
||||
|
||||
// Filter by query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const filtered = allPackages.filter(
|
||||
(pkg) =>
|
||||
pkg.name.toLowerCase().includes(lowerQuery) ||
|
||||
pkg.description?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
|
||||
// Apply pagination
|
||||
const offset = options?.offset || 0;
|
||||
const limit = options?.limit || 50;
|
||||
return filtered.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new version
|
||||
*/
|
||||
public addVersion(version: IPackageVersion): void {
|
||||
this.versions[version.version] = version;
|
||||
this.storageBytes += version.size;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version
|
||||
*/
|
||||
public getVersion(version: string): IPackageVersion | undefined {
|
||||
return this.versions[version];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest version
|
||||
*/
|
||||
public getLatestVersion(): IPackageVersion | undefined {
|
||||
const latest = this.distTags['latest'];
|
||||
if (latest) {
|
||||
return this.versions[latest];
|
||||
}
|
||||
// Fallback to most recent
|
||||
const versionList = Object.keys(this.versions);
|
||||
if (versionList.length === 0) return undefined;
|
||||
return this.versions[versionList[versionList.length - 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count
|
||||
*/
|
||||
public async incrementDownloads(version?: string): Promise<void> {
|
||||
this.downloadCount += 1;
|
||||
if (version && this.versions[version]) {
|
||||
this.versions[version].downloads += 1;
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Package.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
162
ts/models/repository.permission.ts
Normal file
162
ts/models/repository.permission.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* RepositoryPermission model - grants access to repositories
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RepositoryPermission
|
||||
extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission>
|
||||
implements IRepositoryPermission
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public repositoryId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public teamId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public role: TRepositoryRole = 'reader';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public grantedById: string = '';
|
||||
|
||||
/**
|
||||
* Grant permission to a user
|
||||
*/
|
||||
public static async grantToUser(data: {
|
||||
repositoryId: string;
|
||||
userId: string;
|
||||
role: TRepositoryRole;
|
||||
grantedById: string;
|
||||
}): Promise<RepositoryPermission> {
|
||||
// Check for existing permission
|
||||
const existing = await RepositoryPermission.getInstance({
|
||||
repositoryId: data.repositoryId,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing permission
|
||||
existing.role = data.role;
|
||||
await existing.save();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const perm = new RepositoryPermission();
|
||||
perm.id = await RepositoryPermission.getNewId();
|
||||
perm.repositoryId = data.repositoryId;
|
||||
perm.userId = data.userId;
|
||||
perm.role = data.role;
|
||||
perm.grantedById = data.grantedById;
|
||||
perm.createdAt = new Date();
|
||||
await perm.save();
|
||||
return perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant permission to a team
|
||||
*/
|
||||
public static async grantToTeam(data: {
|
||||
repositoryId: string;
|
||||
teamId: string;
|
||||
role: TRepositoryRole;
|
||||
grantedById: string;
|
||||
}): Promise<RepositoryPermission> {
|
||||
// Check for existing permission
|
||||
const existing = await RepositoryPermission.getInstance({
|
||||
repositoryId: data.repositoryId,
|
||||
teamId: data.teamId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing permission
|
||||
existing.role = data.role;
|
||||
await existing.save();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const perm = new RepositoryPermission();
|
||||
perm.id = await RepositoryPermission.getNewId();
|
||||
perm.repositoryId = data.repositoryId;
|
||||
perm.teamId = data.teamId;
|
||||
perm.role = data.role;
|
||||
perm.grantedById = data.grantedById;
|
||||
perm.createdAt = new Date();
|
||||
await perm.save();
|
||||
return perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's direct permission on repository
|
||||
*/
|
||||
public static async getUserPermission(
|
||||
repositoryId: string,
|
||||
userId: string
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team's permission on repository
|
||||
*/
|
||||
public static async getTeamPermission(
|
||||
repositoryId: string,
|
||||
teamId: string
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a repository
|
||||
*/
|
||||
public static async getRepoPermissions(repositoryId: string): Promise<RepositoryPermission[]> {
|
||||
return await RepositoryPermission.getInstances({
|
||||
repositoryId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for user's teams on a repository
|
||||
*/
|
||||
public static async getTeamPermissionsForRepo(
|
||||
repositoryId: string,
|
||||
teamIds: string[]
|
||||
): Promise<RepositoryPermission[]> {
|
||||
if (teamIds.length === 0) return [];
|
||||
return await RepositoryPermission.getInstances({
|
||||
repositoryId,
|
||||
teamId: { $in: teamIds } as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await RepositoryPermission.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ts/models/repository.ts
Normal file
158
ts/models/repository.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Repository model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class Repository
|
||||
extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
|
||||
implements IRepository
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public protocol: TRegistryProtocol = 'npm';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public visibility: TRepositoryVisibility = 'private';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public storageNamespace: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public downloadCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public starCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdById: string = '';
|
||||
|
||||
/**
|
||||
* Create a new repository
|
||||
*/
|
||||
public static async createRepository(data: {
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
createdById: string;
|
||||
}): Promise<Repository> {
|
||||
// Validate name
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name.toLowerCase())) {
|
||||
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
|
||||
}
|
||||
|
||||
// Check for duplicate name in org + protocol
|
||||
const existing = await Repository.getInstance({
|
||||
organizationId: data.organizationId,
|
||||
name: data.name.toLowerCase(),
|
||||
protocol: data.protocol,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Repository with this name and protocol already exists');
|
||||
}
|
||||
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = data.organizationId;
|
||||
repo.name = data.name.toLowerCase();
|
||||
repo.description = data.description;
|
||||
repo.protocol = data.protocol;
|
||||
repo.visibility = data.visibility || 'private';
|
||||
repo.storageNamespace = `${data.protocol}/${data.organizationId}/${data.name.toLowerCase()}`;
|
||||
repo.createdById = data.createdById;
|
||||
repo.createdAt = new Date();
|
||||
repo.updatedAt = new Date();
|
||||
await repo.save();
|
||||
return repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find repository by org, name, and protocol
|
||||
*/
|
||||
public static async findByName(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
protocol: TRegistryProtocol
|
||||
): Promise<Repository | null> {
|
||||
return await Repository.getInstance({
|
||||
organizationId,
|
||||
name: name.toLowerCase(),
|
||||
protocol,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories in an organization
|
||||
*/
|
||||
public static async getOrgRepositories(organizationId: string): Promise<Repository[]> {
|
||||
return await Repository.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all public repositories
|
||||
*/
|
||||
public static async getPublicRepositories(protocol?: TRegistryProtocol): Promise<Repository[]> {
|
||||
const query: Record<string, unknown> = { visibility: 'public' };
|
||||
if (protocol) {
|
||||
query.protocol = protocol;
|
||||
}
|
||||
return await Repository.getInstances(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count
|
||||
*/
|
||||
public async incrementDownloads(): Promise<void> {
|
||||
this.downloadCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full path (org/repo)
|
||||
*/
|
||||
public getFullPath(orgName: string): string {
|
||||
return `${orgName}/${this.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Repository.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
135
ts/models/session.ts
Normal file
135
ts/models/session.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Session model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { ISession } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class Session
|
||||
extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
||||
implements ISession
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public userAgent: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public isValid: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public invalidatedAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public invalidatedReason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastActivityAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
public static async createSession(data: {
|
||||
userId: string;
|
||||
userAgent: string;
|
||||
ipAddress: string;
|
||||
}): Promise<Session> {
|
||||
const session = new Session();
|
||||
session.id = await Session.getNewId();
|
||||
session.userId = data.userId;
|
||||
session.userAgent = data.userAgent;
|
||||
session.ipAddress = data.ipAddress;
|
||||
session.isValid = true;
|
||||
session.lastActivityAt = new Date();
|
||||
session.createdAt = new Date();
|
||||
await session.save();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid session by ID
|
||||
*/
|
||||
public static async findValidSession(sessionId: string): Promise<Session | null> {
|
||||
const session = await Session.getInstance({
|
||||
id: sessionId,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired (7 days)
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000;
|
||||
if (Date.now() - session.createdAt.getTime() > maxAge) {
|
||||
await session.invalidate('expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid sessions for a user
|
||||
*/
|
||||
public static async getUserSessions(userId: string): Promise<Session[]> {
|
||||
return await Session.getInstances({
|
||||
userId,
|
||||
isValid: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all sessions for a user
|
||||
*/
|
||||
public static async invalidateAllUserSessions(
|
||||
userId: string,
|
||||
reason: string = 'logout_all'
|
||||
): Promise<number> {
|
||||
const sessions = await Session.getUserSessions(userId);
|
||||
for (const session of sessions) {
|
||||
await session.invalidate(reason);
|
||||
}
|
||||
return sessions.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate this session
|
||||
*/
|
||||
public async invalidate(reason: string): Promise<void> {
|
||||
this.isValid = false;
|
||||
this.invalidatedAt = new Date();
|
||||
this.invalidatedReason = reason;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity
|
||||
*/
|
||||
public async touchActivity(): Promise<void> {
|
||||
this.lastActivityAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await Session.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
ts/models/team.member.ts
Normal file
97
ts/models/team.member.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* TeamMember model - links users to teams with roles
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class TeamMember
|
||||
extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember>
|
||||
implements ITeamMember
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public teamId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public role: TTeamRole = 'member';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Add a member to a team
|
||||
*/
|
||||
public static async addMember(data: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
role: TTeamRole;
|
||||
}): Promise<TeamMember> {
|
||||
// Check if member already exists
|
||||
const existing = await TeamMember.getInstance({
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('User is already a member of this team');
|
||||
}
|
||||
|
||||
const member = new TeamMember();
|
||||
member.id = await TeamMember.getNewId();
|
||||
member.teamId = data.teamId;
|
||||
member.userId = data.userId;
|
||||
member.role = data.role;
|
||||
member.createdAt = new Date();
|
||||
await member.save();
|
||||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find membership for user in team
|
||||
*/
|
||||
public static async findMembership(teamId: string, userId: string): Promise<TeamMember | null> {
|
||||
return await TeamMember.getInstance({
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a team
|
||||
*/
|
||||
public static async getTeamMembers(teamId: string): Promise<TeamMember[]> {
|
||||
return await TeamMember.getInstances({
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams a user belongs to
|
||||
*/
|
||||
public static async getUserTeams(userId: string): Promise<TeamMember[]> {
|
||||
return await TeamMember.getInstances({
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await TeamMember.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
100
ts/models/team.ts
Normal file
100
ts/models/team.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Team model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { ITeam } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implements ITeam {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public isDefaultTeam: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
public static async createTeam(data: {
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefaultTeam?: boolean;
|
||||
}): Promise<Team> {
|
||||
// Validate name
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name.toLowerCase())) {
|
||||
throw new Error('Team name must be lowercase alphanumeric with optional hyphens');
|
||||
}
|
||||
|
||||
// Check for duplicate name in org
|
||||
const existing = await Team.getInstance({
|
||||
organizationId: data.organizationId,
|
||||
name: data.name.toLowerCase(),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Team with this name already exists in the organization');
|
||||
}
|
||||
|
||||
const team = new Team();
|
||||
team.id = await Team.getNewId();
|
||||
team.organizationId = data.organizationId;
|
||||
team.name = data.name.toLowerCase();
|
||||
team.description = data.description;
|
||||
team.isDefaultTeam = data.isDefaultTeam || false;
|
||||
team.createdAt = new Date();
|
||||
team.updatedAt = new Date();
|
||||
await team.save();
|
||||
return team;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find team by name in organization
|
||||
*/
|
||||
public static async findByName(organizationId: string, name: string): Promise<Team | null> {
|
||||
return await Team.getInstance({
|
||||
organizationId,
|
||||
name: name.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams in an organization
|
||||
*/
|
||||
public static async getOrgTeams(organizationId: string): Promise<Team[]> {
|
||||
return await Team.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Team.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
115
ts/models/user.ts
Normal file
115
ts/models/user.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* User model for Stack.Gallery Registry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IUser, TUserStatus } from '../interfaces/auth.interfaces.ts';
|
||||
import { getDb } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implements IUser {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public email: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public username: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public passwordHash: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public displayName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public avatarUrl?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public status: TUserStatus = 'pending_verification';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public emailVerified: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public mfaEnabled: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public mfaSecret?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastLoginAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastLoginIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public failedLoginAttempts: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lockedUntil?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public isPlatformAdmin: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Create a new user instance
|
||||
*/
|
||||
public static async createUser(data: {
|
||||
email: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName?: string;
|
||||
}): Promise<User> {
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = data.email.toLowerCase();
|
||||
user.username = data.username.toLowerCase();
|
||||
user.passwordHash = data.passwordHash;
|
||||
user.displayName = data.displayName || data.username;
|
||||
user.status = 'pending_verification';
|
||||
user.createdAt = new Date();
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
public static async findByEmail(email: string): Promise<User | null> {
|
||||
return await User.getInstance({ email: email.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
public static async findByUsername(username: string): Promise<User | null> {
|
||||
return await User.getInstance({ username: username.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await User.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ts/plugins.ts
Normal file
52
ts/plugins.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Centralized dependency imports
|
||||
* All external modules should be imported here and accessed via plugins.*
|
||||
*/
|
||||
|
||||
// Push.rocks packages
|
||||
import * as smartregistry from '@push.rocks/smartregistry';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartenv from '@push.rocks/smartenv';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartstring from '@push.rocks/smartstring';
|
||||
import * as smartcrypto from '@push.rocks/smartcrypto';
|
||||
import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
|
||||
// tsclass types
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
// Deno std library
|
||||
import * as path from '@std/path';
|
||||
import * as fs from '@std/fs';
|
||||
import * as http from '@std/http';
|
||||
|
||||
export {
|
||||
// Push.rocks
|
||||
smartregistry,
|
||||
smartdata,
|
||||
smartbucket,
|
||||
smartlog,
|
||||
smartenv,
|
||||
smartpath,
|
||||
smartpromise,
|
||||
smartstring,
|
||||
smartcrypto,
|
||||
smartjwt,
|
||||
smartunique,
|
||||
smartdelay,
|
||||
smartrx,
|
||||
smartcli,
|
||||
// tsclass
|
||||
tsclass,
|
||||
// Deno std
|
||||
path,
|
||||
fs,
|
||||
http,
|
||||
};
|
||||
277
ts/providers/auth.provider.ts
Normal file
277
ts/providers/auth.provider.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* IAuthProvider implementation for smartregistry
|
||||
* Integrates Stack.Gallery's auth system with smartregistry's protocol handlers
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { User } from '../models/user.ts';
|
||||
import { TokenService } from '../services/token.service.ts';
|
||||
import { PermissionService, type TAction } from '../services/permission.service.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
import { AuthService } from '../services/auth.service.ts';
|
||||
|
||||
/**
|
||||
* Request actor representing the authenticated entity making a request
|
||||
*/
|
||||
export interface IStackGalleryActor {
|
||||
type: 'user' | 'api_token' | 'anonymous';
|
||||
userId?: string;
|
||||
user?: User;
|
||||
tokenId?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
permissions: {
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth provider that implements smartregistry's IAuthProvider interface
|
||||
*/
|
||||
export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProvider {
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
private authService: AuthService;
|
||||
|
||||
constructor() {
|
||||
this.tokenService = new TokenService();
|
||||
this.permissionService = new PermissionService();
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request and return the actor
|
||||
* Called by smartregistry for every incoming request
|
||||
*/
|
||||
public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise<plugins.smartregistry.IRequestActor> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: request.ip,
|
||||
actorUserAgent: request.userAgent,
|
||||
});
|
||||
|
||||
// Extract auth credentials
|
||||
const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization'];
|
||||
|
||||
// Try Bearer token (API token)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return await this.authenticateWithApiToken(token, request, auditContext);
|
||||
}
|
||||
|
||||
// Try Basic auth (for npm/other CLI tools)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const credentials = authHeader.substring(6);
|
||||
return await this.authenticateWithBasicAuth(credentials, request, auditContext);
|
||||
}
|
||||
|
||||
// Anonymous access
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor has permission for the requested action
|
||||
*/
|
||||
public async authorize(
|
||||
actor: plugins.smartregistry.IRequestActor,
|
||||
request: plugins.smartregistry.IAuthorizationRequest
|
||||
): Promise<plugins.smartregistry.IAuthorizationResult> {
|
||||
const stackActor = actor as IStackGalleryActor;
|
||||
|
||||
// Anonymous users can only read public packages
|
||||
if (stackActor.type === 'anonymous') {
|
||||
if (request.action === 'read' && request.isPublic) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Authentication required',
|
||||
statusCode: 401,
|
||||
};
|
||||
}
|
||||
|
||||
// Check protocol access
|
||||
if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) &&
|
||||
!stackActor.protocols.includes('*' as TRegistryProtocol)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Token does not have access to ${request.protocol} protocol`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
// Map action to TAction
|
||||
const action = this.mapAction(request.action);
|
||||
|
||||
// Resolve permissions
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: stackActor.userId!,
|
||||
organizationId: request.organizationId,
|
||||
repositoryId: request.repositoryId,
|
||||
protocol: request.protocol as TRegistryProtocol,
|
||||
});
|
||||
|
||||
// Check permission
|
||||
let allowed = false;
|
||||
switch (action) {
|
||||
case 'read':
|
||||
allowed = permissions.canRead || (request.isPublic ?? false);
|
||||
break;
|
||||
case 'write':
|
||||
allowed = permissions.canWrite;
|
||||
break;
|
||||
case 'delete':
|
||||
allowed = permissions.canDelete;
|
||||
break;
|
||||
case 'admin':
|
||||
allowed = permissions.canAdmin;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API token
|
||||
*/
|
||||
private async authenticateWithApiToken(
|
||||
rawToken: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
const result = await this.tokenService.validateToken(rawToken, request.ip);
|
||||
|
||||
if (!result.valid || !result.token || !result.user) {
|
||||
await auditContext.logFailure(
|
||||
'TOKEN_USED',
|
||||
'api_token',
|
||||
result.errorCode || 'UNKNOWN',
|
||||
result.errorMessage || 'Token validation failed'
|
||||
);
|
||||
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
await auditContext.log('TOKEN_USED', 'api_token', {
|
||||
resourceId: result.token.id,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using Basic auth (username:password or username:token)
|
||||
*/
|
||||
private async authenticateWithBasicAuth(
|
||||
credentials: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
try {
|
||||
const decoded = atob(credentials);
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
// If password looks like an API token, try token auth
|
||||
if (password?.startsWith('srg_')) {
|
||||
return await this.authenticateWithApiToken(password, request, auditContext);
|
||||
}
|
||||
|
||||
// Otherwise try username/password (email/password)
|
||||
const result = await this.authService.login(username, password, {
|
||||
userAgent: request.userAgent,
|
||||
ipAddress: request.ip,
|
||||
});
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous actor
|
||||
*/
|
||||
private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor {
|
||||
return {
|
||||
type: 'anonymous',
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: [],
|
||||
permissions: {
|
||||
canRead: false,
|
||||
canWrite: false,
|
||||
canDelete: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map smartregistry action to our TAction type
|
||||
*/
|
||||
private mapAction(action: string): TAction {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
case 'pull':
|
||||
case 'download':
|
||||
case 'fetch':
|
||||
return 'read';
|
||||
case 'write':
|
||||
case 'push':
|
||||
case 'publish':
|
||||
case 'upload':
|
||||
return 'write';
|
||||
case 'delete':
|
||||
case 'unpublish':
|
||||
case 'remove':
|
||||
return 'delete';
|
||||
case 'admin':
|
||||
case 'manage':
|
||||
return 'admin';
|
||||
default:
|
||||
return 'read';
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ts/providers/index.ts
Normal file
6
ts/providers/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
|
||||
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
|
||||
297
ts/providers/storage.provider.ts
Normal file
297
ts/providers/storage.provider.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* IStorageHooks implementation for smartregistry
|
||||
* Integrates Stack.Gallery's storage with smartregistry
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { Package } from '../models/package.ts';
|
||||
import { Repository } from '../models/repository.ts';
|
||||
import { Organization } from '../models/organization.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
export interface IStorageConfig {
|
||||
bucket: plugins.smartbucket.SmartBucket;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage hooks implementation that tracks packages in MongoDB
|
||||
* and stores artifacts in S3 via smartbucket
|
||||
*/
|
||||
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
|
||||
private config: IStorageConfig;
|
||||
|
||||
constructor(config: IStorageConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is stored
|
||||
* Use this to validate, transform, or prepare for storage
|
||||
*/
|
||||
public async beforeStore(context: plugins.smartregistry.IStorageContext): Promise<plugins.smartregistry.IStorageContext> {
|
||||
// Validate organization exists and has quota
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (!org) {
|
||||
throw new Error(`Organization not found: ${context.organizationId}`);
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
const newSize = context.size || 0;
|
||||
if (org.settings.quotas.maxStorageBytes > 0) {
|
||||
if (org.usedStorageBytes + newSize > org.settings.quotas.maxStorageBytes) {
|
||||
throw new Error('Organization storage quota exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate repository exists
|
||||
const repo = await Repository.findById(context.repositoryId);
|
||||
if (!repo) {
|
||||
throw new Error(`Repository not found: ${context.repositoryId}`);
|
||||
}
|
||||
|
||||
// Check repository protocol
|
||||
if (!repo.protocols.includes(context.protocol as TRegistryProtocol)) {
|
||||
throw new Error(`Repository does not support ${context.protocol} protocol`);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is successfully stored
|
||||
* Update database records and metrics
|
||||
*/
|
||||
public async afterStore(context: plugins.smartregistry.IStorageContext): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
|
||||
// Get or create package record
|
||||
let pkg = await Package.findById(packageId);
|
||||
if (!pkg) {
|
||||
pkg = new Package();
|
||||
pkg.id = packageId;
|
||||
pkg.organizationId = context.organizationId;
|
||||
pkg.repositoryId = context.repositoryId;
|
||||
pkg.protocol = protocol;
|
||||
pkg.name = context.packageName;
|
||||
pkg.createdById = context.actorId || '';
|
||||
pkg.createdAt = new Date();
|
||||
}
|
||||
|
||||
// Add version
|
||||
pkg.addVersion({
|
||||
version: context.version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: context.actorId || '',
|
||||
size: context.size || 0,
|
||||
checksum: context.checksum || '',
|
||||
checksumAlgorithm: context.checksumAlgorithm || 'sha256',
|
||||
downloads: 0,
|
||||
metadata: context.metadata || {},
|
||||
});
|
||||
|
||||
// Update dist tags if provided
|
||||
if (context.tags) {
|
||||
for (const [tag, version] of Object.entries(context.tags)) {
|
||||
pkg.distTags[tag] = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Set latest tag if not set
|
||||
if (!pkg.distTags['latest']) {
|
||||
pkg.distTags['latest'] = context.version;
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update organization storage usage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes += context.size || 0;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'anonymous',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackagePublished(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version,
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is fetched
|
||||
*/
|
||||
public async beforeFetch(context: plugins.smartregistry.IFetchContext): Promise<plugins.smartregistry.IFetchContext> {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is fetched
|
||||
* Update download metrics
|
||||
*/
|
||||
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (pkg) {
|
||||
await pkg.incrementDownloads(context.version);
|
||||
}
|
||||
|
||||
// Audit log for authenticated users
|
||||
if (context.actorId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: 'user',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackageDownloaded(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version || 'latest',
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is deleted
|
||||
*/
|
||||
public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise<plugins.smartregistry.IDeleteContext> {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is deleted
|
||||
*/
|
||||
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (!pkg) return;
|
||||
|
||||
if (context.version) {
|
||||
// Delete specific version
|
||||
const version = pkg.versions[context.version];
|
||||
if (version) {
|
||||
const sizeReduction = version.size;
|
||||
delete pkg.versions[context.version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, ver] of Object.entries(pkg.distTags)) {
|
||||
if (ver === context.version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// If no versions left, delete the package
|
||||
if (Object.keys(pkg.versions).length === 0) {
|
||||
await pkg.delete();
|
||||
} else {
|
||||
await pkg.save();
|
||||
}
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete entire package
|
||||
const sizeReduction = pkg.storageBytes;
|
||||
await pkg.delete();
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'system',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: context.packageName,
|
||||
metadata: { version: context.version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the S3 path for a package artifact
|
||||
*/
|
||||
public getArtifactPath(
|
||||
protocol: string,
|
||||
organizationName: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
filename: string
|
||||
): string {
|
||||
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store artifact in S3
|
||||
*/
|
||||
public async storeArtifact(
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
contentType?: string
|
||||
): Promise<string> {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
await bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(data),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch artifact from S3
|
||||
*/
|
||||
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
const file = await bucket.fastGet({ path });
|
||||
if (!file) return null;
|
||||
return new Uint8Array(file.contents);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete artifact from S3
|
||||
*/
|
||||
public async deleteArtifact(path: string): Promise<boolean> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
await bucket.fastDelete({ path });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
276
ts/registry.ts
Normal file
276
ts/registry.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* StackGalleryRegistry - Main registry class
|
||||
* Integrates smartregistry with Stack.Gallery's auth, storage, and database
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { initDb, closeDb, isDbConnected } from './models/db.ts';
|
||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||
import { ApiRouter } from './api/router.ts';
|
||||
|
||||
export interface IRegistryConfig {
|
||||
// MongoDB configuration
|
||||
mongoUrl: string;
|
||||
mongoDb: string;
|
||||
|
||||
// S3 configuration
|
||||
s3Endpoint: string;
|
||||
s3AccessKey: string;
|
||||
s3SecretKey: string;
|
||||
s3Bucket: string;
|
||||
s3Region?: string;
|
||||
|
||||
// Server configuration
|
||||
host?: string;
|
||||
port?: number;
|
||||
|
||||
// Registry settings
|
||||
storagePath?: string;
|
||||
enableUpstreamCache?: boolean;
|
||||
upstreamCacheExpiry?: number; // hours
|
||||
|
||||
// JWT configuration
|
||||
jwtSecret?: string;
|
||||
}
|
||||
|
||||
export class StackGalleryRegistry {
|
||||
private config: IRegistryConfig;
|
||||
private smartBucket: plugins.smartbucket.SmartBucket | null = null;
|
||||
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
||||
private authProvider: StackGalleryAuthProvider | null = null;
|
||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||
private apiRouter: ApiRouter | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
this.config = {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
storagePath: 'packages',
|
||||
enableUpstreamCache: true,
|
||||
upstreamCacheExpiry: 24,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
console.log('[StackGalleryRegistry] Initializing...');
|
||||
|
||||
// Initialize MongoDB
|
||||
console.log('[StackGalleryRegistry] Connecting to MongoDB...');
|
||||
await initDb(this.config.mongoUrl, this.config.mongoDb);
|
||||
console.log('[StackGalleryRegistry] MongoDB connected');
|
||||
|
||||
// Initialize S3/SmartBucket
|
||||
console.log('[StackGalleryRegistry] Initializing S3 storage...');
|
||||
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
||||
accessKey: this.config.s3AccessKey,
|
||||
accessSecret: this.config.s3SecretKey,
|
||||
endpoint: this.config.s3Endpoint,
|
||||
bucketName: this.config.s3Bucket,
|
||||
});
|
||||
console.log('[StackGalleryRegistry] S3 storage initialized');
|
||||
|
||||
// Initialize auth provider
|
||||
this.authProvider = new StackGalleryAuthProvider();
|
||||
|
||||
// Initialize storage hooks
|
||||
this.storageHooks = new StackGalleryStorageHooks({
|
||||
bucket: this.smartBucket,
|
||||
basePath: this.config.storagePath!,
|
||||
});
|
||||
|
||||
// Initialize smartregistry
|
||||
console.log('[StackGalleryRegistry] Initializing smartregistry...');
|
||||
this.smartRegistry = new plugins.smartregistry.SmartRegistry({
|
||||
authProvider: this.authProvider,
|
||||
storageHooks: this.storageHooks,
|
||||
storage: {
|
||||
type: 's3',
|
||||
bucket: this.smartBucket,
|
||||
basePath: this.config.storagePath,
|
||||
},
|
||||
upstreamCache: this.config.enableUpstreamCache
|
||||
? {
|
||||
enabled: true,
|
||||
expiryHours: this.config.upstreamCacheExpiry,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||
|
||||
// Initialize API router
|
||||
console.log('[StackGalleryRegistry] Initializing API router...');
|
||||
this.apiRouter = new ApiRouter();
|
||||
console.log('[StackGalleryRegistry] API router initialized');
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[StackGalleryRegistry] Initialization complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
const port = this.config.port!;
|
||||
const host = this.config.host!;
|
||||
|
||||
console.log(`[StackGalleryRegistry] Starting server on ${host}:${port}...`);
|
||||
|
||||
Deno.serve(
|
||||
{ port, hostname: host },
|
||||
async (request: Request): Promise<Response> => {
|
||||
return await this.handleRequest(request);
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP request
|
||||
*/
|
||||
private async handleRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check
|
||||
if (path === '/health' || path === '/healthz') {
|
||||
return this.healthCheck();
|
||||
}
|
||||
|
||||
// API endpoints (handled by REST API layer)
|
||||
if (path.startsWith('/api/')) {
|
||||
return await this.handleApiRequest(request);
|
||||
}
|
||||
|
||||
// Registry protocol endpoints
|
||||
// NPM: /-/..., /@scope/package, /package
|
||||
// OCI: /v2/...
|
||||
// Maven: /maven2/...
|
||||
// PyPI: /simple/..., /pypi/...
|
||||
// Cargo: /api/v1/crates/...
|
||||
// Composer: /packages.json, /p/...
|
||||
// RubyGems: /api/v1/gems/..., /gems/...
|
||||
|
||||
if (this.smartRegistry) {
|
||||
try {
|
||||
return await this.smartRegistry.handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error('[StackGalleryRegistry] Request error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Internal server error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests
|
||||
*/
|
||||
private async handleApiRequest(request: Request): Promise<Response> {
|
||||
if (!this.apiRouter) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'API router not initialized' }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return await this.apiRouter.handle(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
private healthCheck(): Response {
|
||||
const healthy = this.isInitialized && isDbConnected();
|
||||
|
||||
const status = {
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
mongodb: isDbConnected() ? 'connected' : 'disconnected',
|
||||
s3: this.smartBucket ? 'initialized' : 'not initialized',
|
||||
registry: this.smartRegistry ? 'initialized' : 'not initialized',
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(status), {
|
||||
status: healthy ? 200 : 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the registry
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
console.log('[StackGalleryRegistry] Shutting down...');
|
||||
await closeDb();
|
||||
this.isInitialized = false;
|
||||
console.log('[StackGalleryRegistry] Shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartregistry instance
|
||||
*/
|
||||
public getSmartRegistry(): plugins.smartregistry.SmartRegistry | null {
|
||||
return this.smartRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartbucket instance
|
||||
*/
|
||||
public getSmartBucket(): plugins.smartbucket.SmartBucket | null {
|
||||
return this.smartBucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registry is initialized
|
||||
*/
|
||||
public getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create registry from environment variables
|
||||
*/
|
||||
export function createRegistryFromEnv(): StackGalleryRegistry {
|
||||
const config: IRegistryConfig = {
|
||||
mongoUrl: Deno.env.get('MONGODB_URL') || 'mongodb://localhost:27017',
|
||||
mongoDb: Deno.env.get('MONGODB_DB') || 'stackgallery',
|
||||
s3Endpoint: Deno.env.get('S3_ENDPOINT') || 'http://localhost:9000',
|
||||
s3AccessKey: Deno.env.get('S3_ACCESS_KEY') || 'minioadmin',
|
||||
s3SecretKey: Deno.env.get('S3_SECRET_KEY') || 'minioadmin',
|
||||
s3Bucket: Deno.env.get('S3_BUCKET') || 'registry',
|
||||
s3Region: Deno.env.get('S3_REGION'),
|
||||
host: Deno.env.get('HOST') || '0.0.0.0',
|
||||
port: parseInt(Deno.env.get('PORT') || '3000', 10),
|
||||
storagePath: Deno.env.get('STORAGE_PATH') || 'packages',
|
||||
enableUpstreamCache: Deno.env.get('ENABLE_UPSTREAM_CACHE') !== 'false',
|
||||
upstreamCacheExpiry: parseInt(Deno.env.get('UPSTREAM_CACHE_EXPIRY') || '24', 10),
|
||||
jwtSecret: Deno.env.get('JWT_SECRET'),
|
||||
};
|
||||
|
||||
return new StackGalleryRegistry(config);
|
||||
}
|
||||
197
ts/services/audit.service.ts
Normal file
197
ts/services/audit.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* AuditService - Centralized audit logging
|
||||
*/
|
||||
|
||||
import type { TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
|
||||
import { AuditLog } from '../models/index.ts';
|
||||
|
||||
export interface IAuditContext {
|
||||
actorId?: string;
|
||||
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
|
||||
actorTokenId?: string;
|
||||
actorIp?: string;
|
||||
actorUserAgent?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
private context: IAuditContext;
|
||||
|
||||
constructor(context: IAuditContext = {}) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new audit service with context
|
||||
*/
|
||||
public static withContext(context: IAuditContext): AuditService {
|
||||
return new AuditService(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
*/
|
||||
public async log(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
options: {
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
): Promise<AuditLog> {
|
||||
return await AuditLog.log({
|
||||
actorId: this.context.actorId,
|
||||
actorType: this.context.actorType,
|
||||
actorTokenId: this.context.actorTokenId,
|
||||
actorIp: this.context.actorIp,
|
||||
actorUserAgent: this.context.actorUserAgent,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId: options.resourceId,
|
||||
resourceName: options.resourceName,
|
||||
organizationId: options.organizationId || this.context.organizationId,
|
||||
repositoryId: options.repositoryId || this.context.repositoryId,
|
||||
metadata: options.metadata,
|
||||
success: options.success,
|
||||
errorCode: options.errorCode,
|
||||
errorMessage: options.errorMessage,
|
||||
durationMs: options.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a successful action
|
||||
*/
|
||||
public async logSuccess(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId?: string,
|
||||
resourceName?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
resourceName,
|
||||
metadata,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed action
|
||||
*/
|
||||
public async logFailure(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
metadata,
|
||||
success: false,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience methods for common actions
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('USER_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('USER_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('USER_LOGOUT', 'user', userId);
|
||||
}
|
||||
|
||||
public async logTokenCreated(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_CREATED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logTokenRevoked(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_REVOKED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logPackagePublished(
|
||||
packageId: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUBLISHED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logPackageDownloaded(
|
||||
packageId: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_DOWNLOADED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logOrganizationCreated(orgId: string, orgName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName);
|
||||
}
|
||||
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPOSITORY_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
resourceName: repoName,
|
||||
organizationId,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logPermissionChanged(
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId: string,
|
||||
targetUserId: string,
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PERMISSION_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
oldRole,
|
||||
newRole,
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
405
ts/services/auth.service.ts
Normal file
405
ts/services/auth.service.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* AuthService - JWT-based authentication for UI sessions
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { User, Session } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface IJwtPayload {
|
||||
sub: string; // User ID
|
||||
email: string;
|
||||
sessionId: string;
|
||||
type: 'access' | 'refresh';
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface IAuthResult {
|
||||
success: boolean;
|
||||
user?: User;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
sessionId?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IAuthConfig {
|
||||
jwtSecret: string;
|
||||
accessTokenExpiresIn: number; // seconds (default: 15 minutes)
|
||||
refreshTokenExpiresIn: number; // seconds (default: 7 days)
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private config: IAuthConfig;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor(config: Partial<IAuthConfig> = {}) {
|
||||
this.config = {
|
||||
jwtSecret: config.jwtSecret || Deno.env.get('JWT_SECRET') || 'change-me-in-production',
|
||||
accessTokenExpiresIn: config.accessTokenExpiresIn || 15 * 60, // 15 minutes
|
||||
refreshTokenExpiresIn: config.refreshTokenExpiresIn || 7 * 24 * 60 * 60, // 7 days
|
||||
issuer: config.issuer || 'stack.gallery',
|
||||
};
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
public async login(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { userAgent?: string; ipAddress?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
actorType: 'anonymous',
|
||||
});
|
||||
|
||||
// Find user by email
|
||||
const user = await User.findByEmail(email);
|
||||
if (!user) {
|
||||
await auditContext.logUserLogin('', false, 'User not found');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_CREDENTIALS',
|
||||
errorMessage: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await user.verifyPassword(password);
|
||||
if (!isValid) {
|
||||
await auditContext.logUserLogin(user.id, false, 'Invalid password');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_CREDENTIALS',
|
||||
errorMessage: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
await auditContext.logUserLogin(user.id, false, 'Account inactive');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'ACCOUNT_INACTIVE',
|
||||
errorMessage: 'Account is inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).logUserLogin(user.id, true);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
public async refresh(refreshToken: string): Promise<IAuthResult> {
|
||||
// Verify refresh token
|
||||
const payload = await this.verifyToken(refreshToken);
|
||||
if (!payload) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_TOKEN',
|
||||
errorMessage: 'Invalid refresh token',
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_TOKEN_TYPE',
|
||||
errorMessage: 'Not a refresh token',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await Session.findValidSession(payload.sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'SESSION_INVALID',
|
||||
errorMessage: 'Session is invalid or expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'USER_INVALID',
|
||||
errorMessage: 'User not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Update session activity
|
||||
await session.touchActivity();
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate session
|
||||
*/
|
||||
public async logout(
|
||||
sessionId: string,
|
||||
options: { userId?: string; ipAddress?: string } = {}
|
||||
): Promise<boolean> {
|
||||
const session = await Session.findValidSession(sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
await session.invalidate('logout');
|
||||
|
||||
if (options.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: options.userId,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
}).logUserLogout(options.userId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout all sessions for a user
|
||||
*/
|
||||
public async logoutAll(
|
||||
userId: string,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<number> {
|
||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||
|
||||
await AuditService.withContext({
|
||||
actorId: userId,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
}).log('USER_LOGOUT', 'user', {
|
||||
resourceId: userId,
|
||||
metadata: { sessionsInvalidated: count },
|
||||
success: true,
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate access token and return user
|
||||
*/
|
||||
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
|
||||
const payload = await this.verifyToken(accessToken);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
|
||||
// Validate session is still valid
|
||||
const session = await Session.findValidSession(payload.sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const user = await User.findById(payload.sub);
|
||||
if (!user || !user.isActive) return null;
|
||||
|
||||
return { user, sessionId: payload.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: IJwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + this.config.accessTokenExpiresIn,
|
||||
};
|
||||
|
||||
return await this.signToken(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: IJwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + this.config.refreshTokenExpiresIn,
|
||||
};
|
||||
|
||||
return await this.signToken(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a JWT token
|
||||
*/
|
||||
private async signToken(payload: IJwtPayload): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await this.hmacSign(data);
|
||||
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
*/
|
||||
private async verifyToken(token: string): Promise<IJwtPayload | null> {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Verify signature
|
||||
const expectedSignature = await this.hmacSign(data);
|
||||
if (signature !== expectedSignature) return null;
|
||||
|
||||
// Decode payload
|
||||
const payload: IJwtPayload = JSON.parse(this.base64UrlDecode(encodedPayload));
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) return null;
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 sign
|
||||
*/
|
||||
private async hmacSign(data: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(this.config.jwtSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
return this.base64UrlEncode(String.fromCharCode(...new Uint8Array(signature)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL decode
|
||||
*/
|
||||
private base64UrlDecode(str: string): string {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
return atob(base64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt-like approach with Web Crypto
|
||||
* Note: In production, use a proper bcrypt library
|
||||
*/
|
||||
public static async hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const saltHex = Array.from(salt)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(saltHex + password);
|
||||
|
||||
// Multiple rounds of hashing for security
|
||||
let hash = data;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
|
||||
}
|
||||
|
||||
const hashHex = Array.from(hash)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `${saltHex}:${hashHex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
public static async verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
||||
const [saltHex, expectedHash] = storedHash.split(':');
|
||||
if (!saltHex || !expectedHash) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(saltHex + password);
|
||||
|
||||
let hash = data;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
|
||||
}
|
||||
|
||||
const hashHex = Array.from(hash)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return hashHex === expectedHash;
|
||||
}
|
||||
}
|
||||
22
ts/services/index.ts
Normal file
22
ts/services/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Service exports
|
||||
*/
|
||||
|
||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||
export {
|
||||
TokenService,
|
||||
type ICreateTokenOptions,
|
||||
type ITokenValidationResult,
|
||||
} from './token.service.ts';
|
||||
export {
|
||||
PermissionService,
|
||||
type TAction,
|
||||
type IPermissionContext,
|
||||
type IResolvedPermissions,
|
||||
} from './permission.service.ts';
|
||||
export {
|
||||
AuthService,
|
||||
type IJwtPayload,
|
||||
type IAuthResult,
|
||||
type IAuthConfig,
|
||||
} from './auth.service.ts';
|
||||
307
ts/services/permission.service.ts
Normal file
307
ts/services/permission.service.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* PermissionService - RBAC resolution across org → team → repo hierarchy
|
||||
*/
|
||||
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRegistryProtocol,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import {
|
||||
User,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
Repository,
|
||||
RepositoryPermission,
|
||||
} from '../models/index.ts';
|
||||
|
||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||
|
||||
export interface IPermissionContext {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
}
|
||||
|
||||
export interface IResolvedPermissions {
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
canAdmin: boolean;
|
||||
effectiveRole: TRepositoryRole | null;
|
||||
organizationRole: TOrganizationRole | null;
|
||||
teamRoles: Array<{ teamId: string; role: TTeamRole }>;
|
||||
repositoryRole: TRepositoryRole | null;
|
||||
}
|
||||
|
||||
export class PermissionService {
|
||||
/**
|
||||
* Resolve all permissions for a user in a specific context
|
||||
*/
|
||||
public async resolvePermissions(context: IPermissionContext): Promise<IResolvedPermissions> {
|
||||
const result: IResolvedPermissions = {
|
||||
canRead: false,
|
||||
canWrite: false,
|
||||
canDelete: false,
|
||||
canAdmin: false,
|
||||
effectiveRole: null,
|
||||
organizationRole: null,
|
||||
teamRoles: [],
|
||||
repositoryRole: null,
|
||||
};
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(context.userId);
|
||||
if (!user || !user.isActive) return result;
|
||||
|
||||
// System admins have full access
|
||||
if (user.isSystemAdmin) {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!context.organizationId) return result;
|
||||
|
||||
// Get organization membership
|
||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
||||
if (orgMember) {
|
||||
result.organizationRole = orgMember.role;
|
||||
|
||||
// Organization owners have full access to everything in the org
|
||||
if (orgMember.role === 'owner') {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Organization admins have admin access to all repos
|
||||
if (orgMember.role === 'admin') {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If no repository specified, check org-level permissions
|
||||
if (!context.repositoryId) {
|
||||
if (orgMember) {
|
||||
result.canRead = true; // Members can read org info
|
||||
result.effectiveRole = 'reader';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get repository
|
||||
const repository = await Repository.findById(context.repositoryId);
|
||||
if (!repository) return result;
|
||||
|
||||
// Check if repository is public
|
||||
if (repository.isPublic) {
|
||||
result.canRead = true;
|
||||
}
|
||||
|
||||
// Get team memberships that grant access to this repository
|
||||
if (orgMember) {
|
||||
const teams = await Team.getOrgTeams(context.organizationId);
|
||||
for (const team of teams) {
|
||||
const teamMember = await TeamMember.findMembership(team.id, context.userId);
|
||||
if (teamMember) {
|
||||
result.teamRoles.push({ teamId: team.id, role: teamMember.role });
|
||||
|
||||
// Check if team has access to this repository
|
||||
if (team.repositoryIds.includes(context.repositoryId)) {
|
||||
// Team maintainers get maintainer access to repos
|
||||
if (teamMember.role === 'maintainer') {
|
||||
this.applyRole(result, 'maintainer');
|
||||
} else {
|
||||
// Team members get developer access
|
||||
this.applyRole(result, 'developer');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get direct repository permission (highest priority)
|
||||
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
|
||||
if (repoPerm) {
|
||||
result.repositoryRole = repoPerm.role;
|
||||
this.applyRole(result, repoPerm.role);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform a specific action
|
||||
*/
|
||||
public async checkPermission(
|
||||
context: IPermissionContext,
|
||||
action: TAction
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions(context);
|
||||
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return permissions.canRead;
|
||||
case 'write':
|
||||
return permissions.canWrite;
|
||||
case 'delete':
|
||||
return permissions.canDelete;
|
||||
case 'admin':
|
||||
return permissions.canAdmin;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a package
|
||||
*/
|
||||
public async canAccessPackage(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string,
|
||||
action: 'read' | 'write' | 'delete'
|
||||
): Promise<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage organization
|
||||
*/
|
||||
public async canManageOrganization(userId: string, organizationId: string): Promise<boolean> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return false;
|
||||
if (user.isSystemAdmin) return true;
|
||||
|
||||
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
|
||||
return orgMember?.role === 'owner' || orgMember?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage repository
|
||||
*/
|
||||
public async canManageRepository(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions({
|
||||
userId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
});
|
||||
return permissions.canAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories a user can access in an organization
|
||||
*/
|
||||
public async getAccessibleRepositories(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<Repository[]> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return [];
|
||||
|
||||
// System admins and org owners/admins can access all repos
|
||||
if (user.isSystemAdmin) {
|
||||
return await Repository.getOrgRepositories(organizationId);
|
||||
}
|
||||
|
||||
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
|
||||
if (orgMember?.role === 'owner' || orgMember?.role === 'admin') {
|
||||
return await Repository.getOrgRepositories(organizationId);
|
||||
}
|
||||
|
||||
const allRepos = await Repository.getOrgRepositories(organizationId);
|
||||
const accessibleRepos: Repository[] = [];
|
||||
|
||||
for (const repo of allRepos) {
|
||||
// Public repos are always accessible
|
||||
if (repo.isPublic) {
|
||||
accessibleRepos.push(repo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check direct permission
|
||||
const directPerm = await RepositoryPermission.findPermission(repo.id, userId);
|
||||
if (directPerm) {
|
||||
accessibleRepos.push(repo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check team access
|
||||
const teams = await Team.getOrgTeams(organizationId);
|
||||
for (const team of teams) {
|
||||
if (team.repositoryIds.includes(repo.id)) {
|
||||
const teamMember = await TeamMember.findMembership(team.id, userId);
|
||||
if (teamMember) {
|
||||
accessibleRepos.push(repo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accessibleRepos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role's permissions to the result
|
||||
*/
|
||||
private applyRole(result: IResolvedPermissions, role: TRepositoryRole): void {
|
||||
const roleHierarchy: Record<TRepositoryRole, number> = {
|
||||
reader: 1,
|
||||
developer: 2,
|
||||
maintainer: 3,
|
||||
admin: 4,
|
||||
};
|
||||
|
||||
const currentLevel = result.effectiveRole ? roleHierarchy[result.effectiveRole] : 0;
|
||||
const newLevel = roleHierarchy[role];
|
||||
|
||||
if (newLevel > currentLevel) {
|
||||
result.effectiveRole = role;
|
||||
}
|
||||
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
break;
|
||||
case 'maintainer':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
break;
|
||||
case 'developer':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
break;
|
||||
case 'reader':
|
||||
result.canRead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
ts/services/token.service.ts
Normal file
209
ts/services/token.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* TokenService - API token management with secure hashing
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { ApiToken, User } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface ICreateTokenOptions {
|
||||
userId: string;
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
createdIp?: string;
|
||||
}
|
||||
|
||||
export interface ITokenValidationResult {
|
||||
valid: boolean;
|
||||
token?: ApiToken;
|
||||
user?: User;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class TokenService {
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor(auditService?: AuditService) {
|
||||
this.auditService = auditService || new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token
|
||||
* Returns the raw token (only shown once) and the saved token record
|
||||
*/
|
||||
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
// Generate secure random token: srg_{64 hex chars}
|
||||
const randomBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const hexToken = Array.from(randomBytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const rawToken = `srg_${hexToken}`;
|
||||
|
||||
// Hash the token for storage
|
||||
const tokenHash = await this.hashToken(rawToken);
|
||||
const tokenPrefix = rawToken.substring(0, 12); // "srg_" + first 8 hex chars
|
||||
|
||||
// Create token record
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = options.userId;
|
||||
token.name = options.name;
|
||||
token.tokenHash = tokenHash;
|
||||
token.tokenPrefix = tokenPrefix;
|
||||
token.protocols = options.protocols;
|
||||
token.scopes = options.scopes;
|
||||
token.createdAt = new Date();
|
||||
token.createdIp = options.createdIp;
|
||||
token.usageCount = 0;
|
||||
token.isRevoked = false;
|
||||
|
||||
if (options.expiresInDays) {
|
||||
token.expiresAt = new Date(Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await token.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.logTokenCreated(token.id, token.name);
|
||||
|
||||
return { rawToken, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw token and return the token record and user
|
||||
*/
|
||||
public async validateToken(rawToken: string, ip?: string): Promise<ITokenValidationResult> {
|
||||
// Check token format
|
||||
if (!rawToken || !rawToken.startsWith('srg_') || rawToken.length !== 68) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'INVALID_TOKEN_FORMAT',
|
||||
errorMessage: 'Invalid token format',
|
||||
};
|
||||
}
|
||||
|
||||
// Hash and lookup
|
||||
const tokenHash = await this.hashToken(rawToken);
|
||||
const token = await ApiToken.findByHash(tokenHash);
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_NOT_FOUND',
|
||||
errorMessage: 'Token not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if (!token.isValid()) {
|
||||
if (token.isRevoked) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_REVOKED',
|
||||
errorMessage: 'Token has been revoked',
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_EXPIRED',
|
||||
errorMessage: 'Token has expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(token.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'USER_NOT_FOUND',
|
||||
errorMessage: 'Token owner not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'USER_INACTIVE',
|
||||
errorMessage: 'Token owner account is inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Record usage
|
||||
await token.recordUsage(ip);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tokens for a user (without sensitive data)
|
||||
*/
|
||||
public async getUserTokens(userId: string): Promise<ApiToken[]> {
|
||||
return await ApiToken.getUserTokens(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token
|
||||
*/
|
||||
public async revokeToken(tokenId: string, reason?: string): Promise<boolean> {
|
||||
const token = await ApiToken.getInstance({ id: tokenId });
|
||||
if (!token) return false;
|
||||
|
||||
await token.revoke(reason);
|
||||
await this.auditService.logTokenRevoked(token.id, token.name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
public async revokeAllUserTokens(userId: string, reason?: string): Promise<number> {
|
||||
const tokens = await ApiToken.getUserTokens(userId);
|
||||
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
|
||||
*/
|
||||
public checkTokenPermission(
|
||||
token: ApiToken,
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
): boolean {
|
||||
if (!token.hasProtocol(protocol)) return false;
|
||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token using SHA-256
|
||||
*/
|
||||
private async hashToken(rawToken: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(rawToken);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token prefix for display
|
||||
*/
|
||||
public static getTokenDisplay(tokenPrefix: string): string {
|
||||
return `${tokenPrefix}...`;
|
||||
}
|
||||
}
|
||||
94
ui/angular.json
Normal file
94
ui/angular.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"registry-ui": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/registry-ui",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "registry-ui:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "registry-ui:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
ui/package.json
Normal file
34
ui/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/compiler": "^19.0.0",
|
||||
"@angular/core": "^19.0.0",
|
||||
"@angular/forms": "^19.0.0",
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.0",
|
||||
"@angular/cli": "^19.0.0",
|
||||
"@angular/compiler-cli": "^19.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.6.0"
|
||||
}
|
||||
}
|
||||
6
ui/postcss.config.js
Normal file
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
ui/proxy.conf.json
Normal file
7
ui/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
0
ui/public/favicon.ico
Normal file
0
ui/public/favicon.ico
Normal file
10
ui/src/app/app.component.ts
Normal file
10
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
13
ui/src/app/app.config.ts
Normal file
13
ui/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideExperimentalZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
95
ui/src/app/app.routes.ts
Normal file
95
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./shared/components/layout/layout.component').then(
|
||||
(m) => m.LayoutComponent
|
||||
),
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.component').then(
|
||||
(m) => m.DashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'organizations',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/organizations/organizations.component').then(
|
||||
(m) => m.OrganizationsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId',
|
||||
loadComponent: () =>
|
||||
import('./features/organizations/organization-detail.component').then(
|
||||
(m) => m.OrganizationDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId/repositories/:repoId',
|
||||
loadComponent: () =>
|
||||
import('./features/repositories/repository-detail.component').then(
|
||||
(m) => m.RepositoryDetailComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/packages/packages.component').then(
|
||||
(m) => m.PackagesComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':packageId',
|
||||
loadComponent: () =>
|
||||
import('./features/packages/package-detail.component').then(
|
||||
(m) => m.PackageDetailComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
loadComponent: () =>
|
||||
import('./features/tokens/tokens.component').then(
|
||||
(m) => m.TokensComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then(
|
||||
(m) => m.SettingsComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'dashboard',
|
||||
},
|
||||
];
|
||||
21
ui/src/app/core/guards/auth.guard.ts
Normal file
21
ui/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = async () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to refresh the token
|
||||
const refreshed = await authService.refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
};
|
||||
59
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
59
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
HttpInterceptorFn,
|
||||
HttpRequest,
|
||||
HttpHandlerFn,
|
||||
HttpErrorResponse,
|
||||
} from '@angular/common/http';
|
||||
import { catchError, switchMap, throwError } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn
|
||||
) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
// Skip auth header for login/refresh endpoints
|
||||
if (req.url.includes('/auth/login') || req.url.includes('/auth/refresh')) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const token = authService.accessToken;
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Try to refresh the token
|
||||
return new Promise((resolve) => {
|
||||
authService.refreshAccessToken().then((success) => {
|
||||
if (success) {
|
||||
// Retry the request with new token
|
||||
const newToken = authService.accessToken;
|
||||
const retryReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
});
|
||||
resolve(next(retryReq));
|
||||
} else {
|
||||
// Redirect to login
|
||||
router.navigate(['/login']);
|
||||
resolve(throwError(() => error));
|
||||
}
|
||||
});
|
||||
}).then((result) => result as ReturnType<HttpHandlerFn>);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
226
ui/src/app/core/services/api.service.ts
Normal file
226
ui/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Types
|
||||
export interface IOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IRepository {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
protocols: string[];
|
||||
isPublic: boolean;
|
||||
packageCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: string;
|
||||
organizationId: string;
|
||||
repositoryId: string;
|
||||
latestVersion?: string;
|
||||
isPrivate: boolean;
|
||||
downloadCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
protocols: string[];
|
||||
expiresAt?: string;
|
||||
lastUsedAt?: string;
|
||||
usageCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IAuditLog {
|
||||
id: string;
|
||||
actorId?: string;
|
||||
actorType: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private readonly baseUrl = '/api/v1';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Organizations
|
||||
getOrganizations(): Observable<{ organizations: IOrganization[] }> {
|
||||
return this.http.get<{ organizations: IOrganization[] }>(
|
||||
`${this.baseUrl}/organizations`
|
||||
);
|
||||
}
|
||||
|
||||
getOrganization(id: string): Observable<IOrganization> {
|
||||
return this.http.get<IOrganization>(`${this.baseUrl}/organizations/${id}`);
|
||||
}
|
||||
|
||||
createOrganization(data: {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}): Observable<IOrganization> {
|
||||
return this.http.post<IOrganization>(`${this.baseUrl}/organizations`, data);
|
||||
}
|
||||
|
||||
updateOrganization(
|
||||
id: string,
|
||||
data: Partial<IOrganization>
|
||||
): Observable<IOrganization> {
|
||||
return this.http.put<IOrganization>(
|
||||
`${this.baseUrl}/organizations/${id}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
deleteOrganization(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(
|
||||
`${this.baseUrl}/organizations/${id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Repositories
|
||||
getRepositories(orgId: string): Observable<{ repositories: IRepository[] }> {
|
||||
return this.http.get<{ repositories: IRepository[] }>(
|
||||
`${this.baseUrl}/organizations/${orgId}/repositories`
|
||||
);
|
||||
}
|
||||
|
||||
getRepository(id: string): Observable<IRepository> {
|
||||
return this.http.get<IRepository>(`${this.baseUrl}/repositories/${id}`);
|
||||
}
|
||||
|
||||
createRepository(
|
||||
orgId: string,
|
||||
data: {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
protocols?: string[];
|
||||
isPublic?: boolean;
|
||||
}
|
||||
): Observable<IRepository> {
|
||||
return this.http.post<IRepository>(
|
||||
`${this.baseUrl}/organizations/${orgId}/repositories`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
updateRepository(
|
||||
id: string,
|
||||
data: Partial<IRepository>
|
||||
): Observable<IRepository> {
|
||||
return this.http.put<IRepository>(`${this.baseUrl}/repositories/${id}`, data);
|
||||
}
|
||||
|
||||
deleteRepository(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(
|
||||
`${this.baseUrl}/repositories/${id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Packages
|
||||
searchPackages(params?: {
|
||||
q?: string;
|
||||
protocol?: string;
|
||||
organizationId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Observable<{ packages: IPackage[]; total: number }> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.q) httpParams = httpParams.set('q', params.q);
|
||||
if (params?.protocol) httpParams = httpParams.set('protocol', params.protocol);
|
||||
if (params?.organizationId)
|
||||
httpParams = httpParams.set('organizationId', params.organizationId);
|
||||
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());
|
||||
if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString());
|
||||
|
||||
return this.http.get<{ packages: IPackage[]; total: number }>(
|
||||
`${this.baseUrl}/packages`,
|
||||
{ params: httpParams }
|
||||
);
|
||||
}
|
||||
|
||||
getPackage(id: string): Observable<IPackage> {
|
||||
return this.http.get<IPackage>(
|
||||
`${this.baseUrl}/packages/${encodeURIComponent(id)}`
|
||||
);
|
||||
}
|
||||
|
||||
deletePackage(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(
|
||||
`${this.baseUrl}/packages/${encodeURIComponent(id)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Tokens
|
||||
getTokens(): Observable<{ tokens: IToken[] }> {
|
||||
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
|
||||
}
|
||||
|
||||
createToken(data: {
|
||||
name: string;
|
||||
protocols: string[];
|
||||
scopes: { protocol: string; actions: string[] }[];
|
||||
expiresInDays?: number;
|
||||
}): Observable<IToken & { token: string }> {
|
||||
return this.http.post<IToken & { token: string }>(
|
||||
`${this.baseUrl}/tokens`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
revokeToken(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.baseUrl}/tokens/${id}`);
|
||||
}
|
||||
|
||||
// Audit
|
||||
getAuditLogs(params?: {
|
||||
organizationId?: string;
|
||||
resourceType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Observable<{ logs: IAuditLog[]; total: number }> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.organizationId)
|
||||
httpParams = httpParams.set('organizationId', params.organizationId);
|
||||
if (params?.resourceType)
|
||||
httpParams = httpParams.set('resourceType', params.resourceType);
|
||||
if (params?.startDate) httpParams = httpParams.set('startDate', params.startDate);
|
||||
if (params?.endDate) httpParams = httpParams.set('endDate', params.endDate);
|
||||
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());
|
||||
if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString());
|
||||
|
||||
return this.http.get<{ logs: IAuditLog[]; total: number }>(
|
||||
`${this.baseUrl}/audit`,
|
||||
{ params: httpParams }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
ui/src/app/core/services/auth.service.ts
Normal file
148
ui/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isSystemAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface ILoginResponse {
|
||||
user: IUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly _user = signal<IUser | null>(null);
|
||||
private readonly _accessToken = signal<string | null>(null);
|
||||
private readonly _refreshToken = signal<string | null>(null);
|
||||
private readonly _sessionId = signal<string | null>(null);
|
||||
|
||||
readonly user = this._user.asReadonly();
|
||||
readonly isAuthenticated = computed(() => !!this._accessToken());
|
||||
readonly isAdmin = computed(() => this._user()?.isSystemAdmin ?? false);
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private router: Router
|
||||
) {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this._accessToken();
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<ILoginResponse>('/api/v1/auth/login', { email, password })
|
||||
);
|
||||
|
||||
this._user.set(response.user);
|
||||
this._accessToken.set(response.accessToken);
|
||||
this._refreshToken.set(response.refreshToken);
|
||||
this._sessionId.set(response.sessionId);
|
||||
|
||||
this.saveToStorage();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
const sessionId = this._sessionId();
|
||||
if (sessionId) {
|
||||
await firstValueFrom(
|
||||
this.http.post('/api/v1/auth/logout', { sessionId })
|
||||
).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
this.clearAuth();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<boolean> {
|
||||
const refreshToken = this._refreshToken();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{ accessToken: string }>('/api/v1/auth/refresh', {
|
||||
refreshToken,
|
||||
})
|
||||
);
|
||||
|
||||
this._accessToken.set(response.accessToken);
|
||||
this.saveToStorage();
|
||||
return true;
|
||||
} catch {
|
||||
this.clearAuth();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCurrentUser(): Promise<IUser | null> {
|
||||
try {
|
||||
const user = await firstValueFrom(
|
||||
this.http.get<IUser>('/api/v1/auth/me')
|
||||
);
|
||||
this._user.set(user);
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
const userJson = localStorage.getItem('user');
|
||||
|
||||
if (accessToken) this._accessToken.set(accessToken);
|
||||
if (refreshToken) this._refreshToken.set(refreshToken);
|
||||
if (sessionId) this._sessionId.set(sessionId);
|
||||
if (userJson) {
|
||||
try {
|
||||
this._user.set(JSON.parse(userJson));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private saveToStorage(): void {
|
||||
const accessToken = this._accessToken();
|
||||
const refreshToken = this._refreshToken();
|
||||
const sessionId = this._sessionId();
|
||||
const user = this._user();
|
||||
|
||||
if (accessToken) localStorage.setItem('accessToken', accessToken);
|
||||
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
|
||||
if (sessionId) localStorage.setItem('sessionId', sessionId);
|
||||
if (user) localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
private clearAuth(): void {
|
||||
this._user.set(null);
|
||||
this._accessToken.set(null);
|
||||
this._refreshToken.set(null);
|
||||
this._sessionId.set(null);
|
||||
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
50
ui/src/app/core/services/toast.service.ts
Normal file
50
ui/src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface IToast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private _toasts = signal<IToast[]>([]);
|
||||
readonly toasts = this._toasts.asReadonly();
|
||||
|
||||
show(toast: Omit<IToast, 'id'>): void {
|
||||
const id = crypto.randomUUID();
|
||||
const newToast: IToast = { ...toast, id };
|
||||
this._toasts.update((toasts) => [...toasts, newToast]);
|
||||
|
||||
const duration = toast.duration ?? 5000;
|
||||
if (duration > 0) {
|
||||
setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
success(title: string, message?: string): void {
|
||||
this.show({ type: 'success', title, message });
|
||||
}
|
||||
|
||||
error(title: string, message?: string): void {
|
||||
this.show({ type: 'error', title, message });
|
||||
}
|
||||
|
||||
warning(title: string, message?: string): void {
|
||||
this.show({ type: 'warning', title, message });
|
||||
}
|
||||
|
||||
info(title: string, message?: string): void {
|
||||
this.show({ type: 'info', title, message });
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this._toasts.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._toasts.set([]);
|
||||
}
|
||||
}
|
||||
220
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
220
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, type IOrganization, type IPackage } from '../../core/services/api.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">Welcome back, {{ userName() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card card-content">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Organizations</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organizations().length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-content">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Packages</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ packages().length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-content">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Downloads</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ totalDownloads() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-content">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Last Activity</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">Today</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Packages -->
|
||||
<div class="card">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Packages</h2>
|
||||
<a routerLink="/packages" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="card-content p-0">
|
||||
@if (packages().length === 0) {
|
||||
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
No packages yet
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (pkg of packages().slice(0, 5); track pkg.id) {
|
||||
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ pkg.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }}</p>
|
||||
</div>
|
||||
<span class="badge-default">{{ pkg.downloadCount }} downloads</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organizations -->
|
||||
<div class="card">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Your Organizations</h2>
|
||||
<a routerLink="/organizations" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="card-content p-0">
|
||||
@if (organizations().length === 0) {
|
||||
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
No organizations yet
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (org of organizations().slice(0, 5); track org.id) {
|
||||
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<a [routerLink]="['/organizations', org.id]" class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ org.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ org.displayName }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ org.memberCount }} members</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a routerLink="/organizations" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" 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>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Create Organization</span>
|
||||
</a>
|
||||
|
||||
<a routerLink="/tokens" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Generate API Token</span>
|
||||
</a>
|
||||
|
||||
<a routerLink="/packages" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Search Packages</span>
|
||||
</a>
|
||||
|
||||
<a routerLink="/settings" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Account Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
organizations = signal<IOrganization[]>([]);
|
||||
packages = signal<IPackage[]>([]);
|
||||
totalDownloads = signal(0);
|
||||
|
||||
userName = () => this.authService.user()?.displayName || 'User';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const [orgsResponse, packagesResponse] = await Promise.all([
|
||||
this.apiService.getOrganizations().toPromise(),
|
||||
this.apiService.searchPackages({ limit: 10 }).toPromise(),
|
||||
]);
|
||||
|
||||
this.organizations.set(orgsResponse?.organizations || []);
|
||||
this.packages.set(packagesResponse?.packages || []);
|
||||
|
||||
const totalDownloads = (packagesResponse?.packages || []).reduce(
|
||||
(sum, pkg) => sum + pkg.downloadCount,
|
||||
0
|
||||
);
|
||||
this.totalDownloads.set(totalDownloads);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
ui/src/app/features/login/login.component.ts
Normal file
121
ui/src/app/features/login/login.component.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Stack.Gallery Registry</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading()"
|
||||
class="btn-primary btn-md w-full"
|
||||
>
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
Enterprise Package Registry
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.email || !this.password) {
|
||||
this.error.set('Please enter your email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const success = await this.authService.login(this.email, this.password);
|
||||
|
||||
if (success) {
|
||||
this.toastService.success('Welcome back!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.error.set('Invalid email or password');
|
||||
}
|
||||
} catch (err) {
|
||||
this.error.set('An error occurred. Please try again.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, type IOrganization, type IRepository } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (organization()) {
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ organization()!.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">@{{ organization()!.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (organization()!.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
} @else {
|
||||
<span class="badge-warning">Private</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (organization()!.description) {
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p>
|
||||
}
|
||||
|
||||
<!-- Repositories Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2>
|
||||
<button class="btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-1.5" 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>
|
||||
New Repository
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (repositories().length === 0) {
|
||||
<div class="card card-content text-center py-8">
|
||||
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (repo of repositories(); track repo.id) {
|
||||
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p>
|
||||
</div>
|
||||
@if (repo.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
}
|
||||
</div>
|
||||
@if (repo.description) {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p>
|
||||
}
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
{{ repo.packageCount }} packages
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
@for (protocol of repo.protocols; track protocol) {
|
||||
<span class="badge-primary text-xs">{{ protocol }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OrganizationDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
organization = signal<IOrganization | null>(null);
|
||||
repositories = signal<IRepository[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const orgId = this.route.snapshot.paramMap.get('orgId');
|
||||
if (orgId) {
|
||||
this.loadData(orgId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(orgId: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [org, reposResponse] = await Promise.all([
|
||||
this.apiService.getOrganization(orgId).toPromise(),
|
||||
this.apiService.getRepositories(orgId).toPromise(),
|
||||
]);
|
||||
this.organization.set(org || null);
|
||||
this.repositories.set(reposResponse?.repositories || []);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load organization');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
210
ui/src/app/features/organizations/organizations.component.ts
Normal file
210
ui/src/app/features/organizations/organizations.component.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService, type IOrganization } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-organizations',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Organizations</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage your organizations and repositories</p>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
<svg class="w-5 h-5 mr-2" 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>
|
||||
New Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (organizations().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No organizations yet</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Create your first organization to start managing packages</p>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
Create Organization
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@for (org of organizations(); track org.id) {
|
||||
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-lg font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ org.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ org.displayName }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">@{{ org.name }}</p>
|
||||
</div>
|
||||
@if (org.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
} @else {
|
||||
<span class="badge-warning">Private</span>
|
||||
}
|
||||
</div>
|
||||
@if (org.description) {
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{{ org.description }}</p>
|
||||
}
|
||||
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{{ org.memberCount }} members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-md mx-4">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create Organization</h2>
|
||||
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<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>
|
||||
<form (ngSubmit)="createOrganization()" class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newOrg.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="my-organization"
|
||||
required
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newOrg.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="My Organization"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Description</label>
|
||||
<textarea
|
||||
[(ngModel)]="newOrg.description"
|
||||
name="description"
|
||||
class="input min-h-[80px]"
|
||||
placeholder="Optional description..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="newOrg.isPublic"
|
||||
name="isPublic"
|
||||
id="isPublic"
|
||||
class="w-4 h-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="isPublic" class="text-sm text-gray-700 dark:text-gray-300">Make this organization public</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="showCreateModal.set(false)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="createOrganization()" [disabled]="creating()" class="btn-primary btn-md">
|
||||
@if (creating()) {
|
||||
Creating...
|
||||
} @else {
|
||||
Create
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OrganizationsComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
organizations = signal<IOrganization[]>([]);
|
||||
loading = signal(true);
|
||||
showCreateModal = signal(false);
|
||||
creating = signal(false);
|
||||
|
||||
newOrg = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadOrganizations();
|
||||
}
|
||||
|
||||
private async loadOrganizations(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.apiService.getOrganizations().toPromise();
|
||||
this.organizations.set(response?.organizations || []);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load organizations');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async createOrganization(): Promise<void> {
|
||||
if (!this.newOrg.name) return;
|
||||
|
||||
this.creating.set(true);
|
||||
try {
|
||||
const org = await this.apiService.createOrganization({
|
||||
name: this.newOrg.name,
|
||||
displayName: this.newOrg.displayName || this.newOrg.name,
|
||||
description: this.newOrg.description,
|
||||
isPublic: this.newOrg.isPublic,
|
||||
}).toPromise();
|
||||
|
||||
if (org) {
|
||||
this.organizations.update((orgs) => [...orgs, org]);
|
||||
this.toastService.success('Organization created successfully');
|
||||
this.showCreateModal.set(false);
|
||||
this.newOrg = { name: '', displayName: '', description: '', isPublic: false };
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to create organization');
|
||||
} finally {
|
||||
this.creating.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
186
ui/src/app/features/packages/package-detail.component.ts
Normal file
186
ui/src/app/features/packages/package-detail.component.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, type IPackage } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-package-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (pkg()) {
|
||||
<div class="mb-6">
|
||||
<a routerLink="/packages" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
← Back to packages
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ pkg()!.name }}</h1>
|
||||
<span class="badge-primary">{{ pkg()!.protocol }}</span>
|
||||
@if (pkg()!.isPrivate) {
|
||||
<span class="badge-warning">Private</span>
|
||||
}
|
||||
</div>
|
||||
@if (pkg()!.description) {
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ pkg()!.description }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Installation -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Installation</h2>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@switch (pkg()!.protocol) {
|
||||
@case ('npm') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
npm install {{ pkg()!.name }}
|
||||
</code>
|
||||
}
|
||||
@case ('oci') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
docker pull registry.stack.gallery/{{ pkg()!.name }}:{{ pkg()!.latestVersion || 'latest' }}
|
||||
</code>
|
||||
}
|
||||
@case ('maven') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
<dependency><br/>
|
||||
<groupId>{{ pkg()!.name.split(':')[0] }}</groupId><br/>
|
||||
<artifactId>{{ pkg()!.name.split(':')[1] || pkg()!.name }}</artifactId><br/>
|
||||
<version>{{ pkg()!.latestVersion || 'LATEST' }}</version><br/>
|
||||
</dependency>
|
||||
</code>
|
||||
}
|
||||
@case ('pypi') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
pip install {{ pkg()!.name }}
|
||||
</code>
|
||||
}
|
||||
@case ('cargo') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
cargo add {{ pkg()!.name }}
|
||||
</code>
|
||||
}
|
||||
@case ('composer') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
composer require {{ pkg()!.name }}
|
||||
</code>
|
||||
}
|
||||
@case ('rubygems') {
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
|
||||
gem install {{ pkg()!.name }}
|
||||
</code>
|
||||
}
|
||||
@default {
|
||||
<p class="text-gray-500 dark:text-gray-400">Installation instructions not available</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Versions</h2>
|
||||
</div>
|
||||
<div class="card-content p-0">
|
||||
@if (versions().length === 0) {
|
||||
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
No versions published yet
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (version of versions(); track version.version) {
|
||||
<li class="px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm">{{ version.version }}</span>
|
||||
@if (version.version === pkg()!.latestVersion) {
|
||||
<span class="badge-success">latest</span>
|
||||
}
|
||||
</div>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ version.downloads }} downloads
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<div class="card card-content">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Stats</h3>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Downloads</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ pkg()!.downloadCount }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Latest version</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ pkg()!.latestVersion || 'N/A' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Last updated</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(pkg()!.updatedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PackageDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
pkg = signal<IPackage | null>(null);
|
||||
versions = signal<{ version: string; downloads: number }[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const packageId = this.route.snapshot.paramMap.get('packageId');
|
||||
if (packageId) {
|
||||
this.loadPackage(packageId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPackage(packageId: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const pkg = await this.apiService.getPackage(packageId).toPromise();
|
||||
this.pkg.set(pkg || null);
|
||||
// Versions would come from the full package response in a real implementation
|
||||
this.versions.set([]);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load package');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
179
ui/src/app/features/packages/packages.component.ts
Normal file
179
ui/src/app/features/packages/packages.component.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService, type IPackage } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-packages',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Packages</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">Browse and search all available packages</p>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card card-content mb-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="search()"
|
||||
class="input"
|
||||
placeholder="Search packages..."
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
[(ngModel)]="selectedProtocol"
|
||||
(ngModelChange)="search()"
|
||||
class="input w-full sm:w-40"
|
||||
>
|
||||
<option value="">All protocols</option>
|
||||
<option value="npm">npm</option>
|
||||
<option value="oci">OCI</option>
|
||||
<option value="maven">Maven</option>
|
||||
<option value="cargo">Cargo</option>
|
||||
<option value="composer">Composer</option>
|
||||
<option value="pypi">PyPI</option>
|
||||
<option value="rubygems">RubyGems</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (packages().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No packages found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
@if (searchQuery || selectedProtocol) {
|
||||
Try adjusting your search or filters
|
||||
} @else {
|
||||
Publish your first package to get started
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (pkg of packages(); track pkg.id) {
|
||||
<li>
|
||||
<a [routerLink]="['/packages', pkg.id]" class="block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100 truncate">{{ pkg.name }}</h3>
|
||||
<span class="badge-primary">{{ pkg.protocol }}</span>
|
||||
@if (pkg.isPrivate) {
|
||||
<span class="badge-warning">Private</span>
|
||||
}
|
||||
</div>
|
||||
@if (pkg.description) {
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{{ pkg.description }}</p>
|
||||
}
|
||||
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{{ pkg.latestVersion || 'No versions' }}</span>
|
||||
<span>{{ pkg.downloadCount }} downloads</span>
|
||||
<span>Updated {{ formatDate(pkg.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (total() > packages().length) {
|
||||
<div class="mt-6 text-center">
|
||||
<button (click)="loadMore()" class="btn-secondary btn-md">
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PackagesComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
packages = signal<IPackage[]>([]);
|
||||
total = signal(0);
|
||||
loading = signal(true);
|
||||
|
||||
searchQuery = '';
|
||||
selectedProtocol = '';
|
||||
private offset = 0;
|
||||
private readonly limit = 20;
|
||||
private searchTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPackages();
|
||||
}
|
||||
|
||||
search(): void {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.offset = 0;
|
||||
this.loadPackages();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async loadPackages(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.apiService.searchPackages({
|
||||
q: this.searchQuery || undefined,
|
||||
protocol: this.selectedProtocol || undefined,
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
}).toPromise();
|
||||
|
||||
if (this.offset === 0) {
|
||||
this.packages.set(response?.packages || []);
|
||||
} else {
|
||||
this.packages.update((pkgs) => [...pkgs, ...(response?.packages || [])]);
|
||||
}
|
||||
this.total.set(response?.total || 0);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load packages');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
this.offset += this.limit;
|
||||
this.loadPackages();
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'today';
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
121
ui/src/app/features/repositories/repository-detail.component.ts
Normal file
121
ui/src/app/features/repositories/repository-detail.component.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, type IRepository, type IPackage } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-repository-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (repository()) {
|
||||
<div class="mb-6">
|
||||
<a [routerLink]="['/organizations', repository()!.organizationId]" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
← Back to organization
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repository()!.displayName }}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ repository()!.name }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@for (protocol of repository()!.protocols; track protocol) {
|
||||
<span class="badge-primary">{{ protocol }}</span>
|
||||
}
|
||||
@if (repository()!.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (repository()!.description) {
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ repository()!.description }}</p>
|
||||
}
|
||||
|
||||
<!-- Packages -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Packages</h2>
|
||||
</div>
|
||||
<div class="card-content p-0">
|
||||
@if (packages().length === 0) {
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No packages in this repository yet
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (pkg of packages(); track pkg.id) {
|
||||
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ pkg.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ pkg.downloadCount }} downloads
|
||||
</span>
|
||||
<a [routerLink]="['/packages', pkg.id]" class="btn-ghost btn-sm">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RepositoryDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
repository = signal<IRepository | null>(null);
|
||||
packages = signal<IPackage[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const repoId = this.route.snapshot.paramMap.get('repoId');
|
||||
if (repoId) {
|
||||
this.loadData(repoId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(repoId: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const repo = await this.apiService.getRepository(repoId).toPromise();
|
||||
this.repository.set(repo || null);
|
||||
|
||||
if (repo) {
|
||||
const packagesResponse = await this.apiService.searchPackages({
|
||||
organizationId: repo.organizationId,
|
||||
}).toPromise();
|
||||
this.packages.set(
|
||||
(packagesResponse?.packages || []).filter((p) => p.repositoryId === repoId)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load repository');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
218
ui/src/app/features/settings/settings.component.ts
Normal file
218
ui/src/app/features/settings/settings.component.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService, type IUser } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Account Settings</h1>
|
||||
|
||||
<!-- Profile Section -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-header">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Profile</h2>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="flex items-center gap-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ userInitial() }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ user()?.displayName }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ user()?.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="displayName"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="user()?.username"
|
||||
class="input bg-gray-50 dark:bg-gray-900"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Username cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
[value]="user()?.email"
|
||||
class="input bg-gray-50 dark:bg-gray-900"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Contact support to change your email</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end">
|
||||
<button (click)="saveProfile()" [disabled]="saving()" class="btn-primary btn-md">
|
||||
@if (saving()) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save Changes
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Section -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-header">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Security</h2>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="currentPassword"
|
||||
class="input"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newPassword"
|
||||
class="input"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="confirmPassword"
|
||||
class="input"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end">
|
||||
<button (click)="changePassword()" [disabled]="changingPassword()" class="btn-primary btn-md">
|
||||
@if (changingPassword()) {
|
||||
Changing...
|
||||
} @else {
|
||||
Change Password
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions Section -->
|
||||
<div class="card">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Sessions</h2>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Sign out of all other browser sessions. This will not affect your current session.
|
||||
</p>
|
||||
<button (click)="logoutAllSessions()" class="btn-secondary btn-md">
|
||||
Sign out other sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="card mt-6 border-red-200 dark:border-red-800">
|
||||
<div class="card-header bg-red-50 dark:bg-red-900/20">
|
||||
<h2 class="font-semibold text-red-700 dark:text-red-400">Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Once you delete your account, there is no going back. Please be certain.
|
||||
</p>
|
||||
<button class="btn-md bg-red-600 text-white hover:bg-red-700">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
user = this.authService.user;
|
||||
displayName = '';
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
|
||||
saving = signal(false);
|
||||
changingPassword = signal(false);
|
||||
|
||||
userInitial = () => {
|
||||
const name = this.user()?.displayName || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.displayName = this.user()?.displayName || '';
|
||||
}
|
||||
|
||||
async saveProfile(): Promise<void> {
|
||||
this.saving.set(true);
|
||||
try {
|
||||
// Would call API to update profile
|
||||
this.toastService.success('Profile updated');
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update profile');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(): Promise<void> {
|
||||
if (!this.currentPassword || !this.newPassword) {
|
||||
this.toastService.error('Please fill in all password fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.newPassword !== this.confirmPassword) {
|
||||
this.toastService.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword.set(true);
|
||||
try {
|
||||
// Would call API to change password
|
||||
this.toastService.success('Password changed');
|
||||
this.currentPassword = '';
|
||||
this.newPassword = '';
|
||||
this.confirmPassword = '';
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to change password');
|
||||
} finally {
|
||||
this.changingPassword.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async logoutAllSessions(): Promise<void> {
|
||||
try {
|
||||
// Would call API to logout all sessions
|
||||
this.toastService.success('Signed out of all other sessions');
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to sign out');
|
||||
}
|
||||
}
|
||||
}
|
||||
280
ui/src/app/features/tokens/tokens.component.ts
Normal file
280
ui/src/app/features/tokens/tokens.component.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService, type IToken } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">API Tokens</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage your API tokens for registry access</p>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
<svg class="w-5 h-5 mr-2" 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>
|
||||
New Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (tokens().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No API tokens</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Create a token to authenticate with the registry</p>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">Create Token</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@for (token of tokens(); track token.id) {
|
||||
<li class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ token.name }}</h3>
|
||||
@for (protocol of token.protocols.slice(0, 3); track protocol) {
|
||||
<span class="badge-primary text-xs">{{ protocol }}</span>
|
||||
}
|
||||
@if (token.protocols.length > 3) {
|
||||
<span class="badge-default text-xs">+{{ token.protocols.length - 3 }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<code class="font-mono">{{ token.tokenPrefix }}...</code>
|
||||
@if (token.expiresAt) {
|
||||
<span class="mx-2">·</span>
|
||||
<span>Expires {{ formatDate(token.expiresAt) }}</span>
|
||||
}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Created {{ formatDate(token.createdAt) }}
|
||||
@if (token.lastUsedAt) {
|
||||
· Last used {{ formatDate(token.lastUsedAt) }}
|
||||
}
|
||||
· {{ token.usageCount }} uses
|
||||
</p>
|
||||
</div>
|
||||
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-lg mx-4">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create API Token</h2>
|
||||
<button (click)="closeCreateModal()" class="btn-ghost btn-sm p-1">
|
||||
<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 class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Token Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newToken.name"
|
||||
class="input"
|
||||
placeholder="my-ci-token"
|
||||
/>
|
||||
</div>
|
||||
<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="flex items-center gap-2 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
[class.bg-primary-50]="newToken.protocols.includes(protocol)"
|
||||
[class.border-primary-300]="newToken.protocols.includes(protocol)"
|
||||
[class.dark:bg-primary-900/20]="newToken.protocols.includes(protocol)">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="newToken.protocols.includes(protocol)"
|
||||
(change)="toggleProtocol(protocol)"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-sm">{{ protocol }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Expiration (optional)</label>
|
||||
<select [(ngModel)]="newToken.expiresInDays" class="input">
|
||||
<option [ngValue]="null">Never expires</option>
|
||||
<option [ngValue]="7">7 days</option>
|
||||
<option [ngValue]="30">30 days</option>
|
||||
<option [ngValue]="90">90 days</option>
|
||||
<option [ngValue]="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</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">
|
||||
@if (creating()) {
|
||||
Creating...
|
||||
} @else {
|
||||
Create Token
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Token Created Modal -->
|
||||
@if (createdToken()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-lg mx-4">
|
||||
<div class="card-header">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Token Created</h2>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-2">
|
||||
Make sure to copy your token now. You won't be able to see it again!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Your new token:</label>
|
||||
<div class="flex gap-2">
|
||||
<code class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono overflow-x-auto">
|
||||
{{ createdToken() }}
|
||||
</code>
|
||||
<button (click)="copyToken()" class="btn-secondary btn-md">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end">
|
||||
<button (click)="createdToken.set(null)" class="btn-primary btn-md">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TokensComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
tokens = signal<IToken[]>([]);
|
||||
loading = signal(true);
|
||||
showCreateModal = signal(false);
|
||||
creating = signal(false);
|
||||
createdToken = signal<string | null>(null);
|
||||
|
||||
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||
|
||||
newToken = {
|
||||
name: '',
|
||||
protocols: [] as string[],
|
||||
expiresInDays: null as number | null,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTokens();
|
||||
}
|
||||
|
||||
private async loadTokens(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.apiService.getTokens().toPromise();
|
||||
this.tokens.set(response?.tokens || []);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load tokens');
|
||||
} 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];
|
||||
}
|
||||
}
|
||||
|
||||
async createToken(): Promise<void> {
|
||||
if (!this.newToken.name || this.newToken.protocols.length === 0) return;
|
||||
|
||||
this.creating.set(true);
|
||||
try {
|
||||
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'],
|
||||
})),
|
||||
expiresInDays: this.newToken.expiresInDays || undefined,
|
||||
}).toPromise();
|
||||
|
||||
if (response) {
|
||||
this.createdToken.set(response.token);
|
||||
this.tokens.update((tokens) => [response, ...tokens]);
|
||||
this.showCreateModal.set(false);
|
||||
this.newToken = { name: '', protocols: [], expiresInDays: null };
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to create token');
|
||||
} finally {
|
||||
this.creating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async revokeToken(token: IToken): Promise<void> {
|
||||
if (!confirm(`Are you sure you want to revoke "${token.name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
await this.apiService.revokeToken(token.id).toPromise();
|
||||
this.tokens.update((tokens) => tokens.filter((t) => t.id !== token.id));
|
||||
this.toastService.success('Token revoked');
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to revoke token');
|
||||
}
|
||||
}
|
||||
|
||||
closeCreateModal(): void {
|
||||
this.showCreateModal.set(false);
|
||||
this.newToken = { name: '', protocols: [], expiresInDays: null };
|
||||
}
|
||||
|
||||
copyToken(): void {
|
||||
const token = this.createdToken();
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token);
|
||||
this.toastService.success('Token copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
115
ui/src/app/shared/components/layout/layout.component.ts
Normal file
115
ui/src/app/shared/components/layout/layout.component.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<div class="min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<!-- Logo -->
|
||||
<div class="h-16 flex items-center px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<a routerLink="/" class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-lg">Stack.Gallery</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-1">
|
||||
<a routerLink="/dashboard" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a routerLink="/organizations" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
Organizations
|
||||
</a>
|
||||
|
||||
<a routerLink="/packages" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
Packages
|
||||
</a>
|
||||
|
||||
<a routerLink="/tokens" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
API Tokens
|
||||
</a>
|
||||
|
||||
<a routerLink="/settings" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- User section -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ userInitial() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ userName() }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ userEmail() }}
|
||||
</p>
|
||||
</div>
|
||||
<button (click)="logout()" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 bg-gray-50 dark:bg-gray-900 overflow-auto">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LayoutComponent {
|
||||
private authService = inject(AuthService);
|
||||
|
||||
userName = computed(() => this.authService.user()?.displayName || 'User');
|
||||
userEmail = computed(() => this.authService.user()?.email || '');
|
||||
userInitial = computed(() => {
|
||||
const name = this.authService.user()?.displayName || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
}
|
||||
}
|
||||
17
ui/src/index.html
Normal file
17
ui/src/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Stack.Gallery Registry</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Enterprise-grade multi-protocol package registry">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 antialiased">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
7
ui/src/main.ts
Normal file
7
ui/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||
console.error(err)
|
||||
);
|
||||
144
ui/src/styles.css
Normal file
144
ui/src/styles.css
Normal file
@@ -0,0 +1,144 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 199 89% 48%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 199 89% 48%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 199 89% 48%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 199 89% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-800;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply h-8 px-3 text-xs;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
@apply h-10 px-4;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply h-12 px-6;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800
|
||||
px-3 py-2 text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||
disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800 shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply px-6 py-4 border-t border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
@apply badge bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply badge bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
|
||||
}
|
||||
|
||||
.badge-destructive {
|
||||
@apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
|
||||
}
|
||||
}
|
||||
44
ui/tailwind.config.js
Normal file
44
ui/tailwind.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,ts}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
accent: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3',
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
950: '#4a044e',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
9
ui/tsconfig.app.json
Normal file
9
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
28
ui/tsconfig.json
Normal file
28
ui/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"],
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user