WebApp: Fix channel-detail page for non-owned channels
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m48s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 4m11s
Build Docker and Deploy / Deploy to Server (push) Successful in 22s

This commit is contained in:
2026-03-26 17:05:51 +01:00
parent 9352ff5c2c
commit 1f9abb8574
11 changed files with 248 additions and 115 deletions

View File

@@ -21,6 +21,9 @@ export interface ChannelPreview {
owner_user_id: string; owner_user_id: string;
internal_name: string; internal_name: string;
display_name: string; display_name: string;
description_name: string | null;
messages_sent: number;
subscription: Subscription | null;
} }
export type ChannelSelector = 'owned' | 'subscribed' | 'all' | 'subscribed_any' | 'all_any'; export type ChannelSelector = 'owned' | 'subscribed' | 'all' | 'subscribed_any' | 'all_any';

View File

@@ -19,8 +19,10 @@ export interface ClientListResponse {
export interface ClientPreview { export interface ClientPreview {
client_id: string; client_id: string;
user_id: string;
name: string | null; name: string | null;
type: ClientType; type: ClientType;
timestamp_created: string;
agent_model: string; agent_model: string;
agent_version: string; agent_version: string;
} }

View File

@@ -14,6 +14,10 @@ export interface KeyToken {
export interface KeyTokenPreview { export interface KeyTokenPreview {
keytoken_id: string; keytoken_id: string;
name: string; name: string;
owner_user_id: string;
all_channels: boolean;
channels: string[];
permissions: string;
} }
export type TokenPermission = 'A' | 'CR' | 'CS' | 'UR'; export type TokenPermission = 'A' | 'CR' | 'CS' | 'UR';

View File

@@ -6,6 +6,8 @@ import {
User, User,
UserWithExtra, UserWithExtra,
UserPreview, UserPreview,
ChannelPreview,
KeyTokenPreview,
Message, Message,
MessageListParams, MessageListParams,
MessageListResponse, MessageListResponse,
@@ -98,6 +100,14 @@ export class ApiService {
return this.http.get<ClientPreviewResponse>(`${this.baseUrl}/preview/clients/${clientId}`); return this.http.get<ClientPreviewResponse>(`${this.baseUrl}/preview/clients/${clientId}`);
} }
getChannelPreview(channelId: string): Observable<ChannelPreview> {
return this.http.get<ChannelPreview>(`${this.baseUrl}/preview/channels/${channelId}`);
}
getKeyPreview(keyId: string): Observable<KeyTokenPreview> {
return this.http.get<KeyTokenPreview>(`${this.baseUrl}/preview/keys/${keyId}`);
}
// Channel endpoints // Channel endpoints
getChannels(userId: string, selector?: ChannelSelector): Observable<ChannelListResponse> { getChannels(userId: string, selector?: ChannelSelector): Observable<ChannelListResponse> {
let params = new HttpParams(); let params = new HttpParams();

View File

@@ -3,7 +3,7 @@
<div class="loading-container"> <div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin> <nz-spin nzSimple nzSize="large"></nz-spin>
</div> </div>
} @else if (channel()) { } @else if (channelData()) {
<div class="detail-header"> <div class="detail-header">
<button nz-button (click)="goBack()"> <button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span> <span nz-icon nzType="arrow-left" nzTheme="outline"></span>
@@ -32,13 +32,13 @@
} }
</div> </div>
<nz-card [nzTitle]="channel()!.display_name"> <nz-card [nzTitle]="channelData()!.display_name">
<scn-metadata-grid> <scn-metadata-grid>
<scn-metadata-value label="Channel ID"> <scn-metadata-value label="Channel ID">
<span class="mono">{{ channel()!.channel_id }}</span> <span class="mono">{{ channelData()!.channel_id }}</span>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Internal Name"> <scn-metadata-value label="Internal Name">
<span class="mono">{{ channel()!.internal_name }}</span> <span class="mono">{{ channelData()!.internal_name }}</span>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Status"> <scn-metadata-value label="Status">
<nz-tag [nzColor]="getSubscriptionStatus().color"> <nz-tag [nzColor]="getSubscriptionStatus().color">
@@ -46,29 +46,36 @@
</nz-tag> </nz-tag>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Owner"> <scn-metadata-value label="Owner">
<span class="mono">{{ channel()!.owner_user_id }}</span> @if (resolvedOwner()) {
<div class="owner-name">{{ resolvedOwner()!.displayName }}</div>
<div class="owner-id mono">{{ channelData()!.owner_user_id }}</div>
} @else {
<span class="mono">{{ channelData()!.owner_user_id }}</span>
}
</scn-metadata-value> </scn-metadata-value>
@if (channel()!.description_name) { @if (channelData()!.description_name) {
<scn-metadata-value label="Description"> <scn-metadata-value label="Description">
{{ channel()!.description_name }} {{ channelData()!.description_name }}
</scn-metadata-value> </scn-metadata-value>
} }
<scn-metadata-value label="Messages Sent"> <scn-metadata-value label="Messages Sent">
{{ channel()!.messages_sent }} {{ channelData()!.messages_sent }}
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Last Sent"> @if (channel()) {
@if (channel()!.timestamp_lastsent) { <scn-metadata-value label="Last Sent">
<div class="timestamp-absolute">{{ channel()!.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div> @if (channel()!.timestamp_lastsent) {
<div class="timestamp-relative">{{ channel()!.timestamp_lastsent | relativeTime }}</div> <div class="timestamp-absolute">{{ channel()!.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div>
} @else { <div class="timestamp-relative">{{ channel()!.timestamp_lastsent | relativeTime }}</div>
Never } @else {
} Never
</scn-metadata-value> }
<scn-metadata-value label="Created"> </scn-metadata-value>
<div class="timestamp-absolute">{{ channel()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div> <scn-metadata-value label="Created">
<div class="timestamp-relative">{{ channel()!.timestamp_created | relativeTime }}</div> <div class="timestamp-absolute">{{ channel()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
</scn-metadata-value> <div class="timestamp-relative">{{ channel()!.timestamp_created | relativeTime }}</div>
@if (isOwner() && channel()!.subscribe_key) { </scn-metadata-value>
}
@if (isOwner() && channel()?.subscribe_key) {
<scn-metadata-value label="Subscribe Key"> <scn-metadata-value label="Subscribe Key">
<div class="key-field"> <div class="key-field">
<nz-input-group [nzSuffix]="subscribeKeySuffix"> <nz-input-group [nzSuffix]="subscribeKeySuffix">

View File

@@ -109,6 +109,17 @@
overflow-y: clip; overflow-y: clip;
} }
.owner-name {
font-weight: 500;
color: #333;
}
.owner-id {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.text-muted { .text-muted {
color: #999; color: #999;
} }

View File

@@ -21,7 +21,7 @@ import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { SettingsService } from '../../../core/services/settings.service'; import { SettingsService } from '../../../core/services/settings.service';
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
import { ChannelWithSubscription, Subscription, Message } from '../../../core/models'; import { ChannelWithSubscription, ChannelPreview, Subscription, Message } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive'; import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component'; import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component';
@@ -68,9 +68,11 @@ export class ChannelDetailComponent implements OnInit {
private userCacheService = inject(UserCacheService); private userCacheService = inject(UserCacheService);
channel = signal<ChannelWithSubscription | null>(null); channel = signal<ChannelWithSubscription | null>(null);
channelPreview = signal<ChannelPreview | null>(null);
subscriptions = signal<Subscription[]>([]); subscriptions = signal<Subscription[]>([]);
messages = signal<Message[]>([]); messages = signal<Message[]>([]);
userNames = signal<Map<string, ResolvedUser>>(new Map()); userNames = signal<Map<string, ResolvedUser>>(new Map());
resolvedOwner = signal<ResolvedUser | null>(null);
loading = signal(true); loading = signal(true);
loadingSubscriptions = signal(false); loadingSubscriptions = signal(false);
loadingMessages = signal(false); loadingMessages = signal(false);
@@ -115,14 +117,26 @@ export class ChannelDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); this.loading.set(true);
this.apiService.getChannel(userId, channelId).subscribe({ this.apiService.getChannelPreview(channelId).subscribe({
next: (channel) => { next: (preview) => {
this.channel.set(channel); this.channelPreview.set(preview);
this.loading.set(false); this.resolveOwner(preview.owner_user_id);
if (this.isOwner()) { if (preview.owner_user_id === userId) {
this.loadSubscriptions(channelId); this.apiService.getChannel(userId, channelId).subscribe({
next: (channel) => {
this.channel.set(channel);
this.loading.set(false);
this.loadSubscriptions(channelId);
this.loadMessages(channelId);
},
error: () => {
this.loading.set(false);
}
});
} else {
this.loading.set(false);
this.loadMessages(channelId);
} }
this.loadMessages(channelId);
}, },
error: () => { error: () => {
this.loading.set(false); this.loading.set(false);
@@ -148,14 +162,13 @@ export class ChannelDetailComponent implements OnInit {
} }
loadMessages(channelId: string, nextPageToken?: string): void { loadMessages(channelId: string, nextPageToken?: string): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loadingMessages.set(true); this.loadingMessages.set(true);
this.apiService.getChannelMessages(userId, channelId, { this.apiService.getMessages({
channel_id: [channelId],
page_size: this.messagesPageSize, page_size: this.messagesPageSize,
next_page_token: nextPageToken, next_page_token: nextPageToken,
trimmed: true trimmed: true,
subscription_status: 'all'
}).subscribe({ }).subscribe({
next: (response) => { next: (response) => {
this.messages.set(response.messages); this.messages.set(response.messages);
@@ -210,6 +223,12 @@ export class ChannelDetailComponent implements OnInit {
} }
} }
private resolveOwner(ownerId: string): void {
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
this.resolvedOwner.set(resolved);
});
}
private resolveUserNames(subscriptions: Subscription[]): void { private resolveUserNames(subscriptions: Subscription[]): void {
const userIds = new Set<string>(); const userIds = new Set<string>();
for (const sub of subscriptions) { for (const sub of subscriptions) {
@@ -232,9 +251,16 @@ export class ChannelDetailComponent implements OnInit {
} }
isOwner(): boolean { isOwner(): boolean {
const channel = this.channel();
const userId = this.authService.getUserId(); const userId = this.authService.getUserId();
return channel?.owner_user_id === userId; const channel = this.channel();
if (channel) return channel.owner_user_id === userId;
const preview = this.channelPreview();
if (preview) return preview.owner_user_id === userId;
return false;
}
channelData() {
return this.channel() ?? this.channelPreview();
} }
// Edit methods // Edit methods
@@ -290,18 +316,20 @@ export class ChannelDetailComponent implements OnInit {
} }
getSubscriptionStatus(): { label: string; color: string } { getSubscriptionStatus(): { label: string; color: string } {
const channel = this.channel(); const data = this.channelData();
if (!channel) return { label: 'Unknown', color: 'default' }; if (!data) return { label: 'Unknown', color: 'default' };
const subscription = 'subscribe_key' in data ? data.subscription : data.subscription;
if (this.isOwner()) { if (this.isOwner()) {
if (channel.subscription) { if (subscription) {
return { label: 'Owned & Subscribed', color: 'green' }; return { label: 'Owned & Subscribed', color: 'green' };
} }
return { label: 'Owned', color: 'blue' }; return { label: 'Owned', color: 'blue' };
} }
if (channel.subscription) { if (subscription) {
if (channel.subscription.confirmed) { if (subscription.confirmed) {
return { label: 'Subscribed', color: 'green' }; return { label: 'Subscribed', color: 'green' };
} }
return { label: 'Pending', color: 'orange' }; return { label: 'Pending', color: 'orange' };
@@ -377,7 +405,7 @@ export class ChannelDetailComponent implements OnInit {
} }
isUserSubscribed(): boolean { isUserSubscribed(): boolean {
return this.channel()?.subscription !== null; return this.channelData()?.subscription !== null && this.channelData()?.subscription !== undefined;
} }
toggleSelfSubscription(): void { toggleSelfSubscription(): void {

View File

@@ -3,13 +3,13 @@
<div class="loading-container"> <div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin> <nz-spin nzSimple nzSize="large"></nz-spin>
</div> </div>
} @else if (client()) { } @else if (clientData()) {
<div class="detail-header"> <div class="detail-header">
<button nz-button (click)="goBack()"> <button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span> <span nz-icon nzType="arrow-left" nzTheme="outline"></span>
Back to Clients Back to Clients
</button> </button>
@if (expertMode()) { @if (isOwner() && expertMode()) {
<div class="header-actions"> <div class="header-actions">
<button <button
nz-button nz-button
@@ -29,36 +29,38 @@
<div class="client-header"> <div class="client-header">
<span <span
nz-icon nz-icon
[nzType]="getClientIcon(client()!.type)" [nzType]="getClientIcon(clientData()!.type)"
nzTheme="outline" nzTheme="outline"
class="client-type-icon" class="client-type-icon"
></span> ></span>
<h2 class="client-title">{{ client()!.name || 'Unnamed Client' }}</h2> <h2 class="client-title">{{ clientData()!.name || 'Unnamed Client' }}</h2>
<nz-tag>{{ getClientTypeLabel(client()!.type) }}</nz-tag> <nz-tag>{{ getClientTypeLabel(clientData()!.type) }}</nz-tag>
</div> </div>
<scn-metadata-grid> <scn-metadata-grid>
<scn-metadata-value label="Client ID"> <scn-metadata-value label="Client ID">
<span class="mono">{{ client()!.client_id }}</span> <span class="mono">{{ clientData()!.client_id }}</span>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Type"> <scn-metadata-value label="Type">
<nz-tag>{{ getClientTypeLabel(client()!.type) }}</nz-tag> <nz-tag>{{ getClientTypeLabel(clientData()!.type) }}</nz-tag>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Agent"> <scn-metadata-value label="Agent">
<div class="agent-info"> <div class="agent-info">
<span>{{ client()!.agent_model }}</span> <span>{{ clientData()!.agent_model }}</span>
<span class="agent-version">v{{ client()!.agent_version }}</span> <span class="agent-version">v{{ clientData()!.agent_version }}</span>
</div> </div>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Created"> <scn-metadata-value label="Created">
<div class="timestamp-absolute">{{ client()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div> <div class="timestamp-absolute">{{ clientData()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
<div class="timestamp-relative">{{ client()!.timestamp_created | relativeTime }}</div> <div class="timestamp-relative">{{ clientData()!.timestamp_created | relativeTime }}</div>
</scn-metadata-value>
<scn-metadata-value label="FCM Token">
<span class="mono fcm-token" nz-tooltip [nzTooltipTitle]="client()!.fcm_token">
{{ client()!.fcm_token }}
</span>
</scn-metadata-value> </scn-metadata-value>
@if (client()) {
<scn-metadata-value label="FCM Token">
<span class="mono fcm-token" nz-tooltip [nzTooltipTitle]="client()!.fcm_token">
{{ client()!.fcm_token }}
</span>
</scn-metadata-value>
}
</scn-metadata-grid> </scn-metadata-grid>
</nz-card> </nz-card>
} @else { } @else {

View File

@@ -12,7 +12,7 @@ import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { SettingsService } from '../../../core/services/settings.service'; import { SettingsService } from '../../../core/services/settings.service';
import { Client, ClientType, getClientTypeIcon } from '../../../core/models'; import { Client, ClientPreview, ClientType, getClientTypeIcon } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
@@ -45,6 +45,7 @@ export class ClientDetailComponent implements OnInit {
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
client = signal<Client | null>(null); client = signal<Client | null>(null);
clientPreview = signal<ClientPreview | null>(null);
loading = signal(true); loading = signal(true);
expertMode = this.settingsService.expertMode; expertMode = this.settingsService.expertMode;
@@ -60,10 +61,22 @@ export class ClientDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); this.loading.set(true);
this.apiService.getClient(userId, clientId).subscribe({ this.apiService.getClientPreview(clientId).subscribe({
next: (client) => { next: (response) => {
this.client.set(client); this.clientPreview.set(response.client);
this.loading.set(false); if (response.client.user_id === userId) {
this.apiService.getClient(userId, clientId).subscribe({
next: (client) => {
this.client.set(client);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
} else {
this.loading.set(false);
}
}, },
error: () => { error: () => {
this.loading.set(false); this.loading.set(false);
@@ -71,6 +84,19 @@ export class ClientDetailComponent implements OnInit {
}); });
} }
clientData() {
return this.client() ?? this.clientPreview();
}
isOwner(): boolean {
const userId = this.authService.getUserId();
const client = this.client();
if (client) return client.user_id === userId;
const preview = this.clientPreview();
if (preview) return preview.user_id === userId;
return false;
}
goBack(): void { goBack(): void {
this.router.navigate(['/clients']); this.router.navigate(['/clients']);
} }

View File

@@ -3,35 +3,37 @@
<div class="loading-container"> <div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin> <nz-spin nzSimple nzSize="large"></nz-spin>
</div> </div>
} @else if (key()) { } @else if (keyData()) {
<div class="detail-header"> <div class="detail-header">
<button nz-button (click)="goBack()"> <button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span> <span nz-icon nzType="arrow-left" nzTheme="outline"></span>
Back to Keys Back to Keys
</button> </button>
<div class="header-actions"> @if (isOwner()) {
<button nz-button (click)="openEditModal()"> <div class="header-actions">
<span nz-icon nzType="edit"></span> <button nz-button (click)="openEditModal()">
Edit <span nz-icon nzType="edit"></span>
</button> Edit
@if (!isCurrentKey()) {
<button
nz-button
nzDanger
nz-popconfirm
nzPopconfirmTitle="Are you sure you want to delete this key?"
(nzOnConfirm)="deleteKey()"
>
<span nz-icon nzType="delete"></span>
Delete
</button> </button>
} @if (!isCurrentKey()) {
</div> <button
nz-button
nzDanger
nz-popconfirm
nzPopconfirmTitle="Are you sure you want to delete this key?"
(nzOnConfirm)="deleteKey()"
>
<span nz-icon nzType="delete"></span>
Delete
</button>
}
</div>
}
</div> </div>
<nz-card> <nz-card>
<div class="key-header"> <div class="key-header">
<h2 class="key-title">{{ key()!.name }}</h2> <h2 class="key-title">{{ keyData()!.name }}</h2>
@if (isCurrentKey()) { @if (isCurrentKey()) {
<nz-tag nzColor="cyan">Current</nz-tag> <nz-tag nzColor="cyan">Current</nz-tag>
} }
@@ -39,7 +41,7 @@
<scn-metadata-grid> <scn-metadata-grid>
<scn-metadata-value label="Key ID"> <scn-metadata-value label="Key ID">
<span class="mono">{{ key()!.keytoken_id }}</span> <span class="mono">{{ keyData()!.keytoken_id }}</span>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Permissions"> <scn-metadata-value label="Permissions">
<div class="permissions"> <div class="permissions">
@@ -55,11 +57,11 @@
</div> </div>
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Channel Access"> <scn-metadata-value label="Channel Access">
@if (key()!.all_channels) { @if (keyData()!.all_channels) {
<nz-tag nzColor="default">All Channels</nz-tag> <nz-tag nzColor="default">All Channels</nz-tag>
} @else if (key()!.channels && key()!.channels.length > 0) { } @else if (keyData()!.channels && keyData()!.channels.length > 0) {
<div class="channel-list"> <div class="channel-list">
@for (channelId of key()!.channels; track channelId) { @for (channelId of keyData()!.channels; track channelId) {
<nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId"> <nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId">
{{ getChannelDisplayName(channelId) }} {{ getChannelDisplayName(channelId) }}
</nz-tag> </nz-tag>
@@ -69,27 +71,29 @@
<span class="text-muted">No channels</span> <span class="text-muted">No channels</span>
} }
</scn-metadata-value> </scn-metadata-value>
<scn-metadata-value label="Messages Sent"> @if (key()) {
{{ key()!.messages_sent }} <scn-metadata-value label="Messages Sent">
</scn-metadata-value> {{ key()!.messages_sent }}
<scn-metadata-value label="Created"> </scn-metadata-value>
<div class="timestamp-absolute">{{ key()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div> <scn-metadata-value label="Created">
<div class="timestamp-relative">{{ key()!.timestamp_created | relativeTime }}</div> <div class="timestamp-absolute">{{ key()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
</scn-metadata-value> <div class="timestamp-relative">{{ key()!.timestamp_created | relativeTime }}</div>
<scn-metadata-value label="Last Used"> </scn-metadata-value>
@if (key()!.timestamp_lastused) { <scn-metadata-value label="Last Used">
<div class="timestamp-absolute">{{ key()!.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}</div> @if (key()!.timestamp_lastused) {
<div class="timestamp-relative">{{ key()!.timestamp_lastused | relativeTime }}</div> <div class="timestamp-absolute">{{ key()!.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}</div>
} @else { <div class="timestamp-relative">{{ key()!.timestamp_lastused | relativeTime }}</div>
<span class="text-muted">Never</span> } @else {
} <span class="text-muted">Never</span>
</scn-metadata-value> }
</scn-metadata-value>
}
<scn-metadata-value label="Owner"> <scn-metadata-value label="Owner">
@if (resolvedOwner()) { @if (resolvedOwner()) {
<div class="owner-name">{{ resolvedOwner()!.displayName }}</div> <div class="owner-name">{{ resolvedOwner()!.displayName }}</div>
<div class="owner-id mono">{{ key()!.owner_user_id }}</div> <div class="owner-id mono">{{ keyData()!.owner_user_id }}</div>
} @else { } @else {
<span class="mono">{{ key()!.owner_user_id }}</span> <span class="mono">{{ keyData()!.owner_user_id }}</span>
} }
</scn-metadata-value> </scn-metadata-value>
</scn-metadata-grid> </scn-metadata-grid>

View File

@@ -22,7 +22,7 @@ import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service'; import { NotificationService } from '../../../core/services/notification.service';
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription, Message } from '../../../core/models'; import { KeyToken, KeyTokenPreview, parsePermissions, TokenPermission, ChannelWithSubscription, Message } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
@@ -72,6 +72,7 @@ export class KeyDetailComponent implements OnInit {
private userCacheService = inject(UserCacheService); private userCacheService = inject(UserCacheService);
key = signal<KeyToken | null>(null); key = signal<KeyToken | null>(null);
keyPreview = signal<KeyTokenPreview | null>(null);
currentKeyId = signal<string | null>(null); currentKeyId = signal<string | null>(null);
loading = signal(true); loading = signal(true);
channelNames = signal<Map<string, ResolvedChannel>>(new Map()); channelNames = signal<Map<string, ResolvedChannel>>(new Map());
@@ -105,8 +106,6 @@ export class KeyDetailComponent implements OnInit {
const keyId = this.route.snapshot.paramMap.get('id'); const keyId = this.route.snapshot.paramMap.get('id');
if (keyId) { if (keyId) {
this.loadKey(keyId); this.loadKey(keyId);
this.loadCurrentKey();
this.loadAvailableChannels();
} }
} }
@@ -115,13 +114,29 @@ export class KeyDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); this.loading.set(true);
this.apiService.getKey(userId, keyId).subscribe({ this.apiService.getKeyPreview(keyId).subscribe({
next: (key) => { next: (preview) => {
this.key.set(key); this.keyPreview.set(preview);
this.loading.set(false); this.resolveOwner(preview.owner_user_id);
this.resolveChannelNames(key); this.resolveChannelNamesFromPreview(preview);
this.resolveOwner(key.owner_user_id); if (preview.owner_user_id === userId) {
this.loadMessages(keyId); this.loadCurrentKey();
this.loadAvailableChannels();
this.apiService.getKey(userId, keyId).subscribe({
next: (key) => {
this.key.set(key);
this.loading.set(false);
this.resolveChannelNames(key);
this.loadMessages(keyId);
},
error: () => {
this.loading.set(false);
}
});
} else {
this.loading.set(false);
this.loadMessages(keyId);
}
}, },
error: () => { error: () => {
this.loading.set(false); this.loading.set(false);
@@ -217,6 +232,27 @@ export class KeyDetailComponent implements OnInit {
} }
} }
private resolveChannelNamesFromPreview(preview: KeyTokenPreview): void {
if (!preview.all_channels && preview.channels && preview.channels.length > 0) {
this.channelCacheService.resolveChannels(preview.channels).subscribe(resolved => {
this.channelNames.set(resolved);
});
}
}
keyData() {
return this.key() ?? this.keyPreview();
}
isOwner(): boolean {
const userId = this.authService.getUserId();
const key = this.key();
if (key) return key.owner_user_id === userId;
const preview = this.keyPreview();
if (preview) return preview.owner_user_id === userId;
return false;
}
goBack(): void { goBack(): void {
this.router.navigate(['/keys']); this.router.navigate(['/keys']);
} }
@@ -227,8 +263,8 @@ export class KeyDetailComponent implements OnInit {
} }
getPermissions(): TokenPermission[] { getPermissions(): TokenPermission[] {
const key = this.key(); const data = this.keyData();
return key ? parsePermissions(key.permissions) : []; return data ? parsePermissions(data.permissions) : [];
} }
getPermissionColor(perm: TokenPermission): string { getPermissionColor(perm: TokenPermission): string {