Compare commits

...

2 Commits

Author SHA1 Message Date
Mikescher e98a804efc Fix panic in /preview/channel/{id}
Build Docker and Deploy / Build Docker Container (push) Successful in 1m49s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 7m56s
Build Docker and Deploy / Deploy to Server (push) Successful in 39s
2026-03-27 12:57:19 +01:00
Mikescher 1f9abb8574 WebApp: Fix channel-detail page for non-owned channels
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
2026-03-26 17:05:51 +01:00
20 changed files with 282 additions and 146 deletions
+12 -12
View File
@@ -93,13 +93,13 @@ func (h APIHandler) GetChannelPreview(pctx ginext.PreContext) ginext.HTTPRespons
userid := *ctx.GetPermissionUserID() userid := *ctx.GetPermissionUserID()
channel, err := h.database.GetChannelByID(ctx, u.ChannelID) channel, err := h.database.GetChannelByIDOpt(ctx, u.ChannelID)
if errors.Is(err, sql.ErrNoRows) {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
} }
if channel == nil {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
sub, err := h.database.GetSubscriptionBySubscriber(ctx, userid, channel.ChannelID) sub, err := h.database.GetSubscriptionBySubscriber(ctx, userid, channel.ChannelID)
if err != nil { if err != nil {
@@ -162,13 +162,13 @@ func (h APIHandler) GetUserKeyPreview(pctx ginext.PreContext) ginext.HTTPRespons
// Query by token.token // Query by token.token
keytoken, err := h.database.GetKeyTokenByToken(ctx, u.KeyID) keytoken, err := h.database.GetKeyTokenByTokenOpt(ctx, u.KeyID)
if keytoken == nil {
return ginresp.APIError(g, 404, apierr.KEY_NOT_FOUND, "Key not found", err)
}
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
} }
if keytoken == nil {
return ginresp.APIError(g, 404, apierr.KEY_NOT_FOUND, "Key not found", err)
}
return finishSuccess(ginext.JSON(http.StatusOK, keytoken.Preview())) return finishSuccess(ginext.JSON(http.StatusOK, keytoken.Preview()))
@@ -215,13 +215,13 @@ func (h APIHandler) GetClientPreview(pctx ginext.PreContext) ginext.HTTPResponse
return *permResp return *permResp
} }
client, err := h.database.GetClientByID(ctx, u.ClientID) client, err := h.database.GetClientByIDOpt(ctx, u.ClientID)
if client == nil {
return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
}
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
} }
if client == nil {
return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
}
user, err := h.database.GetUser(ctx, client.UserID) user, err := h.database.GetUser(ctx, client.UserID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+1 -1
View File
@@ -362,7 +362,7 @@ func (h APIHandler) CreateSubscription(pctx ginext.PreContext) ginext.HTTPRespon
} else if b.ChannelOwnerUserID == nil && b.ChannelInternalName == nil && b.ChannelID != nil { } else if b.ChannelOwnerUserID == nil && b.ChannelInternalName == nil && b.ChannelID != nil {
outchannel, err := h.database.GetChannelByID(ctx, *b.ChannelID) outchannel, err := h.database.GetChannelByIDOpt(ctx, *b.ChannelID)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err) return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
} }
+6 -6
View File
@@ -305,7 +305,7 @@ func (h CompatHandler) Info(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
@@ -417,7 +417,7 @@ func (h CompatHandler) Ack(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
@@ -523,7 +523,7 @@ func (h CompatHandler) Requery(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
@@ -644,7 +644,7 @@ func (h CompatHandler) Update(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
@@ -778,7 +778,7 @@ func (h CompatHandler) Expand(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
@@ -901,7 +901,7 @@ func (h CompatHandler) Upgrade(pctx ginext.PreContext) ginext.HTTPResponse {
return ginresp.CompatAPIError(0, "Failed to query user") return ginresp.CompatAPIError(0, "Failed to query user")
} }
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey) keytok, err := h.database.GetKeyTokenByTokenOpt(ctx, *data.UserKey)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token") return ginresp.CompatAPIError(0, "Failed to query token")
} }
+3 -2
View File
@@ -1,10 +1,11 @@
package primary package primary
import ( import (
"time"
"blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models" "blackforestbytes.com/simplecloudnotifier/models"
"git.blackforestbytes.com/BlackForestBytes/goext/sq" "git.blackforestbytes.com/BlackForestBytes/goext/sq"
"time"
) )
func (db *Database) GetChannelByName(ctx db.TxContext, userid models.UserID, chanName string) (*models.Channel, error) { func (db *Database) GetChannelByName(ctx db.TxContext, userid models.UserID, chanName string) (*models.Channel, error) {
@@ -16,7 +17,7 @@ func (db *Database) GetChannelByName(ctx db.TxContext, userid models.UserID, cha
return sq.QuerySingleOpt[models.Channel](ctx, tx, "SELECT * FROM channels WHERE owner_user_id = :uid AND internal_name = :nam AND deleted=0 LIMIT 1", sq.PP{"uid": userid, "nam": chanName}, sq.SModeExtended, sq.Safe) return sq.QuerySingleOpt[models.Channel](ctx, tx, "SELECT * FROM channels WHERE owner_user_id = :uid AND internal_name = :nam AND deleted=0 LIMIT 1", sq.PP{"uid": userid, "nam": chanName}, sq.SModeExtended, sq.Safe)
} }
func (db *Database) GetChannelByID(ctx db.TxContext, chanid models.ChannelID) (*models.Channel, error) { func (db *Database) GetChannelByIDOpt(ctx db.TxContext, chanid models.ChannelID) (*models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -53,7 +53,7 @@ func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid m
}, sq.SModeExtended, sq.Safe) }, sq.SModeExtended, sq.Safe)
} }
func (db *Database) GetClientByID(ctx db.TxContext, clientid models.ClientID) (*models.Client, error) { func (db *Database) GetClientByIDOpt(ctx db.TxContext, clientid models.ClientID) (*models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
+4 -3
View File
@@ -1,12 +1,13 @@
package primary package primary
import ( import (
"strings"
"time"
"blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models" "blackforestbytes.com/simplecloudnotifier/models"
"git.blackforestbytes.com/BlackForestBytes/goext/langext" "git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/sq" "git.blackforestbytes.com/BlackForestBytes/goext/sq"
"strings"
"time"
) )
func (db *Database) CreateKeyToken(ctx db.TxContext, name string, owner models.UserID, allChannels bool, channels []models.ChannelID, permissions models.TokenPermissionList, token string) (models.KeyToken, error) { func (db *Database) CreateKeyToken(ctx db.TxContext, name string, owner models.UserID, allChannels bool, channels []models.ChannelID, permissions models.TokenPermissionList, token string) (models.KeyToken, error) {
@@ -67,7 +68,7 @@ func (db *Database) GetKeyTokenByID(ctx db.TxContext, keyTokenid models.KeyToken
return sq.QuerySingle[models.KeyToken](ctx, tx, "SELECT * FROM keytokens WHERE keytoken_id = :cid AND deleted=0 LIMIT 1", sq.PP{"cid": keyTokenid}, sq.SModeExtended, sq.Safe) return sq.QuerySingle[models.KeyToken](ctx, tx, "SELECT * FROM keytokens WHERE keytoken_id = :cid AND deleted=0 LIMIT 1", sq.PP{"cid": keyTokenid}, sq.SModeExtended, sq.Safe)
} }
func (db *Database) GetKeyTokenByToken(ctx db.TxContext, key string) (*models.KeyToken, error) { func (db *Database) GetKeyTokenByTokenOpt(ctx db.TxContext, key string) (*models.KeyToken, error) {
tx, err := ctx.GetOrCreateTransaction(db) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
+5 -4
View File
@@ -1,14 +1,15 @@
package jobs package jobs
import ( import (
"errors"
"fmt"
"time"
"blackforestbytes.com/simplecloudnotifier/db/simplectx" "blackforestbytes.com/simplecloudnotifier/db/simplectx"
"blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models" "blackforestbytes.com/simplecloudnotifier/models"
"errors"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext" "git.blackforestbytes.com/BlackForestBytes/goext/syncext"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"time"
) )
type DeliveryRetryJob struct { type DeliveryRetryJob struct {
@@ -208,7 +209,7 @@ func (j *DeliveryRetryJob) redeliver(ctx *simplectx.SimpleContext, delivery mode
return return
} }
channel, err := j.app.Database.Primary.GetChannelByID(ctx, msg.ChannelID) channel, err := j.app.Database.Primary.GetChannelByIDOpt(ctx, msg.ChannelID)
if err != nil { if err != nil {
log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel") log.Err(err).Str("ChannelID", msg.ChannelID.String()).Msg("Failed to get channel")
ctx.RollbackTransaction() ctx.RollbackTransaction()
+1 -1
View File
@@ -245,7 +245,7 @@ func (app *Application) getPermissions(ctx db.TxContext, hdr string) (models.Per
key := strings.TrimSpace(hdr[4:]) key := strings.TrimSpace(hdr[4:])
tok, err := app.Database.Primary.GetKeyTokenByToken(ctx, key) tok, err := app.Database.Primary.GetKeyTokenByTokenOpt(ctx, key)
if err != nil { if err != nil {
return models.PermissionSet{}, err return models.PermissionSet{}, err
} }
+1 -1
View File
@@ -75,7 +75,7 @@ func (ac *AppContext) CheckPermissionUserAdmin(userid models.UserID) *ginext.HTT
func (ac *AppContext) CheckPermissionSend(channel models.Channel, key string) (*models.KeyToken, *ginext.HTTPResponse) { func (ac *AppContext) CheckPermissionSend(channel models.Channel, key string) (*models.KeyToken, *ginext.HTTPResponse) {
keytok, err := ac.app.Database.Primary.GetKeyTokenByToken(ac, key) keytok, err := ac.app.Database.Primary.GetKeyTokenByTokenOpt(ac, key)
if err != nil { if err != nil {
return nil, langext.Ptr(ginresp.APIError(ac.ginContext, 500, apierr.DATABASE_ERROR, "Failed to query token", err)) return nil, langext.Ptr(ginresp.APIError(ac.ginContext, 500, apierr.DATABASE_ERROR, "Failed to query token", err))
} }
@@ -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';
@@ -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;
} }
@@ -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';
@@ -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();
@@ -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,16 +46,22 @@
</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>
@if (channel()) {
<scn-metadata-value label="Last Sent"> <scn-metadata-value label="Last Sent">
@if (channel()!.timestamp_lastsent) { @if (channel()!.timestamp_lastsent) {
<div class="timestamp-absolute">{{ channel()!.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div> <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-absolute">{{ channel()!.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
<div class="timestamp-relative">{{ channel()!.timestamp_created | relativeTime }}</div> <div class="timestamp-relative">{{ channel()!.timestamp_created | relativeTime }}</div>
</scn-metadata-value> </scn-metadata-value>
@if (isOwner() && channel()!.subscribe_key) { }
@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">
@@ -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;
} }
@@ -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,19 +117,31 @@ export class ChannelDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); 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({ this.apiService.getChannel(userId, channelId).subscribe({
next: (channel) => { next: (channel) => {
this.channel.set(channel); this.channel.set(channel);
this.loading.set(false); this.loading.set(false);
if (this.isOwner()) {
this.loadSubscriptions(channelId); this.loadSubscriptions(channelId);
}
this.loadMessages(channelId); this.loadMessages(channelId);
}, },
error: () => { error: () => {
this.loading.set(false); this.loading.set(false);
} }
}); });
} else {
this.loading.set(false);
this.loadMessages(channelId);
}
},
error: () => {
this.loading.set(false);
}
});
} }
loadSubscriptions(channelId: string): void { loadSubscriptions(channelId: string): void {
@@ -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 {
@@ -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>
@if (client()) {
<scn-metadata-value label="FCM Token"> <scn-metadata-value label="FCM Token">
<span class="mono fcm-token" nz-tooltip [nzTooltipTitle]="client()!.fcm_token"> <span class="mono fcm-token" nz-tooltip [nzTooltipTitle]="client()!.fcm_token">
{{ client()!.fcm_token }} {{ client()!.fcm_token }}
</span> </span>
</scn-metadata-value> </scn-metadata-value>
}
</scn-metadata-grid> </scn-metadata-grid>
</nz-card> </nz-card>
} @else { } @else {
@@ -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,6 +61,10 @@ export class ClientDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); 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({ this.apiService.getClient(userId, clientId).subscribe({
next: (client) => { next: (client) => {
this.client.set(client); this.client.set(client);
@@ -69,6 +74,27 @@ export class ClientDetailComponent implements OnInit {
this.loading.set(false); 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 { goBack(): void {
@@ -3,12 +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 (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>
@if (isOwner()) {
<div class="header-actions"> <div class="header-actions">
<button nz-button (click)="openEditModal()"> <button nz-button (click)="openEditModal()">
<span nz-icon nzType="edit"></span> <span nz-icon nzType="edit"></span>
@@ -27,11 +28,12 @@
</button> </button>
} }
</div> </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,6 +71,7 @@
<span class="text-muted">No channels</span> <span class="text-muted">No channels</span>
} }
</scn-metadata-value> </scn-metadata-value>
@if (key()) {
<scn-metadata-value label="Messages Sent"> <scn-metadata-value label="Messages Sent">
{{ key()!.messages_sent }} {{ key()!.messages_sent }}
</scn-metadata-value> </scn-metadata-value>
@@ -84,12 +87,13 @@
<span class="text-muted">Never</span> <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>
@@ -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,18 +114,34 @@ export class KeyDetailComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.loading.set(true); 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({ this.apiService.getKey(userId, keyId).subscribe({
next: (key) => { next: (key) => {
this.key.set(key); this.key.set(key);
this.loading.set(false); this.loading.set(false);
this.resolveChannelNames(key); this.resolveChannelNames(key);
this.resolveOwner(key.owner_user_id);
this.loadMessages(keyId); this.loadMessages(keyId);
}, },
error: () => { error: () => {
this.loading.set(false); this.loading.set(false);
} }
}); });
} else {
this.loading.set(false);
this.loadMessages(keyId);
}
},
error: () => {
this.loading.set(false);
}
});
} }
loadMessages(keyId: string, nextPageToken?: string): void { 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 { 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 {