Merge branch 'webapp'
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 49s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 8m43s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s

This commit is contained in:
2025-12-03 19:07:30 +01:00
78 changed files with 20239 additions and 1 deletions

View File

@@ -10,7 +10,7 @@
#
name: Build Docker and Deploy
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
run-name: "[cicd-server]: ${{ github.event.head_commit.message }}"
on:
push:

View File

@@ -0,0 +1,57 @@
# https://docs.gitea.com/next/usage/actions/quickstart
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
# Configurable with a few commit messages:
# - [skip-tests] Skip the test stage
# - [skip-deployment] Skip the deployment stage
# - [skip-ci] Skip all stages (the whole ci/cd)
#
name: Build Docker and Deploy
run-name: "[cicd-webapp]: ${{ github.event.head_commit.message }}"
on:
push:
branches: ['master']
jobs:
build_webapp:
name: Build Docker Container
runs-on: bfb-cicd-latest
if: >-
!contains(github.event.head_commit.message, '[skip-ci]') &&
!contains(github.event.head_commit.message, '[skip-deployment]')
steps:
- run: echo -n "${{ secrets.DOCKER_REG_PASS }}" | docker login registry.blackforestbytes.com -u docker --password-stdin
- name: Check out code
uses: actions/checkout@v3
- run: cd "${{ gitea.workspace }}/webapp" && make clean
- run: cd "${{ gitea.workspace }}/webapp" && make docker
- run: cd "${{ gitea.workspace }}/webapp" && make push-docker
deploy_webapp:
name: Deploy to Server
needs: [build_webapp]
runs-on: ubuntu-latest
if: >-
!cancelled() &&
!contains(github.event.head_commit.message, '[skip-ci]') &&
!contains(github.event.head_commit.message, '[skip-deployment]') &&
needs.build_webapp.result == 'success'
steps:
- name: Execute deploy on remote (via ssh)
uses: appleboy/ssh-action@v1.0.0
with:
host: simplecloudnotifier.de
username: bfb-deploy-bot
port: 4477
key: "${{ secrets.SSH_KEY_BFBDEPLOYBOT }}"
script: cd /var/docker/deploy-scripts/simplecloudnotifier && ./deploy-webapp.sh master "${{ gitea.sha }}" || exit 1

11
webapp/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
*.md
.angular
.vscode
.idea
*.log
Dockerfile
.dockerignore

17
webapp/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

45
webapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
.vscode
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

1
webapp/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

20
webapp/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist/scn-webapp/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

40
webapp/Makefile Normal file
View File

@@ -0,0 +1,40 @@
DOCKER_REPO="registry.blackforestbytes.com"
DOCKER_NAME=mikescher/simplecloudnotifier-webapp
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
setup:
npm install
build:
npm install
npm run build:loc
clean:
rm -rf ./node_modules
rm -rf ./dist
docker:
docker build \
-t $(DOCKER_NAME):$(HASH) \
-t $(DOCKER_NAME):$(NAMESPACE)-latest \
-t $(DOCKER_NAME):latest \
-t $(DOCKER_REPO)/$(DOCKER_NAME):$(HASH) \
-t $(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest \
-t $(DOCKER_REPO)/$(DOCKER_NAME):latest \
-f Dockerfile \
.
push-docker:
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):latest
lint:
. ${HOME}/.nvm/nvm.sh && nvm use && npx eslint .

100
webapp/angular.json Normal file
View File

@@ -0,0 +1,100 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"scn-webapp": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/scn-webapp",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{"input": "src/assets", "output": ".", "glob": "**/*" }
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "8kB",
"maximumError": "16kB"
}
],
"allowedCommonJsDependencies": [
"qrcode"
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "scn-webapp:build:production"
},
"development": {
"buildTarget": "scn-webapp:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

15
webapp/nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|map|jpe?g|png|gif|ico|svg|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

15304
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
webapp/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "scn-webapp",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@types/qrcode": "^1.5.6",
"date-fns": "^4.1.0",
"ng-zorro-antd": "^19.3.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.19",
"@angular/cli": "^19.2.19",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NzMessageModule } from 'ng-zorro-antd/message';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, NzMessageModule],
template: `<router-outlet></router-outlet>`,
styles: []
})
export class AppComponent {}

View File

@@ -0,0 +1,93 @@
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { en_US, provideNzI18n } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import en from '@angular/common/locales/en';
import { provideNzIcons } from 'ng-zorro-antd/icon';
import { IconDefinition } from '@ant-design/icons-angular';
import {
MenuFoldOutline,
MenuUnfoldOutline,
DashboardOutline,
MailOutline,
KeyOutline,
TeamOutline,
UserOutline,
SettingOutline,
LogoutOutline,
SendOutline,
BellOutline,
CopyOutline,
QrcodeOutline,
DeleteOutline,
EditOutline,
PlusOutline,
CheckOutline,
CloseOutline,
SearchOutline,
FilterOutline,
ReloadOutline,
EyeOutline,
EyeInvisibleOutline,
AndroidOutline,
AppleOutline,
WindowsOutline,
DesktopOutline,
LinkOutline,
InfoCircleOutline,
ExclamationCircleOutline,
CheckCircleOutline,
} from '@ant-design/icons-angular/icons';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
registerLocaleData(en);
const icons: IconDefinition[] = [
MenuFoldOutline,
MenuUnfoldOutline,
DashboardOutline,
MailOutline,
KeyOutline,
TeamOutline,
UserOutline,
SettingOutline,
LogoutOutline,
SendOutline,
BellOutline,
CopyOutline,
QrcodeOutline,
DeleteOutline,
EditOutline,
PlusOutline,
CheckOutline,
CloseOutline,
SearchOutline,
FilterOutline,
ReloadOutline,
EyeOutline,
EyeInvisibleOutline,
AndroidOutline,
AppleOutline,
WindowsOutline,
DesktopOutline,
LinkOutline,
InfoCircleOutline,
ExclamationCircleOutline,
CheckCircleOutline,
];
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
provideNzI18n(en_US),
provideNzIcons(icons),
]
};

View File

@@ -0,0 +1,55 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { MainLayoutComponent } from './layout/main-layout/main-layout.component';
export const routes: Routes = [
{
path: 'login',
loadComponent: () => import('./features/auth/login/login.component').then(m => m.LoginComponent)
},
{
path: '',
component: MainLayoutComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'messages', pathMatch: 'full' },
{
path: 'messages',
loadComponent: () => import('./features/messages/message-list/message-list.component').then(m => m.MessageListComponent)
},
{
path: 'messages/:id',
loadComponent: () => import('./features/messages/message-detail/message-detail.component').then(m => m.MessageDetailComponent)
},
{
path: 'channels',
loadComponent: () => import('./features/channels/channel-list/channel-list.component').then(m => m.ChannelListComponent)
},
{
path: 'channels/:id',
loadComponent: () => import('./features/channels/channel-detail/channel-detail.component').then(m => m.ChannelDetailComponent)
},
{
path: 'subscriptions',
loadComponent: () => import('./features/subscriptions/subscription-list/subscription-list.component').then(m => m.SubscriptionListComponent)
},
{
path: 'keys',
loadComponent: () => import('./features/keys/key-list/key-list.component').then(m => m.KeyListComponent)
},
{
path: 'clients',
loadComponent: () => import('./features/clients/client-list/client-list.component').then(m => m.ClientListComponent)
},
{
path: 'senders',
loadComponent: () => import('./features/senders/sender-list/sender-list.component').then(m => m.SenderListComponent)
},
{
path: 'account',
loadComponent: () => import('./features/account/account-info/account-info.component').then(m => m.AccountInfoComponent)
},
]
},
{ path: '**', redirectTo: 'messages' }
];

View File

@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
};

View File

@@ -0,0 +1,19 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const authHeader = authService.getAuthHeader();
if (authHeader) {
const clonedReq = req.clone({
setHeaders: {
Authorization: authHeader
}
});
return next(clonedReq);
}
return next(req);
};

View File

@@ -0,0 +1,35 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { NotificationService } from '../services/notification.service';
import { isApiError } from '../models';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const authService = inject(AuthService);
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
authService.logout();
router.navigate(['/login']);
notification.error('Session expired. Please login again.');
} else if (error.status === 403) {
notification.error('Access denied. Insufficient permissions.');
} else if (error.status === 404) {
notification.error('Resource not found.');
} else if (error.status >= 500) {
notification.error('Server error. Please try again later.');
} else if (error.error && isApiError(error.error)) {
notification.error(error.error.message);
} else {
notification.error('An unexpected error occurred.');
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,15 @@
export interface ApiError {
success: false;
error: number;
errhighlight: number;
message: string;
}
export function isApiError(response: unknown): response is ApiError {
return (
typeof response === 'object' &&
response !== null &&
'success' in response &&
(response as ApiError).success === false
);
}

View File

@@ -0,0 +1,43 @@
import { Subscription } from './subscription.model';
export interface Channel {
channel_id: string;
owner_user_id: string;
internal_name: string;
display_name: string;
description_name: string | null;
subscribe_key?: string;
send_key?: string;
timestamp_created: string;
timestamp_lastsent: string | null;
messages_sent: number;
}
export interface ChannelWithSubscription extends Channel {
subscription: Subscription | null;
}
export interface ChannelPreview {
channel_id: string;
owner_user_id: string;
internal_name: string;
display_name: string;
}
export type ChannelSelector = 'owned' | 'subscribed' | 'all' | 'subscribed_any' | 'all_any';
export interface CreateChannelRequest {
name: string;
subscribe?: boolean;
}
export interface UpdateChannelRequest {
display_name?: string;
description_name?: string;
subscribe_key?: string;
send_key?: string;
}
export interface ChannelListResponse {
channels: ChannelWithSubscription[];
}

View File

@@ -0,0 +1,33 @@
export type ClientType = 'ANDROID' | 'IOS' | 'LINUX' | 'MACOS' | 'WINDOWS';
export interface Client {
client_id: string;
user_id: string;
type: ClientType;
fcm_token: string;
timestamp_created: string;
agent_model: string;
agent_version: string;
name: string | null;
}
export interface ClientListResponse {
clients: Client[];
}
export function getClientTypeIcon(type: ClientType): string {
switch (type) {
case 'ANDROID':
return 'android';
case 'IOS':
return 'apple';
case 'MACOS':
return 'apple';
case 'WINDOWS':
return 'windows';
case 'LINUX':
return 'desktop';
default:
return 'desktop';
}
}

View File

@@ -0,0 +1,8 @@
export * from './user.model';
export * from './message.model';
export * from './channel.model';
export * from './subscription.model';
export * from './key-token.model';
export * from './client.model';
export * from './sender-name.model';
export * from './api-response.model';

View File

@@ -0,0 +1,51 @@
export interface KeyToken {
keytoken_id: string;
name: string;
timestamp_created: string;
timestamp_lastused: string | null;
owner_user_id: string;
all_channels: boolean;
channels: string[];
token?: string;
permissions: string;
messages_sent: number;
}
export interface KeyTokenPreview {
keytoken_id: string;
name: string;
}
export type TokenPermission = 'A' | 'CR' | 'CS' | 'UR';
export interface CreateKeyRequest {
name: string;
permissions: string;
all_channels?: boolean;
channels?: string[];
}
export interface UpdateKeyRequest {
name?: string;
permissions?: string;
all_channels?: boolean;
channels?: string[];
}
export interface KeyListResponse {
keys: KeyToken[];
}
export function parsePermissions(permissions: string): TokenPermission[] {
if (!permissions) return [];
return permissions.split(';').filter(p => p) as TokenPermission[];
}
export function hasPermission(permissions: string, required: TokenPermission): boolean {
const perms = parsePermissions(permissions);
return perms.includes(required) || perms.includes('A');
}
export function isAdminKey(key: KeyToken): boolean {
return hasPermission(key.permissions, 'A');
}

View File

@@ -0,0 +1,36 @@
export interface Message {
message_id: string;
sender_user_id: string;
channel_internal_name: string;
channel_owner_user_id: string;
channel_id: string;
sender_name: string | null;
sender_ip: string;
timestamp: string;
title: string;
content: string | null;
priority: number;
usr_message_id: string | null;
used_key_id: string;
trimmed: boolean;
}
export interface MessageListParams {
after?: string;
before?: string;
channel?: string;
priority?: number;
search?: string;
sender?: string;
subscription_status?: 'all' | 'confirmed' | 'unconfirmed';
trimmed?: boolean;
page_size?: number;
next_page_token?: string;
}
export interface MessageListResponse {
messages: Message[];
next_page_token: string;
page_size: number;
total_count: number;
}

View File

@@ -0,0 +1,10 @@
export interface SenderNameStatistics {
name: string;
first_timestamp: string;
last_timestamp: string;
count: number;
}
export interface SenderNameListResponse {
sender_names: SenderNameStatistics[];
}

View File

@@ -0,0 +1,36 @@
export interface Subscription {
subscription_id: string;
subscriber_user_id: string;
channel_owner_user_id: string;
channel_id: string;
channel_internal_name: string;
timestamp_created: string;
confirmed: boolean;
}
export interface SubscriptionFilter {
direction?: 'outgoing' | 'incoming' | 'both';
confirmation?: 'all' | 'confirmed' | 'unconfirmed';
external?: 'all' | 'true' | 'false';
subscriber_user_id?: string;
channel_owner_user_id?: string;
next_page_token?: string;
page_size?: number;
}
export interface CreateSubscriptionRequest {
channel_id?: string;
channel_owner_user_id?: string;
channel_internal_name?: string;
}
export interface ConfirmSubscriptionRequest {
confirmed: boolean;
}
export interface SubscriptionListResponse {
subscriptions: Subscription[];
next_page_token?: string;
page_size: number;
total_count: number;
}

View File

@@ -0,0 +1,32 @@
export interface User {
user_id: string;
username: string | null;
timestamp_created: string;
timestamp_lastread: string | null;
timestamp_lastsent: string | null;
messages_sent: number;
is_pro: boolean;
quota_used: number;
quota_used_day: string | null;
}
export interface UserExtra {
quota_remaining: number;
quota_max: number;
quota_used: number;
default_channel: string;
max_body_size: number;
max_title_length: number;
default_priority: number;
max_channel_name_length: number;
max_channel_description_length: number;
max_sender_name_length: number;
max_user_message_id_length: number;
}
export interface UserWithExtra extends User, UserExtra {}
export interface UserPreview {
user_id: string;
username: string | null;
}

View File

@@ -0,0 +1,197 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
User,
UserWithExtra,
UserPreview,
Message,
MessageListParams,
MessageListResponse,
Channel,
ChannelWithSubscription,
ChannelSelector,
ChannelListResponse,
CreateChannelRequest,
UpdateChannelRequest,
Subscription,
SubscriptionFilter,
SubscriptionListResponse,
CreateSubscriptionRequest,
ConfirmSubscriptionRequest,
KeyToken,
KeyListResponse,
CreateKeyRequest,
UpdateKeyRequest,
Client,
ClientListResponse,
SenderNameStatistics,
SenderNameListResponse,
} from '../models';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private http = inject(HttpClient);
private baseUrl = environment.apiUrl;
// User endpoints
getUser(userId: string): Observable<UserWithExtra> {
return this.http.get<UserWithExtra>(`${this.baseUrl}/users/${userId}`);
}
getUserPreview(userId: string): Observable<UserPreview> {
return this.http.get<UserPreview>(`${this.baseUrl}/preview/users/${userId}`);
}
updateUser(userId: string, data: { username?: string; pro_token?: string }): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/users/${userId}`, data);
}
deleteUser(userId: string): Observable<User> {
return this.http.delete<User>(`${this.baseUrl}/users/${userId}`);
}
// Key endpoints
getKeys(userId: string): Observable<KeyListResponse> {
return this.http.get<KeyListResponse>(`${this.baseUrl}/users/${userId}/keys`);
}
getCurrentKey(userId: string): Observable<KeyToken> {
return this.http.get<KeyToken>(`${this.baseUrl}/users/${userId}/keys/current`);
}
getKey(userId: string, keyId: string): Observable<KeyToken> {
return this.http.get<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`);
}
createKey(userId: string, data: CreateKeyRequest): Observable<KeyToken> {
return this.http.post<KeyToken>(`${this.baseUrl}/users/${userId}/keys`, data);
}
updateKey(userId: string, keyId: string, data: UpdateKeyRequest): Observable<KeyToken> {
return this.http.patch<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`, data);
}
deleteKey(userId: string, keyId: string): Observable<KeyToken> {
return this.http.delete<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`);
}
// Client endpoints
getClients(userId: string): Observable<ClientListResponse> {
return this.http.get<ClientListResponse>(`${this.baseUrl}/users/${userId}/clients`);
}
getClient(userId: string, clientId: string): Observable<Client> {
return this.http.get<Client>(`${this.baseUrl}/users/${userId}/clients/${clientId}`);
}
deleteClient(userId: string, clientId: string): Observable<Client> {
return this.http.delete<Client>(`${this.baseUrl}/users/${userId}/clients/${clientId}`);
}
// Channel endpoints
getChannels(userId: string, selector?: ChannelSelector): Observable<ChannelListResponse> {
let params = new HttpParams();
if (selector) {
params = params.set('selector', selector);
}
return this.http.get<ChannelListResponse>(`${this.baseUrl}/users/${userId}/channels`, { params });
}
getChannel(userId: string, channelId: string): Observable<ChannelWithSubscription> {
return this.http.get<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels/${channelId}`);
}
createChannel(userId: string, data: CreateChannelRequest): Observable<ChannelWithSubscription> {
return this.http.post<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels`, data);
}
updateChannel(userId: string, channelId: string, data: UpdateChannelRequest): Observable<ChannelWithSubscription> {
return this.http.patch<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels/${channelId}`, data);
}
deleteChannel(userId: string, channelId: string): Observable<Channel> {
return this.http.delete<Channel>(`${this.baseUrl}/users/${userId}/channels/${channelId}`);
}
getChannelMessages(userId: string, channelId: string, params?: { page_size?: number; next_page_token?: string; trimmed?: boolean }): Observable<MessageListResponse> {
let httpParams = new HttpParams();
if (params?.page_size) httpParams = httpParams.set('page_size', params.page_size);
if (params?.next_page_token) httpParams = httpParams.set('next_page_token', params.next_page_token);
if (params?.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed);
return this.http.get<MessageListResponse>(`${this.baseUrl}/users/${userId}/channels/${channelId}/messages`, { params: httpParams });
}
getChannelSubscriptions(userId: string, channelId: string): Observable<SubscriptionListResponse> {
return this.http.get<SubscriptionListResponse>(`${this.baseUrl}/users/${userId}/channels/${channelId}/subscriptions`);
}
// Message endpoints
getMessages(params?: MessageListParams): Observable<MessageListResponse> {
let httpParams = new HttpParams();
if (params) {
if (params.after) httpParams = httpParams.set('after', params.after);
if (params.before) httpParams = httpParams.set('before', params.before);
if (params.channel) httpParams = httpParams.set('channel', params.channel);
if (params.priority !== undefined) httpParams = httpParams.set('priority', params.priority);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.sender) httpParams = httpParams.set('sender', params.sender);
if (params.subscription_status) httpParams = httpParams.set('subscription_status', params.subscription_status);
if (params.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed);
if (params.page_size) httpParams = httpParams.set('page_size', params.page_size);
if (params.next_page_token) httpParams = httpParams.set('next_page_token', params.next_page_token);
}
return this.http.get<MessageListResponse>(`${this.baseUrl}/messages`, { params: httpParams });
}
getMessage(messageId: string): Observable<Message> {
return this.http.get<Message>(`${this.baseUrl}/messages/${messageId}`);
}
deleteMessage(messageId: string): Observable<Message> {
return this.http.delete<Message>(`${this.baseUrl}/messages/${messageId}`);
}
// Subscription endpoints
getSubscriptions(userId: string, filter?: SubscriptionFilter): Observable<SubscriptionListResponse> {
let httpParams = new HttpParams();
if (filter) {
if (filter.direction) httpParams = httpParams.set('direction', filter.direction);
if (filter.confirmation) httpParams = httpParams.set('confirmation', filter.confirmation);
if (filter.external) httpParams = httpParams.set('external', filter.external);
if (filter.subscriber_user_id) httpParams = httpParams.set('subscriber_user_id', filter.subscriber_user_id);
if (filter.channel_owner_user_id) httpParams = httpParams.set('channel_owner_user_id', filter.channel_owner_user_id);
if (filter.page_size) httpParams = httpParams.set('page_size', filter.page_size);
if (filter.next_page_token) httpParams = httpParams.set('next_page_token', filter.next_page_token);
}
return this.http.get<SubscriptionListResponse>(`${this.baseUrl}/users/${userId}/subscriptions`, { params: httpParams });
}
getSubscription(userId: string, subscriptionId: string): Observable<Subscription> {
return this.http.get<Subscription>(`${this.baseUrl}/users/${userId}/subscriptions/${subscriptionId}`);
}
createSubscription(userId: string, data: CreateSubscriptionRequest): Observable<Subscription> {
return this.http.post<Subscription>(`${this.baseUrl}/users/${userId}/subscriptions`, data);
}
confirmSubscription(userId: string, subscriptionId: string, data: ConfirmSubscriptionRequest): Observable<Subscription> {
return this.http.patch<Subscription>(`${this.baseUrl}/users/${userId}/subscriptions/${subscriptionId}`, data);
}
deleteSubscription(userId: string, subscriptionId: string): Observable<Subscription> {
return this.http.delete<Subscription>(`${this.baseUrl}/users/${userId}/subscriptions/${subscriptionId}`);
}
// Sender names
getSenderNames(): Observable<SenderNameListResponse> {
return this.http.get<SenderNameListResponse>(`${this.baseUrl}/sender-names`);
}
getUserSenderNames(userId: string): Observable<SenderNameListResponse> {
return this.http.get<SenderNameListResponse>(`${this.baseUrl}/users/${userId}/sender-names`);
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable, signal, computed } from '@angular/core';
const USER_ID_KEY = 'scn_user_id';
const ADMIN_KEY_KEY = 'scn_admin_key';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private userId = signal<string | null>(null);
private adminKey = signal<string | null>(null);
isAuthenticated = computed(() => !!this.userId() && !!this.adminKey());
constructor() {
this.loadFromStorage();
}
private loadFromStorage(): void {
const userId = sessionStorage.getItem(USER_ID_KEY);
const adminKey = sessionStorage.getItem(ADMIN_KEY_KEY);
if (userId && adminKey) {
this.userId.set(userId);
this.adminKey.set(adminKey);
}
}
login(userId: string, adminKey: string): void {
sessionStorage.setItem(USER_ID_KEY, userId);
sessionStorage.setItem(ADMIN_KEY_KEY, adminKey);
this.userId.set(userId);
this.adminKey.set(adminKey);
}
logout(): void {
sessionStorage.removeItem(USER_ID_KEY);
sessionStorage.removeItem(ADMIN_KEY_KEY);
this.userId.set(null);
this.adminKey.set(null);
}
getUserId(): string | null {
return this.userId();
}
getAdminKey(): string | null {
return this.adminKey();
}
getAuthHeader(): string | null {
const key = this.adminKey();
return key ? `SCN ${key}` : null;
}
}

View File

@@ -0,0 +1,4 @@
export * from './auth.service';
export * from './api.service';
export * from './notification.service';
export * from './user-cache.service';

View File

@@ -0,0 +1,33 @@
import { Injectable, inject } from '@angular/core';
import { NzMessageService } from 'ng-zorro-antd/message';
@Injectable({
providedIn: 'root'
})
export class NotificationService {
private message = inject(NzMessageService);
success(content: string): void {
this.message.success(content);
}
error(content: string): void {
this.message.error(content);
}
warning(content: string): void {
this.message.warning(content);
}
info(content: string): void {
this.message.info(content);
}
loading(content: string): string {
return this.message.loading(content, { nzDuration: 0 }).messageId;
}
remove(id: string): void {
this.message.remove(id);
}
}

View File

@@ -0,0 +1,55 @@
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, tap, catchError, map, shareReplay } from 'rxjs';
import { ApiService } from './api.service';
import { AuthService } from './auth.service';
import { UserPreview } from '../models';
export interface ResolvedUser {
userId: string;
displayName: string;
isCurrentUser: boolean;
}
@Injectable({
providedIn: 'root'
})
export class UserCacheService {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private cache = new Map<string, Observable<UserPreview | null>>();
resolveUser(userId: string): Observable<ResolvedUser> {
const currentUserId = this.authService.getUserId();
const isCurrentUser = userId === currentUserId;
return this.getUserPreview(userId).pipe(
map(preview => {
let displayName = preview?.username || userId;
if (isCurrentUser) {
displayName += ' (you)';
}
return {
userId,
displayName,
isCurrentUser
};
})
);
}
private getUserPreview(userId: string): Observable<UserPreview | null> {
if (!this.cache.has(userId)) {
const request$ = this.apiService.getUserPreview(userId).pipe(
catchError(() => of(null)),
shareReplay(1)
);
this.cache.set(userId, request$);
}
return this.cache.get(userId)!;
}
clearCache(): void {
this.cache.clear();
}
}

View File

@@ -0,0 +1,105 @@
<div class="page-content">
<div class="page-header">
<h2>Account</h2>
<button nz-button (click)="loadUser()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
@if (loading()) {
<div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin>
</div>
} @else if (user()) {
<nz-card nzTitle="User Information">
<nz-descriptions nzBordered [nzColumn]="2">
<nz-descriptions-item nzTitle="User ID" [nzSpan]="2">
<span class="mono">{{ user()!.user_id }}</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Username">
{{ user()!.username || '(Not set)' }}
<button nz-button nzSize="small" nzType="link" (click)="openEditModal()">
<span nz-icon nzType="edit"></span>
</button>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Account Type">
@if (user()!.is_pro) {
<nz-tag nzColor="gold">Pro</nz-tag>
} @else {
<nz-tag>Free</nz-tag>
}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Messages Sent">
{{ user()!.messages_sent }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Created">
{{ user()!.timestamp_created | relativeTime }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Last Read">
{{ user()!.timestamp_lastread | relativeTime }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Last Sent">
{{ user()!.timestamp_lastsent | relativeTime }}
</nz-descriptions-item>
</nz-descriptions>
</nz-card>
<nz-card nzTitle="Quota" class="mt-16">
<div class="quota-info">
<div class="quota-progress">
<nz-progress
[nzPercent]="getQuotaPercent()"
[nzStatus]="getQuotaStatus()"
nzType="circle"
></nz-progress>
</div>
<div class="quota-details">
<p><strong>{{ user()!.quota_used }}</strong> / {{ user()!.quota_max }} messages used today</p>
<p class="quota-remaining">{{ user()!.quota_remaining }} remaining</p>
</div>
</div>
<nz-divider></nz-divider>
<nz-descriptions [nzColumn]="2" nzSize="small">
<nz-descriptions-item nzTitle="Max Body Size">
{{ user()!.max_body_size | number }} bytes
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Max Title Length">
{{ user()!.max_title_length }} chars
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Default Channel">
{{ user()!.default_channel }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Default Priority">
{{ user()!.default_priority }}
</nz-descriptions-item>
</nz-descriptions>
</nz-card>
}
</div>
<!-- Edit Username Modal -->
<nz-modal
[(nzVisible)]="showEditModal"
nzTitle="Edit Username"
(nzOnCancel)="closeEditModal()"
(nzOnOk)="saveUsername()"
[nzOkLoading]="saving()"
>
<ng-container *nzModalContent>
<nz-form-item class="mb-0">
<nz-form-label>Username</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
placeholder="Enter your username"
[(ngModel)]="editUsername"
/>
</nz-form-control>
</nz-form-item>
</ng-container>
</nz-modal>

View File

@@ -0,0 +1,45 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.quota-info {
display: flex;
align-items: center;
gap: 32px;
}
.quota-details {
p {
margin: 0 0 4px 0;
}
.quota-remaining {
color: #666;
font-size: 13px;
}
}
.action-section {
margin-bottom: 16px;
}
.danger-section {
p {
color: #666;
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,119 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
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 { NzProgressModule } from 'ng-zorro-antd/progress';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service';
import { UserWithExtra } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-account-info',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzCardModule,
NzButtonModule,
NzIconModule,
NzDescriptionsModule,
NzTagModule,
NzSpinModule,
NzProgressModule,
NzModalModule,
NzFormModule,
NzInputModule,
NzDividerModule,
RelativeTimePipe,
],
templateUrl: './account-info.component.html',
styleUrl: './account-info.component.scss'
})
export class AccountInfoComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
user = signal<UserWithExtra | null>(null);
loading = signal(true);
// Edit username modal
showEditModal = signal(false);
editUsername = '';
saving = signal(false);
ngOnInit(): void {
this.loadUser();
}
loadUser(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
this.apiService.getUser(userId).subscribe({
next: (user) => {
this.user.set(user);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
getQuotaPercent(): number {
const user = this.user();
if (!user || user.quota_max === 0) return 0;
return Math.round((user.quota_used / user.quota_max) * 100);
}
getQuotaStatus(): 'success' | 'normal' | 'exception' {
const percent = this.getQuotaPercent();
if (percent >= 90) return 'exception';
if (percent >= 70) return 'normal';
return 'success';
}
// Edit username
openEditModal(): void {
const user = this.user();
this.editUsername = user?.username || '';
this.showEditModal.set(true);
}
closeEditModal(): void {
this.showEditModal.set(false);
}
saveUsername(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.saving.set(true);
this.apiService.updateUser(userId, {
username: this.editUsername || undefined
}).subscribe({
next: () => {
this.notification.success('Username updated');
this.closeEditModal();
this.saving.set(false);
this.loadUser();
},
error: () => {
this.saving.set(false);
}
});
}
}

View File

@@ -0,0 +1,75 @@
<div class="login-container">
<nz-card class="login-card">
<div class="login-header">
<img src="/logo.png" alt="SimpleCloudNotifier" class="login-logo" />
<h1>SimpleCloudNotifier</h1>
</div>
@if (error()) {
<nz-alert
nzType="error"
[nzMessage]="error()!"
nzShowIcon
class="mb-16"
></nz-alert>
}
<form nz-form nzLayout="horizontal" (ngSubmit)="login()">
<nz-form-item>
<nz-form-label [nzSpan]="7">User ID</nz-form-label>
<nz-form-control [nzSpan]="17">
<nz-input-group nzPrefixIcon="user">
<input
type="text"
nz-input
placeholder="Enter your User ID"
[(ngModel)]="userId"
name="userId"
[disabled]="loading()"
/>
</nz-input-group>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="7">Admin Key</nz-form-label>
<nz-form-control [nzSpan]="17">
<nz-input-group nzPrefixIcon="key" [nzSuffix]="keySuffix">
<input
[type]="showKey() ? 'text' : 'password'"
nz-input
placeholder="Enter your Admin Key"
[(ngModel)]="adminKey"
name="adminKey"
[disabled]="loading()"
/>
</nz-input-group>
<ng-template #keySuffix>
<span
nz-icon
[nzType]="showKey() ? 'eye' : 'eye-invisible'"
class="key-toggle"
(click)="toggleShowKey()"
></span>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="mb-0">
<button
nz-button
nzType="primary"
nzBlock
type="submit"
[nzLoading]="loading()"
>
Sign In
</button>
</nz-form-item>
</form>
<div class="login-footer">
<p>You need an admin key to access the dashboard.</p>
</div>
</nz-card>
</div>

View File

@@ -0,0 +1,64 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px;
}
.login-card {
width: 100%;
max-width: 400px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.login-logo {
width: 80px;
height: auto;
margin-bottom: 16px;
}
h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
.key-toggle {
cursor: pointer;
color: #999;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.login-footer {
margin-top: 24px;
text-align: center;
p {
margin: 0;
font-size: 12px;
color: #999;
}
}
nz-form-label {
font-weight: 500;
}

View File

@@ -0,0 +1,87 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { AuthService } from '../../../core/services/auth.service';
import { ApiService } from '../../../core/services/api.service';
import { isAdminKey } from '../../../core/models';
@Component({
selector: 'app-login',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzFormModule,
NzInputModule,
NzButtonModule,
NzCardModule,
NzAlertModule,
NzIconModule,
NzSpinModule,
],
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
export class LoginComponent {
private authService = inject(AuthService);
private apiService = inject(ApiService);
private router = inject(Router);
private route = inject(ActivatedRoute);
userId = '';
adminKey = '';
loading = signal(false);
error = signal<string | null>(null);
showKey = signal(false);
async login(): Promise<void> {
if (!this.userId.trim() || !this.adminKey.trim()) {
this.error.set('Please enter both User ID and Admin Key');
return;
}
this.loading.set(true);
this.error.set(null);
// Temporarily set credentials to make the API call
this.authService.login(this.userId.trim(), this.adminKey.trim());
this.apiService.getCurrentKey(this.userId.trim()).subscribe({
next: (key) => {
if (!isAdminKey(key)) {
this.authService.logout();
this.error.set('This key does not have admin permissions. Please use an admin key.');
this.loading.set(false);
return;
}
// Login successful
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.authService.logout();
if (err.status === 401 || err.status === 403) {
this.error.set('Invalid User ID or Admin Key');
} else if (err.status === 404) {
this.error.set('User not found');
} else {
this.error.set('Failed to authenticate. Please try again.');
}
this.loading.set(false);
}
});
}
toggleShowKey(): void {
this.showKey.update(v => !v);
}
}

View File

@@ -0,0 +1,236 @@
<div class="page-content">
@if (loading()) {
<div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin>
</div>
} @else if (channel()) {
<div class="detail-header">
<button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
Back to Channels
</button>
@if (isOwner()) {
<div class="header-actions">
<button nz-button (click)="openEditModal()">
<span nz-icon nzType="edit"></span>
Edit
</button>
<button
nz-button
nzType="primary"
nzDanger
nz-popconfirm
nzPopconfirmTitle="Are you sure you want to delete this channel? All messages and subscriptions will be lost."
nzPopconfirmPlacement="bottomRight"
(nzOnConfirm)="deleteChannel()"
[nzLoading]="deleting()"
>
<span nz-icon nzType="delete"></span>
Delete
</button>
</div>
}
</div>
<nz-card [nzTitle]="channel()!.display_name">
<nz-descriptions nzBordered [nzColumn]="2">
<nz-descriptions-item nzTitle="Channel ID" [nzSpan]="2">
<span class="mono">{{ channel()!.channel_id }}</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Internal Name">
<span class="mono">{{ channel()!.internal_name }}</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Status">
<nz-tag [nzColor]="getSubscriptionStatus().color">
{{ getSubscriptionStatus().label }}
</nz-tag>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Owner" [nzSpan]="2">
<span class="mono">{{ channel()!.owner_user_id }}</span>
</nz-descriptions-item>
@if (channel()!.description_name) {
<nz-descriptions-item nzTitle="Description" [nzSpan]="2">
{{ channel()!.description_name }}
</nz-descriptions-item>
}
<nz-descriptions-item nzTitle="Messages Sent">
{{ channel()!.messages_sent }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Last Sent">
@if (channel()!.timestamp_lastsent) {
{{ channel()!.timestamp_lastsent | relativeTime }}
} @else {
Never
}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Created" [nzSpan]="2">
{{ channel()!.timestamp_created }}
</nz-descriptions-item>
</nz-descriptions>
</nz-card>
@if (isOwner()) {
<nz-card nzTitle="Keys" class="mt-16">
@if (channel()!.subscribe_key) {
<div class="key-section">
<label>Subscribe Key</label>
<nz-input-group [nzSuffix]="subscribeKeySuffix">
<input
type="text"
nz-input
[value]="channel()!.subscribe_key"
readonly
class="mono"
/>
</nz-input-group>
<ng-template #subscribeKeySuffix>
<span
nz-icon
nzType="copy"
class="action-icon"
nz-tooltip
nzTooltipTitle="Copy"
[appCopyToClipboard]="channel()!.subscribe_key!"
></span>
</ng-template>
<div class="key-actions">
<button
nz-button
nzSize="small"
nz-popconfirm
nzPopconfirmTitle="Regenerate subscribe key? The existing key will no longer be valid."
(nzOnConfirm)="regenerateSubscribeKey()"
>
Invalidate & Regenerate
</button>
</div>
<div class="qr-section">
<app-qr-code-display [data]="qrCodeData()"></app-qr-code-display>
<p class="qr-hint">Scan this QR code with the SimpleCloudNotifier app to subscribe to this channel.</p>
</div>
</div>
}
@if (channel()!.send_key) {
<nz-divider></nz-divider>
<div class="key-section">
<label>Send Key</label>
<nz-input-group [nzSuffix]="sendKeySuffix">
<input
type="text"
nz-input
[value]="channel()!.send_key"
readonly
class="mono"
/>
</nz-input-group>
<ng-template #sendKeySuffix>
<span
nz-icon
nzType="copy"
class="action-icon"
nz-tooltip
nzTooltipTitle="Copy"
[appCopyToClipboard]="channel()!.send_key!"
></span>
</ng-template>
<div class="key-actions">
<button
nz-button
nzSize="small"
nz-popconfirm
nzPopconfirmTitle="Regenerate send key?"
(nzOnConfirm)="regenerateSendKey()"
>
Regenerate
</button>
</div>
</div>
}
</nz-card>
<nz-card nzTitle="Subscriptions" class="mt-16">
<nz-table
#subscriptionTable
[nzData]="subscriptions()"
[nzLoading]="loadingSubscriptions()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="small"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th>Subscriber</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@for (sub of subscriptions(); track sub.subscription_id) {
<tr>
<td>
<span class="mono">{{ sub.subscriber_user_id }}</span>
</td>
<td>
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
</nz-tag>
</td>
<td>{{ sub.timestamp_created | relativeTime }}</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-card>
}
} @else {
<nz-card>
<div class="not-found">
<p>Channel not found</p>
<button nz-button nzType="primary" (click)="goBack()">
Back to Channels
</button>
</div>
</nz-card>
}
</div>
<!-- Edit Modal -->
<nz-modal
[(nzVisible)]="showEditModal"
nzTitle="Edit Channel"
(nzOnCancel)="closeEditModal()"
(nzOnOk)="saveChannel()"
[nzOkLoading]="saving()"
>
<ng-container *nzModalContent>
<nz-form-item>
<nz-form-label>Display Name</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
[(ngModel)]="editDisplayName"
/>
</nz-form-control>
</nz-form-item>
<nz-form-item class="mb-0">
<nz-form-label>Description</nz-form-label>
<nz-form-control>
<textarea
nz-input
rows="3"
[(ngModel)]="editDescription"
></textarea>
</nz-form-control>
</nz-form-item>
</ng-container>
</nz-modal>

View File

@@ -0,0 +1,71 @@
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.header-actions {
display: flex;
gap: 8px;
}
.key-section {
label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
}
.key-actions {
margin-top: 8px;
}
.action-icon {
cursor: pointer;
color: #999;
margin-left: 8px;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.not-found {
text-align: center;
padding: 48px;
p {
color: #999;
margin-bottom: 16px;
}
}
.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-hint {
text-align: center;
color: #666;
font-size: 13px;
margin-top: 12px;
margin-bottom: 0;
}

View File

@@ -0,0 +1,245 @@
import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } 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';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
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 { 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';
@Component({
selector: 'app-channel-detail',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzCardModule,
NzButtonModule,
NzIconModule,
NzDescriptionsModule,
NzTagModule,
NzSpinModule,
NzPopconfirmModule,
NzDividerModule,
NzInputModule,
NzModalModule,
NzFormModule,
NzTableModule,
NzToolTipModule,
NzEmptyModule,
RelativeTimePipe,
CopyToClipboardDirective,
QrCodeDisplayComponent,
],
templateUrl: './channel-detail.component.html',
styleUrl: './channel-detail.component.scss'
})
export class ChannelDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
channel = signal<ChannelWithSubscription | null>(null);
subscriptions = signal<Subscription[]>([]);
loading = signal(true);
loadingSubscriptions = signal(false);
deleting = signal(false);
// Edit modal
showEditModal = signal(false);
editDisplayName = '';
editDescription = '';
saving = signal(false);
// QR code data (computed from channel)
qrCodeData = computed(() => {
const channel = this.channel();
if (!channel || !channel.subscribe_key) return '';
return [
'@scn.channel.subscribe',
'v1',
channel.display_name,
channel.owner_user_id,
channel.channel_id,
channel.subscribe_key
].join('\n');
});
ngOnInit(): void {
const channelId = this.route.snapshot.paramMap.get('id');
if (channelId) {
this.loadChannel(channelId);
}
}
loadChannel(channelId: string): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
this.apiService.getChannel(userId, channelId).subscribe({
next: (channel) => {
this.channel.set(channel);
this.loading.set(false);
if (this.isOwner()) {
this.loadSubscriptions(channelId);
}
},
error: () => {
this.loading.set(false);
}
});
}
loadSubscriptions(channelId: string): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loadingSubscriptions.set(true);
this.apiService.getChannelSubscriptions(userId, channelId).subscribe({
next: (response) => {
this.subscriptions.set(response.subscriptions);
this.loadingSubscriptions.set(false);
},
error: () => {
this.loadingSubscriptions.set(false);
}
});
}
goBack(): void {
this.router.navigate(['/channels']);
}
isOwner(): boolean {
const channel = this.channel();
const userId = this.authService.getUserId();
return channel?.owner_user_id === userId;
}
// Edit methods
openEditModal(): void {
const channel = this.channel();
if (!channel) return;
this.editDisplayName = channel.display_name;
this.editDescription = channel.description_name || '';
this.showEditModal.set(true);
}
closeEditModal(): void {
this.showEditModal.set(false);
}
saveChannel(): void {
const channel = this.channel();
const userId = this.authService.getUserId();
if (!channel || !userId) return;
this.saving.set(true);
this.apiService.updateChannel(userId, channel.channel_id, {
display_name: this.editDisplayName,
description_name: this.editDescription || undefined
}).subscribe({
next: (updated) => {
this.channel.set(updated);
this.notification.success('Channel updated');
this.closeEditModal();
this.saving.set(false);
},
error: () => {
this.saving.set(false);
}
});
}
// Delete channel
deleteChannel(): void {
const channel = this.channel();
const userId = this.authService.getUserId();
if (!channel || !userId) return;
this.deleting.set(true);
this.apiService.deleteChannel(userId, channel.channel_id).subscribe({
next: () => {
this.notification.success('Channel deleted');
this.router.navigate(['/channels']);
},
error: () => {
this.deleting.set(false);
}
});
}
// Regenerate keys
regenerateSubscribeKey(): void {
const channel = this.channel();
const userId = this.authService.getUserId();
if (!channel || !userId) return;
this.apiService.updateChannel(userId, channel.channel_id, {
subscribe_key: 'true'
}).subscribe({
next: (updated) => {
this.channel.set(updated);
this.notification.success('Subscribe key regenerated');
}
});
}
regenerateSendKey(): void {
const channel = this.channel();
const userId = this.authService.getUserId();
if (!channel || !userId) return;
this.apiService.updateChannel(userId, channel.channel_id, {
send_key: 'true'
}).subscribe({
next: (updated) => {
this.channel.set(updated);
this.notification.success('Send key regenerated');
}
});
}
getSubscriptionStatus(): { label: string; color: string } {
const channel = this.channel();
if (!channel) return { label: 'Unknown', color: 'default' };
if (this.isOwner()) {
if (channel.subscription) {
return { label: 'Owned & Subscribed', color: 'green' };
}
return { label: 'Owned', color: 'blue' };
}
if (channel.subscription) {
if (channel.subscription.confirmed) {
return { label: 'Subscribed', color: 'green' };
}
return { label: 'Pending', color: 'orange' };
}
return { label: 'Not Subscribed', color: 'default' };
}
}

View File

@@ -0,0 +1,72 @@
<div class="page-content">
<div class="page-header">
<h2>Channels</h2>
<div class="header-actions">
<button nz-button (click)="loadChannels()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
</div>
<nz-card>
<nz-table
#channelTable
[nzData]="channels()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="20%">Name</th>
<th nzWidth="15%">Internal Name</th>
<th nzWidth="15%">Owner</th>
<th nzWidth="15%">Status</th>
<th nzWidth="15%">Messages</th>
<th nzWidth="20%">Last Sent</th>
</tr>
</thead>
<tbody>
@for (channel of channels(); track channel.channel_id) {
<tr class="clickable-row" (click)="viewChannel(channel)">
<td>
<div class="channel-name">{{ channel.display_name }}</div>
@if (channel.description_name) {
<div class="channel-description">{{ channel.description_name }}</div>
}
</td>
<td>
<span class="mono">{{ channel.internal_name }}</span>
</td>
<td>{{ getOwnerDisplayName(channel.owner_user_id) }}</td>
<td>
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
{{ getSubscriptionStatus(channel).label }}
</nz-tag>
</td>
<td>{{ channel.messages_sent }}</td>
<td>
@if (channel.timestamp_lastsent) {
<span nz-tooltip [nzTooltipTitle]="channel.timestamp_lastsent">
{{ channel.timestamp_lastsent | relativeTime }}
</span>
} @else {
<span class="text-muted">Never</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="6">
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-card>
</div>

View File

@@ -0,0 +1,34 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.header-actions {
display: flex;
gap: 8px;
}
.filter-card {
margin-bottom: 16px;
}
.channel-name {
font-weight: 500;
color: #333;
}
.channel-description {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.text-muted {
color: #999;
}

View File

@@ -0,0 +1,104 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } 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';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzBadgeModule } from 'ng-zorro-antd/badge';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
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 { ChannelWithSubscription } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-channel-list',
standalone: true,
imports: [
CommonModule,
NzTableModule,
NzButtonModule,
NzIconModule,
NzTagModule,
NzBadgeModule,
NzEmptyModule,
NzCardModule,
NzToolTipModule,
RelativeTimePipe,
],
templateUrl: './channel-list.component.html',
styleUrl: './channel-list.component.scss'
})
export class ChannelListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private userCacheService = inject(UserCacheService);
private router = inject(Router);
channels = signal<ChannelWithSubscription[]>([]);
ownerNames = signal<Map<string, ResolvedUser>>(new Map());
loading = signal(false);
ngOnInit(): void {
this.loadChannels();
}
loadChannels(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
this.apiService.getChannels(userId, 'all_any').subscribe({
next: (response) => {
this.channels.set(response.channels);
this.loading.set(false);
this.resolveOwnerNames(response.channels);
},
error: () => {
this.loading.set(false);
}
});
}
private resolveOwnerNames(channels: ChannelWithSubscription[]): void {
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
for (const ownerId of uniqueOwnerIds) {
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
this.ownerNames.update(map => new Map(map).set(ownerId, resolved));
});
}
}
getOwnerDisplayName(ownerId: string): string {
const resolved = this.ownerNames().get(ownerId);
return resolved?.displayName || ownerId;
}
viewChannel(channel: ChannelWithSubscription): void {
this.router.navigate(['/channels', channel.channel_id]);
}
getSubscriptionStatus(channel: ChannelWithSubscription): { label: string; color: string } {
const userId = this.authService.getUserId();
if (channel.owner_user_id === userId) {
if (channel.subscription) {
return { label: 'Owned & Subscribed', color: 'green' };
}
return { label: 'Owned', color: 'blue' };
}
if (channel.subscription) {
if (channel.subscription.confirmed) {
return { label: 'Subscribed', color: 'green' };
}
return { label: 'Pending', color: 'orange' };
}
return { label: 'Not Subscribed', color: 'default' };
}
}

View File

@@ -0,0 +1,70 @@
<div class="page-content">
<div class="page-header">
<h2>Clients</h2>
<button nz-button (click)="loadClients()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
<nz-card>
<nz-table
#clientTable
[nzData]="clients()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="5%"></th>
<th nzWidth="20%">Name</th>
<th nzWidth="15%">Type</th>
<th nzWidth="25%">Agent</th>
<th nzWidth="20%">Created</th>
<th nzWidth="15%">Client ID</th>
</tr>
</thead>
<tbody>
@for (client of clients(); track client.client_id) {
<tr>
<td>
<span
nz-icon
[nzType]="getClientIcon(client.type)"
nzTheme="outline"
class="client-icon"
></span>
</td>
<td>{{ client.name || '-' }}</td>
<td>
<nz-tag>{{ getClientTypeLabel(client.type) }}</nz-tag>
</td>
<td>
<div class="agent-info">
<span>{{ client.agent_model }}</span>
<span class="agent-version">v{{ client.agent_version }}</span>
</div>
</td>
<td>
<span nz-tooltip [nzTooltipTitle]="client.timestamp_created">
{{ client.timestamp_created | relativeTime }}
</span>
</td>
<td>
<span class="mono client-id">{{ client.client_id }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="6">
<nz-empty nzNotFoundContent="No clients registered"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-card>
</div>

View File

@@ -0,0 +1,30 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.client-icon {
font-size: 18px;
color: #666;
}
.agent-info {
display: flex;
flex-direction: column;
.agent-version {
font-size: 12px;
color: #999;
}
}
.client-id {
font-size: 11px;
color: #999;
}

View File

@@ -0,0 +1,73 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-client-list',
standalone: true,
imports: [
CommonModule,
NzTableModule,
NzButtonModule,
NzIconModule,
NzTagModule,
NzEmptyModule,
NzCardModule,
NzToolTipModule,
RelativeTimePipe,
],
templateUrl: './client-list.component.html',
styleUrl: './client-list.component.scss'
})
export class ClientListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
clients = signal<Client[]>([]);
loading = signal(false);
ngOnInit(): void {
this.loadClients();
}
loadClients(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
this.apiService.getClients(userId).subscribe({
next: (response) => {
this.clients.set(response.clients);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
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;
}
}
}

View File

@@ -0,0 +1,207 @@
<div class="page-content">
<div class="page-header">
<h2>Keys</h2>
<div class="header-actions">
<button nz-button (click)="loadKeys()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
<button nz-button nzType="primary" (click)="openCreateModal()">
<span nz-icon nzType="plus"></span>
Create Key
</button>
</div>
</div>
<nz-card>
<nz-table
#keyTable
[nzData]="keys()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="25%">Name</th>
<th nzWidth="25%">Permissions</th>
<th nzWidth="15%">Messages Sent</th>
<th nzWidth="20%">Last Used</th>
<th nzWidth="15%">Actions</th>
</tr>
</thead>
<tbody>
@for (key of keys(); track key.keytoken_id) {
<tr>
<td>
<div class="key-name">
{{ key.name }}
@if (isCurrentKey(key)) {
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
}
</div>
<div class="key-id mono">{{ key.keytoken_id }}</div>
</td>
<td>
<div class="permissions">
@for (perm of getPermissions(key); track perm) {
<nz-tag
[nzColor]="getPermissionColor(perm)"
nz-tooltip
[nzTooltipTitle]="getPermissionLabel(perm)"
>
{{ perm }}
</nz-tag>
}
@if (key.all_channels) {
<nz-tag nzColor="default" nz-tooltip nzTooltipTitle="Access to all channels">
All Channels
</nz-tag>
}
</div>
</td>
<td>{{ key.messages_sent }}</td>
<td>
@if (key.timestamp_lastused) {
<span nz-tooltip [nzTooltipTitle]="key.timestamp_lastused">
{{ key.timestamp_lastused | relativeTime }}
</span>
} @else {
<span class="text-muted">Never</span>
}
</td>
<td>
@if (!isCurrentKey(key)) {
<button
nz-button
nzSize="small"
nzDanger
nz-popconfirm
nzPopconfirmTitle="Are you sure you want to delete this key?"
(nzOnConfirm)="deleteKey(key)"
>
<span nz-icon nzType="delete"></span>
</button>
} @else {
<span class="text-muted" nz-tooltip nzTooltipTitle="Cannot delete the key you're currently using">
-
</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="5">
<nz-empty nzNotFoundContent="No keys found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-card>
</div>
<!-- Create Key Modal -->
<nz-modal
[(nzVisible)]="showCreateModal"
nzTitle="Create Key"
(nzOnCancel)="closeCreateModal()"
[nzFooter]="createModalFooter"
nzWidth="500px"
>
<ng-container *nzModalContent>
@if (createdKey()) {
<!-- Show created key -->
<nz-alert
nzType="success"
nzMessage="Key created successfully!"
nzDescription="Make sure to copy the token now. You won't be able to see it again."
nzShowIcon
class="mb-16"
></nz-alert>
<nz-form-item>
<nz-form-label>Key Token</nz-form-label>
<nz-form-control>
<nz-input-group [nzSuffix]="copyButton">
<input
type="text"
nz-input
[value]="createdKey()!.token"
readonly
class="mono"
/>
</nz-input-group>
<ng-template #copyButton>
<span
nz-icon
nzType="copy"
class="copy-icon"
nz-tooltip
nzTooltipTitle="Copy"
[appCopyToClipboard]="createdKey()!.token!"
></span>
</ng-template>
</nz-form-control>
</nz-form-item>
} @else {
<!-- Create form -->
<nz-form-item>
<nz-form-label>Name</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
placeholder="Enter a name for this key"
[(ngModel)]="newKeyName"
/>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label>Permissions</nz-form-label>
<nz-form-control>
<div class="permission-checkboxes">
@for (opt of permissionOptions; track opt.value) {
<label
nz-checkbox
[nzChecked]="isPermissionChecked(opt.value)"
[nzDisabled]="opt.value !== 'A' && isPermissionChecked('A')"
(nzCheckedChange)="onPermissionChange(opt.value, $event)"
>
<nz-tag [nzColor]="getPermissionColor(opt.value)">{{ opt.value }}</nz-tag>
<span class="perm-label">{{ opt.label }}</span>
<span class="perm-desc">- {{ opt.description }}</span>
</label>
}
</div>
</nz-form-control>
</nz-form-item>
<nz-form-item class="mb-0">
<label nz-checkbox [(ngModel)]="newKeyAllChannels">
Access to all channels
</label>
</nz-form-item>
}
</ng-container>
</nz-modal>
<ng-template #createModalFooter>
@if (createdKey()) {
<button nz-button nzType="primary" (click)="closeCreateModal()">Done</button>
} @else {
<button nz-button (click)="closeCreateModal()">Cancel</button>
<button
nz-button
nzType="primary"
[nzLoading]="creating()"
[disabled]="!newKeyName.trim() || newKeyPermissions.length === 0"
(click)="createKey()"
>
Create
</button>
}
</ng-template>

View File

@@ -0,0 +1,80 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.header-actions {
display: flex;
gap: 8px;
}
.key-name {
font-weight: 500;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.key-id {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.current-tag {
font-size: 11px;
}
.permissions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.text-muted {
color: #999;
}
.copy-icon {
cursor: pointer;
color: #999;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.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;
}
}

View File

@@ -0,0 +1,201 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
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 { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service';
import { KeyToken, parsePermissions, TokenPermission } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
interface PermissionOption {
value: TokenPermission;
label: string;
description: string;
}
@Component({
selector: 'app-key-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzTableModule,
NzButtonModule,
NzIconModule,
NzTagModule,
NzEmptyModule,
NzCardModule,
NzPopconfirmModule,
NzModalModule,
NzFormModule,
NzInputModule,
NzCheckboxModule,
NzToolTipModule,
NzAlertModule,
RelativeTimePipe,
CopyToClipboardDirective,
],
templateUrl: './key-list.component.html',
styleUrl: './key-list.component.scss'
})
export class KeyListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
keys = signal<KeyToken[]>([]);
currentKeyId = signal<string | null>(null);
loading = signal(false);
// Create modal
showCreateModal = signal(false);
newKeyName = '';
newKeyPermissions: TokenPermission[] = ['CR'];
newKeyAllChannels = true;
creating = signal(false);
createdKey = signal<KeyToken | null>(null);
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 {
this.loadKeys();
this.loadCurrentKey();
}
loadKeys(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
this.apiService.getKeys(userId).subscribe({
next: (response) => {
this.keys.set(response.keys);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
loadCurrentKey(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.getCurrentKey(userId).subscribe({
next: (key) => {
this.currentKeyId.set(key.keytoken_id);
}
});
}
isCurrentKey(key: KeyToken): boolean {
return key.keytoken_id === this.currentKeyId();
}
deleteKey(key: KeyToken): void {
if (this.isCurrentKey(key)) {
this.notification.warning('Cannot delete the key you are currently using');
return;
}
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.deleteKey(userId, key.keytoken_id).subscribe({
next: () => {
this.notification.success('Key deleted');
this.loadKeys();
}
});
}
// Create key modal
openCreateModal(): void {
this.newKeyName = '';
this.newKeyPermissions = ['CR'];
this.newKeyAllChannels = true;
this.createdKey.set(null);
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
createKey(): void {
const userId = this.authService.getUserId();
if (!userId || !this.newKeyName.trim() || this.newKeyPermissions.length === 0) return;
this.creating.set(true);
this.apiService.createKey(userId, {
name: this.newKeyName.trim(),
permissions: this.newKeyPermissions.join(';'),
all_channels: this.newKeyAllChannels
}).subscribe({
next: (key) => {
this.createdKey.set(key);
this.creating.set(false);
this.loadKeys();
},
error: () => {
this.creating.set(false);
}
});
}
getPermissions(key: KeyToken): TokenPermission[] {
return 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;
}
onPermissionChange(perm: TokenPermission, checked: boolean): void {
if (checked) {
if (perm === 'A') {
// Admin selected - clear other permissions
this.newKeyPermissions = ['A'];
} else if (!this.newKeyPermissions.includes(perm)) {
this.newKeyPermissions = [...this.newKeyPermissions, perm];
}
} else {
this.newKeyPermissions = this.newKeyPermissions.filter(p => p !== perm);
}
}
isPermissionChecked(perm: TokenPermission): boolean {
return this.newKeyPermissions.includes(perm);
}
}

View File

@@ -0,0 +1,74 @@
<div class="page-content">
@if (loading()) {
<div class="loading-container">
<nz-spin nzSimple nzSize="large"></nz-spin>
</div>
} @else if (message()) {
<div class="detail-header">
<button nz-button (click)="goBack()">
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
Back to Messages
</button>
<button
nz-button
nzType="primary"
nzDanger
nz-popconfirm
nzPopconfirmTitle="Are you sure you want to delete this message?"
nzPopconfirmPlacement="bottomRight"
(nzOnConfirm)="deleteMessage()"
[nzLoading]="deleting()"
>
<span nz-icon nzType="delete"></span>
Delete
</button>
</div>
<nz-card [nzTitle]="message()!.title">
<nz-descriptions nzBordered [nzColumn]="2">
<nz-descriptions-item nzTitle="Message ID" [nzSpan]="2">
<span class="mono">{{ message()!.message_id }}</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Channel">
{{ message()!.channel_internal_name }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Priority">
<nz-tag [nzColor]="getPriorityColor(message()!.priority)">
{{ getPriorityLabel(message()!.priority) }}
</nz-tag>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Sender Name">
{{ message()!.sender_name || '-' }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Sender IP">
{{ message()!.sender_ip }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Timestamp" [nzSpan]="2">
{{ message()!.timestamp }} ({{ message()!.timestamp | relativeTime }})
</nz-descriptions-item>
<nz-descriptions-item nzTitle="User Message ID" [nzSpan]="2">
<span class="mono">{{ message()!.usr_message_id || '-' }}</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Used Key ID" [nzSpan]="2">
<span class="mono">{{ message()!.used_key_id }}</span>
</nz-descriptions-item>
</nz-descriptions>
@if (message()!.content) {
<nz-divider nzText="Content"></nz-divider>
<div class="message-content">
<pre>{{ message()!.content }}</pre>
</div>
}
</nz-card>
} @else {
<nz-card>
<div class="not-found">
<p>Message not found</p>
<button nz-button nzType="primary" (click)="goBack()">
Back to Messages
</button>
</div>
</nz-card>
}
</div>

View File

@@ -0,0 +1,31 @@
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.message-content {
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 13px;
}
}
.not-found {
text-align: center;
padding: 48px;
p {
color: #999;
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,102 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } 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 { 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 { Message } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-message-detail',
standalone: true,
imports: [
CommonModule,
NzCardModule,
NzButtonModule,
NzIconModule,
NzDescriptionsModule,
NzTagModule,
NzSpinModule,
NzPopconfirmModule,
NzDividerModule,
RelativeTimePipe,
],
templateUrl: './message-detail.component.html',
styleUrl: './message-detail.component.scss'
})
export class MessageDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
private notification = inject(NotificationService);
message = signal<Message | null>(null);
loading = signal(true);
deleting = signal(false);
ngOnInit(): void {
const messageId = this.route.snapshot.paramMap.get('id');
if (messageId) {
this.loadMessage(messageId);
}
}
loadMessage(messageId: string): void {
this.loading.set(true);
this.apiService.getMessage(messageId).subscribe({
next: (message) => {
this.message.set(message);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
goBack(): void {
this.router.navigate(['/messages']);
}
deleteMessage(): void {
const message = this.message();
if (!message) return;
this.deleting.set(true);
this.apiService.deleteMessage(message.message_id).subscribe({
next: () => {
this.notification.success('Message deleted');
this.router.navigate(['/messages']);
},
error: () => {
this.deleting.set(false);
}
});
}
getPriorityLabel(priority: number): string {
switch (priority) {
case 0: return 'Low';
case 1: return 'Normal';
case 2: return 'High';
default: return 'Unknown';
}
}
getPriorityColor(priority: number): string {
switch (priority) {
case 0: return 'default';
case 1: return 'blue';
case 2: return 'red';
default: return 'default';
}
}
}

View File

@@ -0,0 +1,122 @@
<div class="page-content">
<div class="page-header">
<h2>Messages</h2>
<button nz-button nzType="default" (click)="loadMessages()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
<div class="search-bar">
<nz-input-group nzSearch [nzAddOnAfter]="searchButton">
<input
type="text"
nz-input
placeholder="Search messages..."
[(ngModel)]="searchText"
(keyup.enter)="applyFilters()"
/>
</nz-input-group>
<ng-template #searchButton>
<button nz-button nzType="primary" nzSearch (click)="applyFilters()">
<span nz-icon nzType="search"></span>
</button>
</ng-template>
</div>
@if (hasActiveFilters()) {
<div class="active-filters">
@if (appliedSearchText) {
<nz-tag nzMode="closeable" (nzOnClose)="clearSearch()">
"{{ appliedSearchText }}"
</nz-tag>
}
@for (channel of channelFilter; track channel) {
<nz-tag nzMode="closeable" (nzOnClose)="removeChannelFilter(channel)">
{{ getChannelDisplayName(channel) }}
</nz-tag>
}
@if (priorityFilter.length > 0) {
<nz-tag nzMode="closeable" (nzOnClose)="clearPriorityFilter()">
{{ getPriorityLabel(+priorityFilter[0]) }}
</nz-tag>
}
<a class="clear-all" (click)="clearAllFilters()">Clear all</a>
</div>
}
<nz-table
#messageTable
nzBordered
[nzData]="messages()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="40%">Title</th>
<th
nzWidth="15%"
[nzFilters]="channelFilters()"
[nzFilterMultiple]="true"
(nzFilterChange)="onChannelFilterChange($event)"
>Channel</th>
<th nzWidth="15%">Sender</th>
<th
nzWidth="10%"
[nzFilters]="priorityFilters"
[nzFilterMultiple]="false"
(nzFilterChange)="onPriorityFilterChange($event)"
>Priority</th>
<th nzWidth="20%">Time</th>
</tr>
</thead>
<tbody>
@for (message of messages(); track message.message_id) {
<tr class="clickable-row" (click)="viewMessage(message)">
<td>
<div class="message-title">{{ message.title }}</div>
@if (message.content && !message.trimmed) {
<div class="message-preview">{{ message.content | slice:0:100 }}{{ message.content.length > 100 ? '...' : '' }}</div>
}
</td>
<td>
<span class="mono">{{ message.channel_internal_name }}</span>
</td>
<td>
{{ message.sender_name || '-' }}
</td>
<td>
<nz-tag [nzColor]="getPriorityColor(message.priority)">
{{ getPriorityLabel(message.priority) }}
</nz-tag>
</td>
<td>
<span nz-tooltip [nzTooltipTitle]="message.timestamp">
{{ message.timestamp | relativeTime }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="5">
<nz-empty nzNotFoundContent="No messages found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
<div class="pagination-controls">
<nz-pagination
[nzPageIndex]="currentPage()"
[nzPageSize]="pageSize"
[nzTotal]="totalCount()"
[nzDisabled]="loading()"
(nzPageIndexChange)="goToPage($event)"
></nz-pagination>
</div>
</div>

View File

@@ -0,0 +1,58 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.search-bar {
margin-bottom: 16px;
}
.active-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
nz-tag {
max-width: none;
}
.clear-all {
margin-left: 8px;
font-size: 12px;
cursor: pointer;
}
}
.message-title {
font-weight: 500;
color: #333;
}
.message-preview {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px 0;
.page-indicator {
font-size: 14px;
color: #666;
min-width: 80px;
text-align: center;
}
}

View File

@@ -0,0 +1,211 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { Message, MessageListParams } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-message-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzTableModule,
NzButtonModule,
NzInputModule,
NzTagModule,
NzIconModule,
NzEmptyModule,
NzSpinModule,
NzToolTipModule,
NzPaginationModule,
RelativeTimePipe,
],
templateUrl: './message-list.component.html',
styleUrl: './message-list.component.scss'
})
export class MessageListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private router = inject(Router);
messages = signal<Message[]>([]);
loading = signal(false);
// Pagination
currentPage = signal(1);
pageSize = 50;
totalCount = signal(0);
// Filters
searchText = '';
appliedSearchText = '';
priorityFilter: string[] = [];
channelFilter: string[] = [];
// Filter options
priorityFilters: NzTableFilterList = [
{ text: 'Low', value: '0' },
{ text: 'Normal', value: '1' },
{ text: 'High', value: '2' },
];
channelFilters = signal<NzTableFilterList>([]);
ngOnInit(): void {
this.loadChannels();
this.loadMessages();
}
loadChannels(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.getChannels(userId, 'all_any').subscribe({
next: (response) => {
this.channelFilters.set(
response.channels.map(ch => ({
text: ch.display_name,
value: ch.internal_name,
}))
);
}
});
}
loadMessages(): void {
this.loading.set(true);
const params: MessageListParams = {
page_size: this.pageSize,
trimmed: true,
};
if (this.appliedSearchText) {
params.search = this.appliedSearchText;
}
if (this.priorityFilter.length === 1) {
params.priority = parseInt(this.priorityFilter[0], 10);
}
if (this.channelFilter.length > 0) {
params.channel = this.channelFilter.join(',');
}
// Use page-index based pagination: $1 = page 1, $2 = page 2, etc.
const page = this.currentPage();
if (page > 1) {
params.next_page_token = `$${page}`;
}
this.apiService.getMessages(params).subscribe({
next: (response) => {
this.messages.set(response.messages);
this.totalCount.set(response.total_count);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
applyFilters(): void {
this.appliedSearchText = this.searchText;
this.currentPage.set(1);
this.loadMessages();
}
onPriorityFilterChange(filters: string[] | null): void {
this.priorityFilter = filters ?? [];
this.currentPage.set(1);
this.loadMessages();
}
onChannelFilterChange(filters: string[] | null): void {
this.channelFilter = filters ?? [];
this.currentPage.set(1);
this.loadMessages();
}
clearSearch(): void {
this.searchText = '';
this.appliedSearchText = '';
this.currentPage.set(1);
this.loadMessages();
}
clearChannelFilter(): void {
this.channelFilter = [];
this.currentPage.set(1);
this.loadMessages();
}
removeChannelFilter(channel: string): void {
this.channelFilter = this.channelFilter.filter(c => c !== channel);
this.currentPage.set(1);
this.loadMessages();
}
clearPriorityFilter(): void {
this.priorityFilter = [];
this.currentPage.set(1);
this.loadMessages();
}
clearAllFilters(): void {
this.searchText = '';
this.appliedSearchText = '';
this.channelFilter = [];
this.priorityFilter = [];
this.currentPage.set(1);
this.loadMessages();
}
hasActiveFilters(): boolean {
return !!this.appliedSearchText || this.channelFilter.length > 0 || this.priorityFilter.length > 0;
}
getChannelDisplayName(internalName: string): string {
const filters = this.channelFilters();
const channel = filters.find(f => f.value === internalName);
return channel?.text?.toString() ?? internalName;
}
goToPage(page: number): void {
this.currentPage.set(page);
this.loadMessages();
}
viewMessage(message: Message): void {
this.router.navigate(['/messages', message.message_id]);
}
getPriorityLabel(priority: number): string {
switch (priority) {
case 0: return 'Low';
case 1: return 'Normal';
case 2: return 'High';
default: return 'Unknown';
}
}
getPriorityColor(priority: number): string {
switch (priority) {
case 0: return 'default';
case 1: return 'blue';
case 2: return 'red';
default: return 'default';
}
}
}

View File

@@ -0,0 +1,95 @@
<div class="page-content">
<div class="page-header">
<h2>Senders</h2>
<button nz-button (click)="refresh()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
<nz-card>
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
<nz-tab nzTitle="My Senders">
<nz-table
#mySenderTable
[nzData]="mySenders()"
[nzLoading]="loadingMy()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="40%">Sender Name</th>
<th nzWidth="20%">Message Count</th>
<th nzWidth="40%">Last Used</th>
</tr>
</thead>
<tbody>
@for (sender of mySenders(); track sender.name) {
<tr>
<td>
<span class="sender-name">{{ sender.name || '(No name)' }}</span>
</td>
<td>{{ sender.count }}</td>
<td>
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
{{ sender.last_timestamp | relativeTime }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<nz-empty nzNotFoundContent="No senders found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-tab>
<nz-tab nzTitle="All Senders">
<nz-table
#allSenderTable
[nzData]="allSenders()"
[nzLoading]="loadingAll()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl2"
nzSize="middle"
>
<ng-template #noResultTpl2></ng-template>
<thead>
<tr>
<th nzWidth="40%">Sender Name</th>
<th nzWidth="20%">Message Count</th>
<th nzWidth="40%">Last Used</th>
</tr>
</thead>
<tbody>
@for (sender of allSenders(); track sender.name) {
<tr>
<td>
<span class="sender-name">{{ sender.name || '(No name)' }}</span>
</td>
<td>{{ sender.count }}</td>
<td>
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
{{ sender.last_timestamp | relativeTime }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<nz-empty nzNotFoundContent="No senders found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-tab>
</nz-tabset>
</nz-card>
</div>

View File

@@ -0,0 +1,14 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.sender-name {
font-weight: 500;
}

View File

@@ -0,0 +1,91 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { SenderNameStatistics } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
selector: 'app-sender-list',
standalone: true,
imports: [
CommonModule,
NzTableModule,
NzButtonModule,
NzIconModule,
NzEmptyModule,
NzCardModule,
NzToolTipModule,
NzTabsModule,
RelativeTimePipe,
],
templateUrl: './sender-list.component.html',
styleUrl: './sender-list.component.scss'
})
export class SenderListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
mySenders = signal<SenderNameStatistics[]>([]);
allSenders = signal<SenderNameStatistics[]>([]);
loadingMy = signal(false);
loadingAll = signal(false);
activeTab = signal(0);
ngOnInit(): void {
this.loadMySenders();
}
onTabChange(index: number): void {
this.activeTab.set(index);
if (index === 0 && this.mySenders().length === 0) {
this.loadMySenders();
} else if (index === 1 && this.allSenders().length === 0) {
this.loadAllSenders();
}
}
loadMySenders(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loadingMy.set(true);
this.apiService.getUserSenderNames(userId).subscribe({
next: (response) => {
this.mySenders.set(response.sender_names);
this.loadingMy.set(false);
},
error: () => {
this.loadingMy.set(false);
}
});
}
loadAllSenders(): void {
this.loadingAll.set(true);
this.apiService.getSenderNames().subscribe({
next: (response) => {
this.allSenders.set(response.sender_names);
this.loadingAll.set(false);
},
error: () => {
this.loadingAll.set(false);
}
});
}
refresh(): void {
if (this.activeTab() === 0) {
this.loadMySenders();
} else {
this.loadAllSenders();
}
}
}

View File

@@ -0,0 +1,176 @@
<div class="page-content">
<div class="page-header">
<h2>Subscriptions</h2>
<div class="header-actions">
<button nz-button (click)="loadSubscriptions()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
<button nz-button nzType="primary" (click)="openCreateModal()">
<span nz-icon nzType="plus"></span>
Subscribe
</button>
</div>
</div>
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
<nz-tab nzTitle="All"></nz-tab>
<nz-tab nzTitle="Own"></nz-tab>
<nz-tab nzTitle="Deactivated"></nz-tab>
<nz-tab nzTitle="External"></nz-tab>
<nz-tab nzTitle="Incoming"></nz-tab>
</nz-tabset>
@if (getTabDescription()) {
<nz-alert
nzType="info"
[nzMessage]="getTabDescription()!"
nzShowIcon
style="margin-bottom: 16px;"
></nz-alert>
}
<nz-table
#subscriptionTable
nzBordered
[nzData]="subscriptions()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="10%">Type</th>
<th nzWidth="20%">Channel</th>
<th nzWidth="20%">Subscriber</th>
<th nzWidth="20%">Owner</th>
<th nzWidth="10%">Status</th>
<th nzWidth="12%">Created</th>
<th nzWidth="8%">Actions</th>
</tr>
</thead>
<tbody>
@for (sub of subscriptions(); track sub.subscription_id) {
<tr>
<td>
<nz-tag [nzColor]="getTypeLabel(sub).color">
{{ getTypeLabel(sub).label }}
</nz-tag>
</td>
<td>
<span class="mono">{{ sub.channel_internal_name }}</span>
</td>
<td>{{ getUserDisplayName(sub.subscriber_user_id) }}</td>
<td>{{ getUserDisplayName(sub.channel_owner_user_id) }}</td>
<td>
<nz-tag [nzColor]="getStatusInfo(sub).color">
{{ getStatusInfo(sub).label }}
</nz-tag>
</td>
<td>
<span nz-tooltip [nzTooltipTitle]="sub.timestamp_created">
{{ sub.timestamp_created | relativeTime }}
</span>
</td>
<td>
<div class="action-buttons">
@if (!sub.confirmed && isOwner(sub)) {
<!-- Incoming unconfirmed: can accept or deny -->
<button
nz-button
nzSize="small"
nzType="primary"
nz-tooltip
nzTooltipTitle="Accept"
(click)="acceptSubscription(sub)"
>
<span nz-icon nzType="check"></span>
</button>
<button
nz-button
nzSize="small"
nzDanger
nz-tooltip
nzTooltipTitle="Deny"
nz-popconfirm
nzPopconfirmTitle="Deny this subscription request?"
(nzOnConfirm)="denySubscription(sub)"
>
<span nz-icon nzType="close"></span>
</button>
} @else {
<!-- Confirmed or outgoing: can revoke -->
<button
nz-button
nzSize="small"
nzDanger
nz-tooltip
nzTooltipTitle="Revoke"
nz-popconfirm
nzPopconfirmTitle="Revoke this subscription?"
(nzOnConfirm)="revokeSubscription(sub)"
>
<span nz-icon nzType="delete"></span>
</button>
}
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="7">
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
<div class="pagination-controls">
<nz-pagination
[nzPageIndex]="currentPage()"
[nzPageSize]="pageSize"
[nzTotal]="totalCount()"
[nzDisabled]="loading()"
(nzPageIndexChange)="goToPage($event)"
></nz-pagination>
</div>
</div>
<!-- Create Subscription Modal -->
<nz-modal
[(nzVisible)]="showCreateModal"
nzTitle="Subscribe to Channel"
(nzOnCancel)="closeCreateModal()"
(nzOnOk)="createSubscription()"
[nzOkLoading]="creating()"
[nzOkDisabled]="!newChannelOwner.trim() || !newChannelName.trim()"
>
<ng-container *nzModalContent>
<p class="modal-hint">Enter the channel owner's User ID and the channel name to subscribe.</p>
<nz-form-item>
<nz-form-label>Channel Owner User ID</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
placeholder="e.g., USR12345"
[(ngModel)]="newChannelOwner"
/>
</nz-form-control>
</nz-form-item>
<nz-form-item class="mb-0">
<nz-form-label>Channel Name</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
placeholder="e.g., main"
[(ngModel)]="newChannelName"
/>
</nz-form-control>
</nz-form-item>
</ng-container>
</nz-modal>

View File

@@ -0,0 +1,38 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
}
}
.header-actions {
display: flex;
gap: 8px;
}
.label {
font-size: 12px;
color: #999;
margin-right: 4px;
}
.action-buttons {
display: flex;
gap: 4px;
}
.modal-hint {
color: #666;
font-size: 13px;
margin-bottom: 16px;
}
.pagination-controls {
display: flex;
justify-content: center;
padding: 16px 0;
}

View File

@@ -0,0 +1,259 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
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 { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
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 { Subscription, SubscriptionFilter } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
type SubscriptionTab = 'all' | 'own' | 'deactivated' | 'external' | 'incoming';
interface TabConfig {
filter: SubscriptionFilter;
}
const TAB_CONFIGS: Record<SubscriptionTab, TabConfig> = {
all: { filter: {} },
own: { filter: { direction: 'outgoing', confirmation: 'confirmed', external: 'false' } },
deactivated: { filter: { direction: 'outgoing', confirmation: 'unconfirmed', external: 'false' } },
external: { filter: { direction: 'outgoing', confirmation: 'all', external: 'true' } },
incoming: { filter: { direction: 'incoming', confirmation: 'all', external: 'true' } },
};
@Component({
selector: 'app-subscription-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NzTableModule,
NzButtonModule,
NzIconModule,
NzTagModule,
NzEmptyModule,
NzTabsModule,
NzPopconfirmModule,
NzModalModule,
NzFormModule,
NzInputModule,
NzToolTipModule,
NzAlertModule,
NzPaginationModule,
RelativeTimePipe,
],
templateUrl: './subscription-list.component.html',
styleUrl: './subscription-list.component.scss'
})
export class SubscriptionListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
private userCacheService = inject(UserCacheService);
subscriptions = signal<Subscription[]>([]);
userNames = signal<Map<string, ResolvedUser>>(new Map());
loading = signal(false);
activeTab: SubscriptionTab = 'all';
// Pagination
currentPage = signal(1);
pageSize = 50;
totalCount = signal(0);
// Create subscription modal
showCreateModal = signal(false);
newChannelOwner = '';
newChannelName = '';
creating = signal(false);
ngOnInit(): void {
this.loadSubscriptions();
}
loadSubscriptions(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loading.set(true);
const filter: SubscriptionFilter = {
...TAB_CONFIGS[this.activeTab].filter,
page_size: this.pageSize,
};
// Use page-index based pagination: $1 = page 1, $2 = page 2, etc.
const page = this.currentPage();
if (page > 1) {
filter.next_page_token = `$${page}`;
}
this.apiService.getSubscriptions(userId, filter).subscribe({
next: (response) => {
this.subscriptions.set(response.subscriptions);
this.totalCount.set(response.total_count);
this.loading.set(false);
this.resolveUserNames(response.subscriptions);
},
error: () => {
this.loading.set(false);
}
});
}
private resolveUserNames(subscriptions: Subscription[]): void {
const userIds = new Set<string>();
for (const sub of subscriptions) {
userIds.add(sub.subscriber_user_id);
userIds.add(sub.channel_owner_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;
}
onTabChange(index: number): void {
const tabs: SubscriptionTab[] = ['all', 'own', 'deactivated', 'external', 'incoming'];
this.activeTab = tabs[index];
this.currentPage.set(1);
this.loadSubscriptions();
}
goToPage(page: number): void {
this.currentPage.set(page);
this.loadSubscriptions();
}
isOutgoing(sub: Subscription): boolean {
const userId = this.authService.getUserId();
return sub.subscriber_user_id === userId;
}
isOwner(sub: Subscription): boolean {
const userId = this.authService.getUserId();
return sub.channel_owner_user_id === userId;
}
// Actions
acceptSubscription(sub: Subscription): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: true }).subscribe({
next: () => {
this.notification.success('Subscription accepted');
this.loadSubscriptions();
}
});
}
denySubscription(sub: Subscription): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
next: () => {
this.notification.success('Subscription denied');
this.loadSubscriptions();
}
});
}
revokeSubscription(sub: Subscription): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
next: () => {
this.notification.success('Subscription revoked');
this.loadSubscriptions();
}
});
}
// Create subscription
openCreateModal(): void {
this.newChannelOwner = '';
this.newChannelName = '';
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
createSubscription(): void {
const userId = this.authService.getUserId();
if (!userId || !this.newChannelOwner.trim() || !this.newChannelName.trim()) return;
this.creating.set(true);
this.apiService.createSubscription(userId, {
channel_owner_user_id: this.newChannelOwner.trim(),
channel_internal_name: this.newChannelName.trim()
}).subscribe({
next: () => {
this.notification.success('Subscription request sent');
this.closeCreateModal();
this.creating.set(false);
this.loadSubscriptions();
},
error: () => {
this.creating.set(false);
}
});
}
getStatusInfo(sub: Subscription): { label: string; color: string } {
if (sub.confirmed) {
return { label: 'Confirmed', color: 'green' };
}
return { label: 'Pending', color: 'orange' };
}
getTypeLabel(sub: Subscription): { label: string; color: string } {
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' };
}
getTabDescription(): string | null {
switch (this.activeTab) {
case 'own':
return 'Active subscriptions to your channels.';
case 'deactivated':
return 'Deactivated subscriptions to your channels. These can be reactivated by you.';
case 'external':
return 'Your subscriptions to channels owned by other users.';
case 'incoming':
return 'Subscription from other users to your channels.';
default:
return null;
}
}
}

View File

@@ -0,0 +1,70 @@
<nz-layout class="app-layout">
<nz-sider
class="menu-sidebar"
nzCollapsible
nzBreakpoint="md"
[nzCollapsed]="isCollapsed()"
(nzCollapsedChange)="isCollapsed.set($event)"
[nzWidth]="240"
[nzCollapsedWidth]="80"
>
<div class="sidebar-logo">
<img src="/logo.png" alt="SCN" class="sidebar-logo-img" />
@if (!isCollapsed()) {
<span>SimpleCloudNotifier</span>
}
</div>
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed()">
<li nz-menu-item nzMatchRouter routerLink="/messages">
<span nz-icon nzType="mail"></span>
<span>Messages</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/channels">
<span nz-icon nzType="send"></span>
<span>Channels</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/subscriptions">
<span nz-icon nzType="link"></span>
<span>Subscriptions</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/keys">
<span nz-icon nzType="key"></span>
<span>Keys</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/clients">
<span nz-icon nzType="desktop"></span>
<span>Clients</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/senders">
<span nz-icon nzType="team"></span>
<span>Senders</span>
</li>
<li nz-menu-item nzMatchRouter routerLink="/account">
<span nz-icon nzType="user"></span>
<span>Account</span>
</li>
</ul>
</nz-sider>
<nz-layout>
<nz-header class="app-header">
<div class="header-left">
<span
class="header-trigger"
(click)="toggleCollapsed()"
>
<span nz-icon [nzType]="isCollapsed() ? 'menu-unfold' : 'menu-fold'"></span>
</span>
</div>
<div class="header-right">
<span class="user-id mono">{{ userId }}</span>
<button nz-button nzType="text" nzDanger (click)="logout()">
<span nz-icon nzType="logout"></span>
Logout
</button>
</div>
</nz-header>
<nz-content class="content-area">
<router-outlet></router-outlet>
</nz-content>
</nz-layout>
</nz-layout>

View File

@@ -0,0 +1,95 @@
.app-layout {
min-height: 100vh;
}
.menu-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
overflow: auto;
overflow-x: hidden;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.sidebar-logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 16px;
background: #001529;
color: #fff;
font-size: 16px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
.sidebar-logo-img {
height: 32px;
width: auto;
flex-shrink: 0;
}
}
.app-header {
background: #fff;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 99;
}
.header-left {
display: flex;
align-items: center;
}
.header-trigger {
font-size: 18px;
cursor: pointer;
padding: 0 16px;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.user-id {
color: #666;
font-size: 13px;
}
}
.content-area {
margin-left: 240px;
transition: margin-left 0.2s;
min-height: calc(100vh - 64px);
background: #f0f2f5;
}
:host-context(.ant-layout-sider-collapsed) + nz-layout .content-area,
nz-layout:has(.ant-layout-sider-collapsed) .content-area {
margin-left: 80px;
}
// Handle collapsed state with sibling selector
:host {
display: block;
.ant-layout-sider-collapsed ~ nz-layout .content-area {
margin-left: 80px;
}
}

View File

@@ -0,0 +1,42 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink, Router } from '@angular/router';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-main-layout',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
RouterLink,
NzLayoutModule,
NzMenuModule,
NzIconModule,
NzButtonModule,
NzDropDownModule,
],
templateUrl: './main-layout.component.html',
styleUrl: './main-layout.component.scss'
})
export class MainLayoutComponent {
private authService = inject(AuthService);
private router = inject(Router);
isCollapsed = signal(false);
userId = this.authService.getUserId();
toggleCollapsed(): void {
this.isCollapsed.update(v => !v);
}
logout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,70 @@
import { Component, Input, OnChanges, SimpleChanges, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import QRCode from 'qrcode';
@Component({
selector: 'app-qr-code-display',
standalone: true,
imports: [CommonModule, NzSpinModule],
template: `
@if (loading()) {
<div class="qr-loading">
<nz-spin nzSimple></nz-spin>
</div>
} @else if (qrDataUrl()) {
<div class="qr-code-container">
<img [src]="qrDataUrl()" alt="QR Code" />
</div>
}
`,
styles: [`
.qr-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 48px;
}
.qr-code-container {
display: flex;
justify-content: center;
padding: 16px;
img {
max-width: 256px;
width: 100%;
height: auto;
}
}
`]
})
export class QrCodeDisplayComponent implements OnChanges {
@Input() data = '';
@Input() size = 256;
loading = signal(false);
qrDataUrl = signal<string | null>(null);
async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (changes['data'] && this.data) {
await this.generateQrCode();
}
}
private async generateQrCode(): Promise<void> {
this.loading.set(true);
try {
const url = await QRCode.toDataURL(this.data, {
width: this.size,
margin: 2,
errorCorrectionLevel: 'M'
});
this.qrDataUrl.set(url);
} catch (error) {
console.error('Failed to generate QR code:', error);
this.qrDataUrl.set(null);
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,28 @@
import { Directive, HostListener, Input, inject } from '@angular/core';
import { NotificationService } from '../../core/services/notification.service';
@Directive({
selector: '[appCopyToClipboard]',
standalone: true
})
export class CopyToClipboardDirective {
@Input('appCopyToClipboard') textToCopy = '';
private notification = inject(NotificationService);
@HostListener('click', ['$event'])
async onClick(event: Event): Promise<void> {
event.stopPropagation();
if (!this.textToCopy) {
return;
}
try {
await navigator.clipboard.writeText(this.textToCopy);
this.notification.success('Copied to clipboard');
} catch {
this.notification.error('Failed to copy to clipboard');
}
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import { formatDistanceToNow, parseISO } from 'date-fns';
@Pipe({
name: 'relativeTime',
standalone: true
})
export class RelativeTimePipe implements PipeTransform {
transform(value: string | Date | null | undefined): string {
if (!value) {
return '-';
}
try {
const date = typeof value === 'string' ? parseISO(value) : value;
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return '-';
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
webapp/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: '/api/v2'
};

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'https://simplecloudnotifier.blackforestbytes.com/api/v2'
};

13
webapp/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SimpleCloudNotifier</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
webapp/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

152
webapp/src/styles.scss Normal file
View File

@@ -0,0 +1,152 @@
@import "ng-zorro-antd/ng-zorro-antd.min.css";
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
// Global utilities
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.mb-0 { margin-bottom: 0; }
.mb-8 { margin-bottom: 8px; }
.mb-16 { margin-bottom: 16px; }
.mb-24 { margin-bottom: 24px; }
.mt-0 { margin-top: 0; }
.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.mt-24 { margin-top: 24px; }
.mr-8 { margin-right: 8px; }
.mr-16 { margin-right: 16px; }
.ml-8 { margin-left: 8px; }
.ml-16 { margin-left: 16px; }
.p-16 { padding: 16px; }
.p-24 { padding: 24px; }
.w-100 { width: 100%; }
// Monospace for IDs and keys
.mono {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 13px;
}
// Card styling
.content-card {
margin-bottom: 16px;
}
// Page content wrapper
.page-content {
padding: 24px;
background: #fff;
min-height: calc(100vh - 64px);
}
// Filter bar
.filter-bar {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
align-items: center;
}
// Clickable row
.clickable-row {
cursor: pointer;
&:hover {
background-color: #fafafa;
}
}
// Status colors
.status-confirmed {
color: #52c41a;
}
.status-pending {
color: #faad14;
}
.status-inactive {
color: #999;
}
// Priority colors
.priority-high {
color: #f5222d;
}
.priority-normal {
color: #1890ff;
}
.priority-low {
color: #999;
}
// Empty state wrapper
.empty-wrapper {
padding: 48px 0;
}
// Action buttons group
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
// Detail page header
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
// QR code display
.qr-code-container {
display: flex;
justify-content: center;
padding: 24px;
img {
max-width: 256px;
width: 100%;
height: auto;
}
}
nz-card {
border: 1px solid #CCC !important;
box-shadow: 0 0 6px #CCC;
> .ant-card-head {
border-bottom: 1px solid #CCC;
}
display: flex;
flex-direction: column;
> .ant-card-body {
flex-grow: 1;
}
}

15
webapp/tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

27
webapp/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

15
webapp/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}