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;
internal_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';

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import {
User,
UserWithExtra,
UserPreview,
ChannelPreview,
KeyTokenPreview,
Message,
MessageListParams,
MessageListResponse,
@@ -98,6 +100,14 @@ export class ApiService {
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
getChannels(userId: string, selector?: ChannelSelector): Observable<ChannelListResponse> {
let params = new HttpParams();

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.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 { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
@@ -45,6 +45,7 @@ export class ClientDetailComponent implements OnInit {
private settingsService = inject(SettingsService);
client = signal<Client | null>(null);
clientPreview = signal<ClientPreview | null>(null);
loading = signal(true);
expertMode = this.settingsService.expertMode;
@@ -60,6 +61,10 @@ export class ClientDetailComponent implements OnInit {
if (!userId) return;
this.loading.set(true);
this.apiService.getClientPreview(clientId).subscribe({
next: (response) => {
this.clientPreview.set(response.client);
if (response.client.user_id === userId) {
this.apiService.getClient(userId, clientId).subscribe({
next: (client) => {
this.client.set(client);
@@ -69,6 +74,27 @@ export class ClientDetailComponent implements OnInit {
this.loading.set(false);
}
});
} else {
this.loading.set(false);
}
},
error: () => {
this.loading.set(false);
}
});
}
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 {

View File

@@ -3,12 +3,13 @@
<div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin>
</div>
} @else if (key()) {
} @else if (keyData()) {
<div class="detail-header">
<button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
Back to Keys
</button>
@if (isOwner()) {
<div class="header-actions">
<button nz-button (click)="openEditModal()">
<span nz-icon nzType="edit"></span>
@@ -27,11 +28,12 @@
</button>
}
</div>
}
</div>
<nz-card>
<div class="key-header">
<h2 class="key-title">{{ key()!.name }}</h2>
<h2 class="key-title">{{ keyData()!.name }}</h2>
@if (isCurrentKey()) {
<nz-tag nzColor="cyan">Current</nz-tag>
}
@@ -39,7 +41,7 @@
<scn-metadata-grid>
<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 label="Permissions">
<div class="permissions">
@@ -55,11 +57,11 @@
</div>
</scn-metadata-value>
<scn-metadata-value label="Channel Access">
@if (key()!.all_channels) {
@if (keyData()!.all_channels) {
<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">
@for (channelId of key()!.channels; track channelId) {
@for (channelId of keyData()!.channels; track channelId) {
<nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId">
{{ getChannelDisplayName(channelId) }}
</nz-tag>
@@ -69,6 +71,7 @@
<span class="text-muted">No channels</span>
}
</scn-metadata-value>
@if (key()) {
<scn-metadata-value label="Messages Sent">
{{ key()!.messages_sent }}
</scn-metadata-value>
@@ -84,12 +87,13 @@
<span class="text-muted">Never</span>
}
</scn-metadata-value>
}
<scn-metadata-value label="Owner">
@if (resolvedOwner()) {
<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 {
<span class="mono">{{ key()!.owner_user_id }}</span>
<span class="mono">{{ keyData()!.owner_user_id }}</span>
}
</scn-metadata-value>
</scn-metadata-grid>

View File

@@ -22,7 +22,7 @@ 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, Message } from '../../../core/models';
import { KeyToken, KeyTokenPreview, parsePermissions, TokenPermission, ChannelWithSubscription, Message } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
@@ -72,6 +72,7 @@ export class KeyDetailComponent implements OnInit {
private userCacheService = inject(UserCacheService);
key = signal<KeyToken | null>(null);
keyPreview = signal<KeyTokenPreview | null>(null);
currentKeyId = signal<string | null>(null);
loading = signal(true);
channelNames = signal<Map<string, ResolvedChannel>>(new Map());
@@ -105,8 +106,6 @@ export class KeyDetailComponent implements OnInit {
const keyId = this.route.snapshot.paramMap.get('id');
if (keyId) {
this.loadKey(keyId);
this.loadCurrentKey();
this.loadAvailableChannels();
}
}
@@ -115,18 +114,34 @@ export class KeyDetailComponent implements OnInit {
if (!userId) return;
this.loading.set(true);
this.apiService.getKeyPreview(keyId).subscribe({
next: (preview) => {
this.keyPreview.set(preview);
this.resolveOwner(preview.owner_user_id);
this.resolveChannelNamesFromPreview(preview);
if (preview.owner_user_id === userId) {
this.loadCurrentKey();
this.loadAvailableChannels();
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);
this.loadMessages(keyId);
},
error: () => {
this.loading.set(false);
}
});
} else {
this.loading.set(false);
this.loadMessages(keyId);
}
},
error: () => {
this.loading.set(false);
}
});
}
loadMessages(keyId: string, nextPageToken?: string): void {
@@ -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 {
this.router.navigate(['/keys']);
}
@@ -227,8 +263,8 @@ export class KeyDetailComponent implements OnInit {
}
getPermissions(): TokenPermission[] {
const key = this.key();
return key ? parsePermissions(key.permissions) : [];
const data = this.keyData();
return data ? parsePermissions(data.permissions) : [];
}
getPermissionColor(perm: TokenPermission): string {