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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user