diff --git a/webapp/Makefile b/webapp/Makefile index 57218b7..ad303d9 100644 --- a/webapp/Makefile +++ b/webapp/Makefile @@ -6,7 +6,7 @@ NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD) HASH=$(shell git rev-parse HEAD) run: - . ${HOME}/.nvm/nvm.sh && nvm use && npm i && npm run dev + . ${HOME}/.nvm/nvm.sh && nvm use && npm i && npm run start setup: npm install diff --git a/webapp/src/app/app.routes.ts b/webapp/src/app/app.routes.ts index 09f1f11..ad7df30 100644 --- a/webapp/src/app/app.routes.ts +++ b/webapp/src/app/app.routes.ts @@ -33,14 +33,26 @@ export const routes: Routes = [ path: 'subscriptions', loadComponent: () => import('./features/subscriptions/subscription-list/subscription-list.component').then(m => m.SubscriptionListComponent) }, + { + path: 'subscriptions/:id', + loadComponent: () => import('./features/subscriptions/subscription-detail/subscription-detail.component').then(m => m.SubscriptionDetailComponent) + }, { path: 'keys', loadComponent: () => import('./features/keys/key-list/key-list.component').then(m => m.KeyListComponent) }, + { + path: 'keys/:id', + loadComponent: () => import('./features/keys/key-detail/key-detail.component').then(m => m.KeyDetailComponent) + }, { path: 'clients', loadComponent: () => import('./features/clients/client-list/client-list.component').then(m => m.ClientListComponent) }, + { + path: 'clients/:id', + loadComponent: () => import('./features/clients/client-detail/client-detail.component').then(m => m.ClientDetailComponent) + }, { path: 'senders', loadComponent: () => import('./features/senders/sender-list/sender-list.component').then(m => m.SenderListComponent) diff --git a/webapp/src/app/core/models/subscription.model.ts b/webapp/src/app/core/models/subscription.model.ts index d780cf2..cbade36 100644 --- a/webapp/src/app/core/models/subscription.model.ts +++ b/webapp/src/app/core/models/subscription.model.ts @@ -6,6 +6,7 @@ export interface Subscription { channel_internal_name: string; timestamp_created: string; confirmed: boolean; + active: boolean; } export interface SubscriptionFilter { diff --git a/webapp/src/app/core/services/key-cache.service.ts b/webapp/src/app/core/services/key-cache.service.ts new file mode 100644 index 0000000..2162c72 --- /dev/null +++ b/webapp/src/app/core/services/key-cache.service.ts @@ -0,0 +1,58 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, of, map, shareReplay, catchError } from 'rxjs'; +import { ApiService } from './api.service'; +import { AuthService } from './auth.service'; +import { KeyToken } from '../models'; + +export interface ResolvedKey { + keyId: string; + name: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class KeyCacheService { + private apiService = inject(ApiService); + private authService = inject(AuthService); + + private keysCache$: Observable> | null = null; + + resolveKey(keyId: string): Observable { + return this.getKeysMap().pipe( + map(keysMap => { + const key = keysMap.get(keyId); + return { + keyId, + name: key?.name || keyId + }; + }) + ); + } + + private getKeysMap(): Observable> { + const userId = this.authService.getUserId(); + if (!userId) { + return of(new Map()); + } + + if (!this.keysCache$) { + this.keysCache$ = this.apiService.getKeys(userId).pipe( + map(response => { + const map = new Map(); + for (const key of response.keys) { + map.set(key.keytoken_id, key); + } + return map; + }), + catchError(() => of(new Map())), + shareReplay(1) + ); + } + return this.keysCache$; + } + + clearCache(): void { + this.keysCache$ = null; + } +} diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.html b/webapp/src/app/features/channels/channel-detail/channel-detail.component.html index 8dbcc95..b1dbc18 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.html +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.html @@ -20,122 +20,123 @@ - - + + {{ channel()!.channel_id }} - - + + {{ channel()!.internal_name }} - - + + {{ getSubscriptionStatus().label }} - - + + {{ channel()!.owner_user_id }} - + @if (channel()!.description_name) { - + {{ channel()!.description_name }} - + } - + {{ channel()!.messages_sent }} - - + + @if (channel()!.timestamp_lastsent) { - {{ channel()!.timestamp_lastsent | relativeTime }} +
{{ channel()!.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ channel()!.timestamp_lastsent | relativeTime }}
} @else { Never } -
- - {{ channel()!.timestamp_created }} - -
+ + +
{{ channel()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ channel()!.timestamp_created | relativeTime }}
+
+ @if (isOwner() && channel()!.subscribe_key) { + +
+ + + + + + +
+ +
+
+
+ +
+ +

Scan with the SimpleCloudNotifier app to subscribe

+
+
+ } + @if (isOwner() && channel()!.send_key) { + +
+ + + + + + +
+ +
+
+
+ } +
@if (isOwner()) { - - @if (channel()!.subscribe_key) { -
- - - - - - - -
- -
-
- -

Scan this QR code with the SimpleCloudNotifier app to subscribe to this channel.

-
-
- } - - @if (channel()!.send_key) { - -
- - - - - - - -
- -
-
- } -
- Subscriber - Status - Created + Status + Created @for (sub of subscriptions(); track sub.subscription_id) { - {{ sub.subscriber_user_id }} +
{{ getUserDisplayName(sub.subscriber_user_id) }}
+
{{ sub.subscriber_user_id }}
{{ sub.confirmed ? 'Confirmed' : 'Pending' }} - {{ sub.timestamp_created | relativeTime }} + +
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sub.timestamp_created | relativeTime }}
+ } @empty { diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss b/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss index e22c986..258d3f0 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss @@ -10,17 +10,14 @@ gap: 8px; } -.key-section { - label { - display: block; - font-weight: 500; - margin-bottom: 8px; - color: #333; - } +.key-field { + display: flex; + flex-direction: column; + gap: 8px; } .key-actions { - margin-top: 8px; + display: flex; } .action-icon { @@ -44,28 +41,36 @@ } } -.qr-section { - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid #f0f0f0; - - label { - display: block; - font-weight: 500; - margin-bottom: 12px; - color: #333; - } - - app-qr-code-display { - display: flex; - justify-content: center; - } +.qr-container { + display: flex; + flex-direction: column; + align-items: center; } .qr-hint { - text-align: center; color: #666; font-size: 13px; - margin-top: 12px; + margin-top: 8px; margin-bottom: 0; } + +.cell-name { + font-weight: 500; + color: #333; +} + +.cell-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts b/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts index e4db74a..5a96a6e 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts @@ -1,11 +1,10 @@ import { Component, inject, signal, computed, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzCardModule } from 'ng-zorro-antd/card'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; -import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { NzSpinModule } from 'ng-zorro-antd/spin'; import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; @@ -19,21 +18,23 @@ import { NzEmptyModule } from 'ng-zorro-antd/empty'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { ChannelWithSubscription, Subscription } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive'; import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @Component({ selector: 'app-channel-detail', standalone: true, imports: [ CommonModule, + DatePipe, FormsModule, NzCardModule, NzButtonModule, NzIconModule, - NzDescriptionsModule, NzTagModule, NzSpinModule, NzPopconfirmModule, @@ -47,6 +48,8 @@ import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-displ RelativeTimePipe, CopyToClipboardDirective, QrCodeDisplayComponent, + MetadataGridComponent, + MetadataValueComponent, ], templateUrl: './channel-detail.component.html', styleUrl: './channel-detail.component.scss' @@ -57,9 +60,11 @@ export class ChannelDetailComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private userCacheService = inject(UserCacheService); channel = signal(null); subscriptions = signal([]); + userNames = signal>(new Map()); loading = signal(true); loadingSubscriptions = signal(false); // Edit modal @@ -118,6 +123,7 @@ export class ChannelDetailComponent implements OnInit { next: (response) => { this.subscriptions.set(response.subscriptions); this.loadingSubscriptions.set(false); + this.resolveUserNames(response.subscriptions); }, error: () => { this.loadingSubscriptions.set(false); @@ -125,6 +131,23 @@ export class ChannelDetailComponent implements OnInit { }); } + private resolveUserNames(subscriptions: Subscription[]): void { + const userIds = new Set(); + for (const sub of subscriptions) { + userIds.add(sub.subscriber_user_id); + } + for (const id of userIds) { + this.userCacheService.resolveUser(id).subscribe(resolved => { + this.userNames.update(map => new Map(map).set(id, resolved)); + }); + } + } + + getUserDisplayName(userId: string): string { + const resolved = this.userNames().get(userId); + return resolved?.displayName || userId; + } + goBack(): void { this.router.navigate(['/channels']); } diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.html b/webapp/src/app/features/channels/channel-list/channel-list.component.html index 5ef0f8a..3141599 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.html +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.html @@ -21,12 +21,13 @@ - Name - Internal Name - Owner - Status - Messages - Last Sent + Name + Internal Name + Owner + Status + Subscribers + Messages + Last Sent @@ -34,9 +35,7 @@
{{ channel.display_name }}
- @if (channel.description_name) { -
{{ channel.description_name }}
- } +
{{ channel.channel_id }}
{{ channel.internal_name }} @@ -47,12 +46,18 @@ {{ getSubscriptionStatus(channel).label }} + + @if (isOwned(channel)) { + + } @else { + - + } + {{ channel.messages_sent }} @if (channel.timestamp_lastsent) { - - {{ channel.timestamp_lastsent | relativeTime }} - +
{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ channel.timestamp_lastsent | relativeTime }}
} @else { Never } @@ -60,7 +65,7 @@ } @empty { - + diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.scss b/webapp/src/app/features/channels/channel-list/channel-list.component.scss index 5ae9441..6b2c2d4 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.scss +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.scss @@ -23,12 +23,30 @@ color: #333; } -.channel-description { - font-size: 12px; +.channel-id { + font-size: 11px; color: #999; - margin-top: 4px; + margin-top: 2px; } .text-muted { color: #999; } + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } +} diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.ts b/webapp/src/app/features/channels/channel-list/channel-list.component.ts index 587156e..1df361e 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.ts +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.ts @@ -1,5 +1,5 @@ import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { Router } from '@angular/router'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -14,12 +14,14 @@ import { AuthService } from '../../../core/services/auth.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { ChannelWithSubscription } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subscribers.component'; @Component({ selector: 'app-channel-list', standalone: true, imports: [ CommonModule, + DatePipe, NzTableModule, NzButtonModule, NzIconModule, @@ -29,6 +31,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; NzCardModule, NzToolTipModule, RelativeTimePipe, + ChannelSubscribersComponent, ], templateUrl: './channel-list.component.html', styleUrl: './channel-list.component.scss' diff --git a/webapp/src/app/features/channels/channel-subscribers/channel-subscribers.component.ts b/webapp/src/app/features/channels/channel-subscribers/channel-subscribers.component.ts new file mode 100644 index 0000000..f0e02ec --- /dev/null +++ b/webapp/src/app/features/channels/channel-subscribers/channel-subscribers.component.ts @@ -0,0 +1,107 @@ +import { Component, inject, input, signal, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; +import { Subscription } from '../../../core/models'; + +@Component({ + selector: 'app-channel-subscribers', + standalone: true, + imports: [CommonModule, NzSpinModule, NzToolTipModule], + template: ` + @if (loading()) { + + } @else if (subscribers().length === 0) { + None + } @else { +
+ @for (sub of subscribers(); track sub.subscription_id) { + + {{ getDisplayName(sub.subscriber_user_id) }} + + } +
+ } + `, + styles: [` + .text-muted { + color: #999; + } + .subscribers-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + .subscriber { + background: #f0f0f0; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + } + .subscriber.unconfirmed { + background: #fff7e6; + color: #d48806; + } + `] +}) +export class ChannelSubscribersComponent implements OnInit { + private apiService = inject(ApiService); + private authService = inject(AuthService); + private userCacheService = inject(UserCacheService); + + channelId = input.required(); + + loading = signal(true); + subscribers = signal([]); + userNames = signal>(new Map()); + + ngOnInit(): void { + this.loadSubscribers(); + } + + private loadSubscribers(): void { + const userId = this.authService.getUserId(); + if (!userId) { + this.loading.set(false); + return; + } + + this.apiService.getChannelSubscriptions(userId, this.channelId()).subscribe({ + next: (response) => { + this.subscribers.set(response.subscriptions); + this.loading.set(false); + this.resolveUserNames(response.subscriptions); + }, + error: () => { + this.loading.set(false); + } + }); + } + + private resolveUserNames(subscriptions: Subscription[]): void { + const userIds = new Set(subscriptions.map(s => s.subscriber_user_id)); + for (const userId of userIds) { + this.userCacheService.resolveUser(userId).subscribe(resolved => { + this.userNames.update(map => new Map(map).set(userId, resolved)); + }); + } + } + + getDisplayName(userId: string): string { + const resolved = this.userNames().get(userId); + return resolved?.displayName || userId; + } + + getTooltip(sub: Subscription): string { + const status = sub.confirmed ? 'Confirmed' : 'Pending'; + return `${sub.subscriber_user_id} (${status})`; + } +} diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.html b/webapp/src/app/features/clients/client-detail/client-detail.component.html new file mode 100644 index 0000000..9156c11 --- /dev/null +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.html @@ -0,0 +1,72 @@ +
+ @if (loading()) { +
+ +
+ } @else if (client()) { +
+ +
+ +
+
+ + +
+ +

{{ client()!.name || 'Unnamed Client' }}

+ {{ getClientTypeLabel(client()!.type) }} +
+ + + + {{ client()!.client_id }} + + + {{ getClientTypeLabel(client()!.type) }} + + +
+ {{ client()!.agent_model }} + v{{ client()!.agent_version }} +
+
+ +
{{ client()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ client()!.timestamp_created | relativeTime }}
+
+ + + {{ client()!.fcm_token }} + + +
+
+ } @else { + +
+

Client not found

+ +
+
+ } +
diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.scss b/webapp/src/app/features/clients/client-detail/client-detail.component.scss new file mode 100644 index 0000000..2b61dc0 --- /dev/null +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.scss @@ -0,0 +1,67 @@ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.client-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.client-type-icon { + font-size: 24px; + color: #666; +} + +.client-title { + margin: 0; + font-size: 20px; + font-weight: 500; +} + +.agent-info { + display: flex; + flex-direction: column; + + .agent-version { + font-size: 12px; + color: #999; + } +} + +.fcm-token { + display: block; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.not-found { + text-align: center; + padding: 48px; + + p { + color: #999; + margin-bottom: 16px; + } +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.ts b/webapp/src/app/features/clients/client-detail/client-detail.component.ts new file mode 100644 index 0000000..800da64 --- /dev/null +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.ts @@ -0,0 +1,102 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { NotificationService } from '../../../core/services/notification.service'; +import { Client, ClientType, getClientTypeIcon } from '../../../core/models'; +import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; + +@Component({ + selector: 'app-client-detail', + standalone: true, + imports: [ + CommonModule, + DatePipe, + NzCardModule, + NzButtonModule, + NzIconModule, + NzTagModule, + NzSpinModule, + NzPopconfirmModule, + NzToolTipModule, + RelativeTimePipe, + MetadataGridComponent, + MetadataValueComponent, + ], + templateUrl: './client-detail.component.html', + styleUrl: './client-detail.component.scss' +}) +export class ClientDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private apiService = inject(ApiService); + private authService = inject(AuthService); + private notification = inject(NotificationService); + + client = signal(null); + loading = signal(true); + + ngOnInit(): void { + const clientId = this.route.snapshot.paramMap.get('id'); + if (clientId) { + this.loadClient(clientId); + } + } + + loadClient(clientId: string): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.loading.set(true); + this.apiService.getClient(userId, clientId).subscribe({ + next: (client) => { + this.client.set(client); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + } + }); + } + + goBack(): void { + this.router.navigate(['/clients']); + } + + getClientIcon(type: ClientType): string { + return getClientTypeIcon(type); + } + + getClientTypeLabel(type: ClientType): string { + switch (type) { + case 'ANDROID': return 'Android'; + case 'IOS': return 'iOS'; + case 'MACOS': return 'macOS'; + case 'WINDOWS': return 'Windows'; + case 'LINUX': return 'Linux'; + default: return type; + } + } + + deleteClient(): void { + const client = this.client(); + const userId = this.authService.getUserId(); + if (!client || !userId) return; + + this.apiService.deleteClient(userId, client.client_id).subscribe({ + next: () => { + this.notification.success('Client deleted'); + this.router.navigate(['/clients']); + } + }); + } +} diff --git a/webapp/src/app/features/clients/client-list/client-list.component.html b/webapp/src/app/features/clients/client-list/client-list.component.html index a38b72d..b61461d 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.html +++ b/webapp/src/app/features/clients/client-list/client-list.component.html @@ -19,17 +19,16 @@ - - Name - Type - Agent - Created - Client ID + + Name + Type + Agent + Created @for (client of clients(); track client.client_id) { - + - {{ client.name || '-' }} + +
{{ client.name || '-' }}
+
{{ client.client_id }}
+ {{ getClientTypeLabel(client.type) }} @@ -49,17 +51,13 @@ - - {{ client.timestamp_created | relativeTime }} - - - - {{ client.client_id }} +
{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ client.timestamp_created | relativeTime }}
} @empty { - + diff --git a/webapp/src/app/features/clients/client-list/client-list.component.scss b/webapp/src/app/features/clients/client-list/client-list.component.scss index 340a2aa..d07c2aa 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.scss +++ b/webapp/src/app/features/clients/client-list/client-list.component.scss @@ -24,7 +24,31 @@ } } +.client-name { + font-weight: 500; + color: #333; +} + .client-id { font-size: 11px; color: #999; + margin-top: 2px; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; } diff --git a/webapp/src/app/features/clients/client-list/client-list.component.ts b/webapp/src/app/features/clients/client-list/client-list.component.ts index e2d3470..dc5f7d7 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.ts +++ b/webapp/src/app/features/clients/client-list/client-list.component.ts @@ -1,5 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; +import { Router } from '@angular/router'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; @@ -17,6 +18,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; standalone: true, imports: [ CommonModule, + DatePipe, NzTableModule, NzButtonModule, NzIconModule, @@ -32,6 +34,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; export class ClientListComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); + private router = inject(Router); clients = signal([]); loading = signal(false); @@ -70,4 +73,8 @@ export class ClientListComponent implements OnInit { default: return type; } } + + openClient(clientId: string): void { + this.router.navigate(['/clients', clientId]); + } } diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.html b/webapp/src/app/features/keys/key-detail/key-detail.component.html new file mode 100644 index 0000000..4a789cb --- /dev/null +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.html @@ -0,0 +1,191 @@ +
+ @if (loading()) { +
+ +
+ } @else if (key()) { +
+ +
+ + @if (!isCurrentKey()) { + + } +
+
+ + +
+

{{ key()!.name }}

+ @if (isCurrentKey()) { + Current + } +
+ + + + {{ key()!.keytoken_id }} + + +
+ @for (perm of getPermissions(); track perm) { + + {{ perm }} + + } +
+
+ + @if (key()!.all_channels) { + All Channels + } @else if (key()!.channels && key()!.channels.length > 0) { +
+ @for (channelId of key()!.channels; track channelId) { + + {{ getChannelDisplayName(channelId) }} + + } +
+ } @else { + No channels + } +
+ + {{ key()!.messages_sent }} + + +
{{ key()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ key()!.timestamp_created | relativeTime }}
+
+ + @if (key()!.timestamp_lastused) { +
{{ key()!.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ key()!.timestamp_lastused | relativeTime }}
+ } @else { + Never + } +
+ + @if (resolvedOwner()) { +
{{ resolvedOwner()!.displayName }}
+
{{ key()!.owner_user_id }}
+ } @else { + {{ key()!.owner_user_id }} + } +
+
+
+ } @else { + +
+

Key not found

+ +
+
+ } +
+ + + + + + Name + + + + + + + Permissions + +
+ @for (opt of permissionOptions; track opt.value) { + + } +
+
+
+ + + + + + @if (!editKeyAllChannels) { + + Channels + + + @for (channel of availableChannels(); track channel.channel_id) { + + } + + + + } +
+
+ + + + + diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.scss b/webapp/src/app/features/keys/key-detail/key-detail.component.scss new file mode 100644 index 0000000..89f6976 --- /dev/null +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.scss @@ -0,0 +1,98 @@ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.key-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.key-title { + margin: 0; + font-size: 20px; + font-weight: 500; +} + +.permissions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.channel-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.text-muted { + color: #999; +} + +.owner-name { + font-weight: 500; + color: #333; +} + +.owner-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.not-found { + text-align: center; + padding: 48px; + + p { + color: #999; + margin-bottom: 16px; + } +} + +.permission-checkboxes { + display: flex; + flex-direction: column; + gap: 8px; + + label { + display: flex; + align-items: center; + margin-left: 0; + } + + nz-tag { + width: 32px; + text-align: center; + margin-right: 8px; + } + + .perm-label { + min-width: 100px; + } + + .perm-desc { + color: #999; + font-size: 12px; + } +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.ts b/webapp/src/app/features/keys/key-detail/key-detail.component.ts new file mode 100644 index 0000000..e4f154b --- /dev/null +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.ts @@ -0,0 +1,259 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; +import { NzModalModule } from 'ng-zorro-antd/modal'; +import { NzFormModule } from 'ng-zorro-antd/form'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzCheckboxModule } from 'ng-zorro-antd/checkbox'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { NotificationService } from '../../../core/services/notification.service'; +import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; +import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; +import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription } from '../../../core/models'; +import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; + +interface PermissionOption { + value: TokenPermission; + label: string; + description: string; +} + +@Component({ + selector: 'app-key-detail', + standalone: true, + imports: [ + CommonModule, + DatePipe, + FormsModule, + NzCardModule, + NzButtonModule, + NzIconModule, + NzTagModule, + NzSpinModule, + NzPopconfirmModule, + NzModalModule, + NzFormModule, + NzInputModule, + NzCheckboxModule, + NzSelectModule, + NzToolTipModule, + RelativeTimePipe, + MetadataGridComponent, + MetadataValueComponent, + ], + templateUrl: './key-detail.component.html', + styleUrl: './key-detail.component.scss' +}) +export class KeyDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private apiService = inject(ApiService); + private authService = inject(AuthService); + private notification = inject(NotificationService); + private channelCacheService = inject(ChannelCacheService); + private userCacheService = inject(UserCacheService); + + key = signal(null); + currentKeyId = signal(null); + loading = signal(true); + channelNames = signal>(new Map()); + availableChannels = signal([]); + resolvedOwner = signal(null); + + // Edit modal + showEditModal = signal(false); + editKeyName = ''; + editKeyPermissions: TokenPermission[] = []; + editKeyAllChannels = true; + editKeyChannels: string[] = []; + updating = signal(false); + + permissionOptions: PermissionOption[] = [ + { value: 'A', label: 'Admin', description: 'Full access to all operations' }, + { value: 'CR', label: 'Channel Read', description: 'Read messages from channels' }, + { value: 'CS', label: 'Channel Send', description: 'Send messages to channels' }, + { value: 'UR', label: 'User Read', description: 'Read user information' }, + ]; + + ngOnInit(): void { + const keyId = this.route.snapshot.paramMap.get('id'); + if (keyId) { + this.loadKey(keyId); + this.loadCurrentKey(); + this.loadAvailableChannels(); + } + } + + loadKey(keyId: string): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.loading.set(true); + this.apiService.getKey(userId, keyId).subscribe({ + next: (key) => { + this.key.set(key); + this.loading.set(false); + this.resolveChannelNames(key); + this.resolveOwner(key.owner_user_id); + }, + error: () => { + this.loading.set(false); + } + }); + } + + private resolveOwner(ownerId: string): void { + this.userCacheService.resolveUser(ownerId).subscribe(resolved => { + this.resolvedOwner.set(resolved); + }); + } + + loadCurrentKey(): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.apiService.getCurrentKey(userId).subscribe({ + next: (key) => { + this.currentKeyId.set(key.keytoken_id); + } + }); + } + + loadAvailableChannels(): void { + this.channelCacheService.getAllChannels().subscribe(channels => { + this.availableChannels.set(channels); + }); + } + + private resolveChannelNames(key: KeyToken): void { + if (!key.all_channels && key.channels && key.channels.length > 0) { + this.channelCacheService.resolveChannels(key.channels).subscribe(resolved => { + this.channelNames.set(resolved); + }); + } + } + + goBack(): void { + this.router.navigate(['/keys']); + } + + isCurrentKey(): boolean { + const key = this.key(); + return key?.keytoken_id === this.currentKeyId(); + } + + getPermissions(): TokenPermission[] { + const key = this.key(); + return key ? parsePermissions(key.permissions) : []; + } + + getPermissionColor(perm: TokenPermission): string { + switch (perm) { + case 'A': return 'red'; + case 'CR': return 'blue'; + case 'CS': return 'green'; + case 'UR': return 'purple'; + default: return 'default'; + } + } + + getPermissionLabel(perm: TokenPermission): string { + const option = this.permissionOptions.find(o => o.value === perm); + return option?.label || perm; + } + + getChannelDisplayName(channelId: string): string { + const resolved = this.channelNames().get(channelId); + return resolved?.displayName || channelId; + } + + getChannelLabel(channel: ChannelWithSubscription): string { + return channel.display_name || channel.internal_name; + } + + // Edit modal + openEditModal(): void { + const key = this.key(); + if (!key) return; + + this.editKeyName = key.name; + this.editKeyPermissions = parsePermissions(key.permissions); + this.editKeyAllChannels = key.all_channels; + this.editKeyChannels = key.channels ? [...key.channels] : []; + this.showEditModal.set(true); + } + + closeEditModal(): void { + this.showEditModal.set(false); + } + + updateKey(): void { + const userId = this.authService.getUserId(); + const key = this.key(); + if (!userId || !key || !this.editKeyName.trim() || this.editKeyPermissions.length === 0) return; + + this.updating.set(true); + this.apiService.updateKey(userId, key.keytoken_id, { + name: this.editKeyName.trim(), + permissions: this.editKeyPermissions.join(';'), + all_channels: this.editKeyAllChannels, + channels: this.editKeyAllChannels ? undefined : this.editKeyChannels + }).subscribe({ + next: (updated) => { + this.key.set(updated); + this.notification.success('Key updated'); + this.updating.set(false); + this.closeEditModal(); + this.resolveChannelNames(updated); + }, + error: () => { + this.updating.set(false); + } + }); + } + + onEditPermissionChange(perm: TokenPermission, checked: boolean): void { + if (checked) { + if (perm === 'A') { + this.editKeyPermissions = ['A']; + } else if (!this.editKeyPermissions.includes(perm)) { + this.editKeyPermissions = [...this.editKeyPermissions, perm]; + } + } else { + this.editKeyPermissions = this.editKeyPermissions.filter(p => p !== perm); + } + } + + isEditPermissionChecked(perm: TokenPermission): boolean { + return this.editKeyPermissions.includes(perm); + } + + deleteKey(): void { + const key = this.key(); + const userId = this.authService.getUserId(); + if (!key || !userId) return; + + if (this.isCurrentKey()) { + this.notification.warning('Cannot delete the key you are currently using'); + return; + } + + this.apiService.deleteKey(userId, key.keytoken_id).subscribe({ + next: () => { + this.notification.success('Key deleted'); + this.router.navigate(['/keys']); + } + }); + } +} diff --git a/webapp/src/app/features/keys/key-list/key-list.component.html b/webapp/src/app/features/keys/key-list/key-list.component.html index db5d52f..adfe0a3 100644 --- a/webapp/src/app/features/keys/key-list/key-list.component.html +++ b/webapp/src/app/features/keys/key-list/key-list.component.html @@ -25,16 +25,16 @@ - Name - Permissions - Messages Sent - Last Used - Actions + Name + Permissions + Messages Sent + Last Used + Actions @for (key of keys(); track key.keytoken_id) { - +
{{ key.name }} @@ -71,14 +71,13 @@ {{ key.messages_sent }} @if (key.timestamp_lastused) { - - {{ key.timestamp_lastused | relativeTime }} - +
{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ key.timestamp_lastused | relativeTime }}
} @else { Never } - +
-
- - - {{ message()!.message_id }} - - - {{ message()!.channel_internal_name }} - - - - {{ getPriorityLabel(message()!.priority) }} - - - - {{ message()!.sender_name || '-' }} - - - {{ message()!.sender_ip }} - - - {{ message()!.timestamp }} ({{ message()!.timestamp | relativeTime }}) - - - {{ message()!.usr_message_id || '-' }} - - - {{ message()!.used_key_id }} - - - @if (message()!.content) { -
{{ message()!.content }}
+ } @else { +
No content
}
+ + + + + {{ message()!.message_id }} + + + + + + + {{ getPriorityLabel(message()!.priority) }} + + + + {{ message()!.sender_name || '-' }} + + + {{ message()!.sender_ip }} + + +
{{ message()!.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ message()!.timestamp | relativeTime }}
+
+ + {{ message()!.usr_message_id || '-' }} + + + + +
+
} @else {
diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.scss b/webapp/src/app/features/messages/message-detail/message-detail.component.scss index 0879e5c..29009c0 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.scss +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.scss @@ -20,6 +20,11 @@ } } +.no-content { + color: rgba(0, 0, 0, 0.45); + font-style: italic; +} + .not-found { text-align: center; padding: 48px; @@ -29,3 +34,39 @@ margin-bottom: 16px; } } + +nz-card + nz-card { + margin-top: 16px; +} + +.cell-name { + font-weight: 500; + color: #333; +} + +.cell-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.metadata-link { + text-decoration: none; + display: block; + + &:hover { + .cell-name { + color: #1890ff; + } + } +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.ts b/webapp/src/app/features/messages/message-detail/message-detail.component.ts index e389421..8c79e05 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.ts +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.ts @@ -1,33 +1,33 @@ import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; +import { CommonModule, DatePipe } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { NzCardModule } from 'ng-zorro-antd/card'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; -import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { NzSpinModule } from 'ng-zorro-antd/spin'; -import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; -import { NzDividerModule } from 'ng-zorro-antd/divider'; import { ApiService } from '../../../core/services/api.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { KeyCacheService, ResolvedKey } from '../../../core/services/key-cache.service'; import { Message } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @Component({ selector: 'app-message-detail', standalone: true, imports: [ CommonModule, + DatePipe, NzCardModule, NzButtonModule, NzIconModule, - NzDescriptionsModule, NzTagModule, NzSpinModule, - NzPopconfirmModule, - NzDividerModule, + RouterLink, RelativeTimePipe, + MetadataGridComponent, + MetadataValueComponent, ], templateUrl: './message-detail.component.html', styleUrl: './message-detail.component.scss' @@ -37,8 +37,10 @@ export class MessageDetailComponent implements OnInit { private router = inject(Router); private apiService = inject(ApiService); private notification = inject(NotificationService); + private keyCacheService = inject(KeyCacheService); message = signal(null); + resolvedKey = signal(null); loading = signal(true); deleting = signal(false); @@ -55,6 +57,7 @@ export class MessageDetailComponent implements OnInit { next: (message) => { this.message.set(message); this.loading.set(false); + this.resolveKey(message.used_key_id); }, error: () => { this.loading.set(false); @@ -62,6 +65,12 @@ export class MessageDetailComponent implements OnInit { }); } + private resolveKey(keyId: string): void { + this.keyCacheService.resolveKey(keyId).subscribe({ + next: (resolved) => this.resolvedKey.set(resolved) + }); + } + goBack(): void { this.router.navigate(['/messages']); } diff --git a/webapp/src/app/features/messages/message-list/message-list.component.html b/webapp/src/app/features/messages/message-list/message-list.component.html index 07ef5e5..797c507 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.html +++ b/webapp/src/app/features/messages/message-list/message-list.component.html @@ -67,26 +67,26 @@ - Title + Title + Content Channel Sender Priority - + Time @@ -110,12 +110,18 @@
{{ message.title }}
- @if (message.content && !message.trimmed) { -
{{ message.content | slice:0:100 }}{{ message.content.length > 100 ? '...' : '' }}
+
{{ message.message_id }}
+ + + @if (message.content) { +
{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}
+ } @else { + } - {{ message.channel_internal_name }} +
{{ message.channel_internal_name }}
+
{{ message.channel_id }}
{{ message.sender_name || '-' }} @@ -126,14 +132,13 @@ - - {{ message.timestamp | relativeTime }} - +
{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ message.timestamp | relativeTime }}
} @empty { - + diff --git a/webapp/src/app/features/messages/message-list/message-list.component.scss b/webapp/src/app/features/messages/message-list/message-list.component.scss index 21e0a80..0d3f2f3 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.scss +++ b/webapp/src/app/features/messages/message-list/message-list.component.scss @@ -41,10 +41,40 @@ color: #333; } -.message-preview { +.message-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.message-content { + font-size: 12px; + color: #666; +} + +.text-muted { + color: #999; +} + +.cell-name { + font-weight: 500; + color: #333; +} + +.cell-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { font-size: 12px; color: #999; - margin-top: 4px; } .pagination-controls { diff --git a/webapp/src/app/features/senders/sender-list/sender-list.component.html b/webapp/src/app/features/senders/sender-list/sender-list.component.html index cb22f6b..e3bf922 100644 --- a/webapp/src/app/features/senders/sender-list/sender-list.component.html +++ b/webapp/src/app/features/senders/sender-list/sender-list.component.html @@ -21,9 +21,9 @@ - Sender Name - Message Count - Last Used + Sender Name + Message Count + Last Used @@ -34,9 +34,8 @@ {{ sender.count }} - - {{ sender.last_timestamp | relativeTime }} - +
{{ sender.last_timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sender.last_timestamp | relativeTime }}
} @empty { @@ -62,9 +61,9 @@ - Sender Name - Message Count - Last Used + Sender Name + Message Count + Last Used @@ -75,9 +74,8 @@ {{ sender.count }} - - {{ sender.last_timestamp | relativeTime }} - +
{{ sender.last_timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sender.last_timestamp | relativeTime }}
} @empty { diff --git a/webapp/src/app/features/senders/sender-list/sender-list.component.scss b/webapp/src/app/features/senders/sender-list/sender-list.component.scss index 43e0630..7ce5b76 100644 --- a/webapp/src/app/features/senders/sender-list/sender-list.component.scss +++ b/webapp/src/app/features/senders/sender-list/sender-list.component.scss @@ -12,3 +12,13 @@ .sender-name { font-weight: 500; } + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/senders/sender-list/sender-list.component.ts b/webapp/src/app/features/senders/sender-list/sender-list.component.ts index a337c50..5f4ff83 100644 --- a/webapp/src/app/features/senders/sender-list/sender-list.component.ts +++ b/webapp/src/app/features/senders/sender-list/sender-list.component.ts @@ -1,5 +1,5 @@ import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; @@ -17,6 +17,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; standalone: true, imports: [ CommonModule, + DatePipe, NzTableModule, NzButtonModule, NzIconModule, diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html new file mode 100644 index 0000000..a7e889d --- /dev/null +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html @@ -0,0 +1,111 @@ +
+ @if (loading()) { +
+ +
+ } @else if (subscription()) { +
+ +
+ @if (!subscription()!.confirmed && isOwner()) { + + } + @if (subscription()!.confirmed && isOwner()) { + + } + +
+
+ + +
+

Subscription

+ {{ getTypeLabel().label }} + {{ getStatusInfo().label }} +
+ + + + {{ subscription()!.subscription_id }} + + + + @if (resolvedChannel()) { +
{{ resolvedChannel()!.displayName }}
+ } +
{{ subscription()!.channel_id }}
+
+
+ + {{ subscription()!.channel_internal_name }} + + + @if (resolvedSubscriber()) { +
{{ resolvedSubscriber()!.displayName }}
+
{{ subscription()!.subscriber_user_id }}
+ } @else { + {{ subscription()!.subscriber_user_id }} + } +
+ + @if (resolvedOwner()) { +
{{ resolvedOwner()!.displayName }}
+
{{ subscription()!.channel_owner_user_id }}
+ } @else { + {{ subscription()!.channel_owner_user_id }} + } +
+ + @if (subscription()!.confirmed) { + Yes + } @else { + No + } + + + @if (subscription()!.active) { + Yes + } @else { + No + } + + +
{{ subscription()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ subscription()!.timestamp_created | relativeTime }}
+
+
+
+ } @else { + +
+

Subscription not found

+ +
+
+ } +
diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss new file mode 100644 index 0000000..73acef1 --- /dev/null +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss @@ -0,0 +1,66 @@ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.subscription-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.subscription-title { + margin: 0; + font-size: 20px; + font-weight: 500; +} + +.channel-link { + text-decoration: none; + color: inherit; + + &:hover { + .resolved-name { + color: #1890ff; + } + } +} + +.resolved-name { + font-weight: 500; + color: #333; +} + +.resolved-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.not-found { + text-align: center; + padding: 48px; + + p { + color: #999; + margin-bottom: 16px; + } +} + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts new file mode 100644 index 0000000..ab6d9b6 --- /dev/null +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts @@ -0,0 +1,178 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { NotificationService } from '../../../core/services/notification.service'; +import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; +import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; +import { Subscription } from '../../../core/models'; +import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; + +@Component({ + selector: 'app-subscription-detail', + standalone: true, + imports: [ + CommonModule, + DatePipe, + RouterLink, + NzCardModule, + NzButtonModule, + NzIconModule, + NzTagModule, + NzSpinModule, + NzPopconfirmModule, + NzToolTipModule, + RelativeTimePipe, + MetadataGridComponent, + MetadataValueComponent, + ], + templateUrl: './subscription-detail.component.html', + styleUrl: './subscription-detail.component.scss' +}) +export class SubscriptionDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private apiService = inject(ApiService); + private authService = inject(AuthService); + private notification = inject(NotificationService); + private channelCacheService = inject(ChannelCacheService); + private userCacheService = inject(UserCacheService); + + subscription = signal(null); + loading = signal(true); + resolvedChannel = signal(null); + resolvedSubscriber = signal(null); + resolvedOwner = signal(null); + + ngOnInit(): void { + const subscriptionId = this.route.snapshot.paramMap.get('id'); + if (subscriptionId) { + this.loadSubscription(subscriptionId); + } + } + + loadSubscription(subscriptionId: string): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.loading.set(true); + this.apiService.getSubscription(userId, subscriptionId).subscribe({ + next: (subscription) => { + this.subscription.set(subscription); + this.loading.set(false); + this.resolveChannel(subscription.channel_id); + this.resolveSubscriber(subscription.subscriber_user_id); + this.resolveOwner(subscription.channel_owner_user_id); + }, + error: () => { + this.loading.set(false); + } + }); + } + + private resolveChannel(channelId: string): void { + this.channelCacheService.resolveChannel(channelId).subscribe(resolved => { + this.resolvedChannel.set(resolved); + }); + } + + private resolveSubscriber(userId: string): void { + this.userCacheService.resolveUser(userId).subscribe(resolved => { + this.resolvedSubscriber.set(resolved); + }); + } + + private resolveOwner(userId: string): void { + this.userCacheService.resolveUser(userId).subscribe(resolved => { + this.resolvedOwner.set(resolved); + }); + } + + goBack(): void { + this.router.navigate(['/subscriptions']); + } + + isOutgoing(): boolean { + const sub = this.subscription(); + if (!sub) return false; + const userId = this.authService.getUserId(); + return sub.subscriber_user_id === userId; + } + + isOwner(): boolean { + const sub = this.subscription(); + if (!sub) return false; + const userId = this.authService.getUserId(); + return sub.channel_owner_user_id === userId; + } + + getStatusInfo(): { label: string; color: string } { + const sub = this.subscription(); + if (!sub) return { label: 'Unknown', color: 'default' }; + if (sub.confirmed) { + return { label: 'Confirmed', color: 'green' }; + } + return { label: 'Pending', color: 'orange' }; + } + + getTypeLabel(): { label: string; color: string } { + const sub = this.subscription(); + if (!sub) return { label: 'Unknown', color: 'default' }; + const userId = this.authService.getUserId(); + if (sub.subscriber_user_id === sub.channel_owner_user_id) { + return { label: 'Own', color: 'green' }; + } + if (sub.subscriber_user_id === userId) { + return { label: 'External', color: 'blue' }; + } + return { label: 'Incoming', color: 'purple' }; + } + + acceptSubscription(): void { + const sub = this.subscription(); + const userId = this.authService.getUserId(); + if (!sub || !userId) return; + + this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: true }).subscribe({ + next: (updated) => { + this.subscription.set(updated); + this.notification.success('Subscription accepted'); + } + }); + } + + deactivateSubscription(): void { + const sub = this.subscription(); + const userId = this.authService.getUserId(); + if (!sub || !userId) return; + + this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: false }).subscribe({ + next: (updated) => { + this.subscription.set(updated); + this.notification.success('Subscription deactivated'); + } + }); + } + + deleteSubscription(): void { + const sub = this.subscription(); + const userId = this.authService.getUserId(); + if (!sub || !userId) return; + + this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({ + next: () => { + this.notification.success('Subscription deleted'); + this.router.navigate(['/subscriptions']); + } + }); + } +} diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html index 7d7a40c..192acca 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html @@ -42,39 +42,49 @@ - Type - Channel - Subscriber - Owner - Status - Created - Actions + ID + Type + Channel + Subscriber + Owner + Status + Created + Actions @for (sub of subscriptions(); track sub.subscription_id) { - + + + {{ sub.subscription_id }} + {{ getTypeLabel(sub).label }} - {{ sub.channel_internal_name }} +
{{ sub.channel_internal_name }}
+
{{ sub.channel_id }}
+ + +
{{ getUserDisplayName(sub.subscriber_user_id) }}
+
{{ sub.subscriber_user_id }}
+ + +
{{ getUserDisplayName(sub.channel_owner_user_id) }}
+
{{ sub.channel_owner_user_id }}
- {{ getUserDisplayName(sub.subscriber_user_id) }} - {{ getUserDisplayName(sub.channel_owner_user_id) }} {{ getStatusInfo(sub).label }} - - {{ sub.timestamp_created | relativeTime }} - +
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sub.timestamp_created | relativeTime }}
- +
@if (!sub.confirmed && isOwner(sub)) { @@ -120,7 +130,7 @@ } @empty { - + diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss index 60d2b37..f07d69a 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss @@ -25,6 +25,30 @@ gap: 4px; } +.clickable-row { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } +} + +.subscription-id { + font-size: 11px; + color: #999; +} + +.cell-name { + font-weight: 500; + color: #333; +} + +.cell-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + .modal-hint { color: #666; font-size: 13px; @@ -36,3 +60,13 @@ justify-content: center; padding: 16px 0; } + +.timestamp-absolute { + font-size: 13px; + color: #333; +} + +.timestamp-relative { + font-size: 12px; + color: #999; +} diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts index 94e1b90..b35ba48 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts @@ -1,5 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DatePipe } from '@angular/common'; +import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -40,6 +41,7 @@ const TAB_CONFIGS: Record = { standalone: true, imports: [ CommonModule, + DatePipe, FormsModule, NzTableModule, NzButtonModule, @@ -60,6 +62,7 @@ const TAB_CONFIGS: Record = { styleUrl: './subscription-list.component.scss' }) export class SubscriptionListComponent implements OnInit { + private router = inject(Router); private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); @@ -155,6 +158,10 @@ export class SubscriptionListComponent implements OnInit { return sub.channel_owner_user_id === userId; } + viewSubscription(sub: Subscription): void { + this.router.navigate(['/subscriptions', sub.subscription_id]); + } + // Actions acceptSubscription(sub: Subscription): void { const userId = this.authService.getUserId(); diff --git a/webapp/src/app/shared/components/metadata-grid/index.ts b/webapp/src/app/shared/components/metadata-grid/index.ts new file mode 100644 index 0000000..c35e148 --- /dev/null +++ b/webapp/src/app/shared/components/metadata-grid/index.ts @@ -0,0 +1,2 @@ +export { MetadataGridComponent } from './metadata-grid.component'; +export { MetadataValueComponent } from './metadata-value.component'; diff --git a/webapp/src/app/shared/components/metadata-grid/metadata-grid.component.ts b/webapp/src/app/shared/components/metadata-grid/metadata-grid.component.ts new file mode 100644 index 0000000..44391fc --- /dev/null +++ b/webapp/src/app/shared/components/metadata-grid/metadata-grid.component.ts @@ -0,0 +1,74 @@ +import { Component, ContentChildren, QueryList, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MetadataValueComponent } from './metadata-value.component'; + +@Component({ + selector: 'scn-metadata-grid', + standalone: true, + imports: [CommonModule], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styles: [` + .scn-metadata-grid { + display: grid; + grid-template-columns: minmax(120px, auto) 1fr; + border-radius: 4px; + overflow: hidden; + } + + .scn-metadata-label { + font-size: 14px; + color: rgba(0, 0, 0, 0.45); + padding: 12px 16px; + background: #fafafa; + border: 1px solid #f0f0f0; + border-right: none; + border-top: none; + } + + .scn-metadata-label.first { + border-top: 1px solid #f0f0f0; + border-top-left-radius: 4px; + } + + .scn-metadata-label.last { + border-bottom-left-radius: 4px; + } + + .scn-metadata-value { + font-size: 14px; + color: rgba(0, 0, 0, 0.85); + padding: 12px 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-top: none; + word-break: break-word; + } + + .scn-metadata-value.first { + border-top: 1px solid #f0f0f0; + border-top-right-radius: 4px; + } + + .scn-metadata-value.last { + border-bottom-right-radius: 4px; + } + `] +}) +export class MetadataGridComponent { + @ContentChildren(MetadataValueComponent) children!: QueryList; + + get items(): MetadataValueComponent[] { + return this.children?.toArray() ?? []; + } +} diff --git a/webapp/src/app/shared/components/metadata-grid/metadata-value.component.ts b/webapp/src/app/shared/components/metadata-grid/metadata-value.component.ts new file mode 100644 index 0000000..971a66f --- /dev/null +++ b/webapp/src/app/shared/components/metadata-grid/metadata-value.component.ts @@ -0,0 +1,20 @@ +import { Component, Input, ViewChild, TemplateRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'scn-metadata-value', + standalone: true, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class MetadataValueComponent { + @Input() label: string = ''; + + @ViewChild('contentTemplate', { static: true }) contentTemplate!: TemplateRef; + + get content(): TemplateRef { + return this.contentTemplate; + } +}