Merge branch 'webapp'
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
name: Build Docker and Deploy
|
name: Build Docker and Deploy
|
||||||
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
|
run-name: "[cicd-server]: ${{ github.event.head_commit.message }}"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
57
.gitea/workflows/cicd-webapp.yml
Normal file
57
.gitea/workflows/cicd-webapp.yml
Normal 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
11
webapp/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.angular
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
17
webapp/.editorconfig
Normal file
17
webapp/.editorconfig
Normal 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
45
webapp/.gitignore
vendored
Normal 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
1
webapp/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
20
webapp/Dockerfile
Normal file
20
webapp/Dockerfile
Normal 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
40
webapp/Makefile
Normal 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
100
webapp/angular.json
Normal 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
15
webapp/nginx.conf
Normal 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
15304
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
webapp/package.json
Normal file
41
webapp/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
webapp/src/app/app.component.ts
Normal file
12
webapp/src/app/app.component.ts
Normal 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 {}
|
||||||
93
webapp/src/app/app.config.ts
Normal file
93
webapp/src/app/app.config.ts
Normal 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),
|
||||||
|
]
|
||||||
|
};
|
||||||
55
webapp/src/app/app.routes.ts
Normal file
55
webapp/src/app/app.routes.ts
Normal 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' }
|
||||||
|
];
|
||||||
15
webapp/src/app/core/guards/auth.guard.ts
Normal file
15
webapp/src/app/core/guards/auth.guard.ts
Normal 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;
|
||||||
|
};
|
||||||
19
webapp/src/app/core/interceptors/auth.interceptor.ts
Normal file
19
webapp/src/app/core/interceptors/auth.interceptor.ts
Normal 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);
|
||||||
|
};
|
||||||
35
webapp/src/app/core/interceptors/error.interceptor.ts
Normal file
35
webapp/src/app/core/interceptors/error.interceptor.ts
Normal 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
15
webapp/src/app/core/models/api-response.model.ts
Normal file
15
webapp/src/app/core/models/api-response.model.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
43
webapp/src/app/core/models/channel.model.ts
Normal file
43
webapp/src/app/core/models/channel.model.ts
Normal 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[];
|
||||||
|
}
|
||||||
33
webapp/src/app/core/models/client.model.ts
Normal file
33
webapp/src/app/core/models/client.model.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
8
webapp/src/app/core/models/index.ts
Normal file
8
webapp/src/app/core/models/index.ts
Normal 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';
|
||||||
51
webapp/src/app/core/models/key-token.model.ts
Normal file
51
webapp/src/app/core/models/key-token.model.ts
Normal 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');
|
||||||
|
}
|
||||||
36
webapp/src/app/core/models/message.model.ts
Normal file
36
webapp/src/app/core/models/message.model.ts
Normal 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;
|
||||||
|
}
|
||||||
10
webapp/src/app/core/models/sender-name.model.ts
Normal file
10
webapp/src/app/core/models/sender-name.model.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface SenderNameStatistics {
|
||||||
|
name: string;
|
||||||
|
first_timestamp: string;
|
||||||
|
last_timestamp: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SenderNameListResponse {
|
||||||
|
sender_names: SenderNameStatistics[];
|
||||||
|
}
|
||||||
36
webapp/src/app/core/models/subscription.model.ts
Normal file
36
webapp/src/app/core/models/subscription.model.ts
Normal 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;
|
||||||
|
}
|
||||||
32
webapp/src/app/core/models/user.model.ts
Normal file
32
webapp/src/app/core/models/user.model.ts
Normal 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;
|
||||||
|
}
|
||||||
197
webapp/src/app/core/services/api.service.ts
Normal file
197
webapp/src/app/core/services/api.service.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
webapp/src/app/core/services/auth.service.ts
Normal file
54
webapp/src/app/core/services/auth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
webapp/src/app/core/services/index.ts
Normal file
4
webapp/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './auth.service';
|
||||||
|
export * from './api.service';
|
||||||
|
export * from './notification.service';
|
||||||
|
export * from './user-cache.service';
|
||||||
33
webapp/src/app/core/services/notification.service.ts
Normal file
33
webapp/src/app/core/services/notification.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
webapp/src/app/core/services/user-cache.service.ts
Normal file
55
webapp/src/app/core/services/user-cache.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
75
webapp/src/app/features/auth/login/login.component.html
Normal file
75
webapp/src/app/features/auth/login/login.component.html
Normal 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>
|
||||||
64
webapp/src/app/features/auth/login/login.component.scss
Normal file
64
webapp/src/app/features/auth/login/login.component.scss
Normal 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;
|
||||||
|
}
|
||||||
87
webapp/src/app/features/auth/login/login.component.ts
Normal file
87
webapp/src/app/features/auth/login/login.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
webapp/src/app/features/keys/key-list/key-list.component.html
Normal file
207
webapp/src/app/features/keys/key-list/key-list.component.html
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal file
201
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
webapp/src/app/layout/main-layout/main-layout.component.html
Normal file
70
webapp/src/app/layout/main-layout/main-layout.component.html
Normal 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>
|
||||||
95
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal file
95
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
webapp/src/app/layout/main-layout/main-layout.component.ts
Normal file
42
webapp/src/app/layout/main-layout/main-layout.component.ts
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
webapp/src/app/shared/pipes/relative-time.pipe.ts
Normal file
21
webapp/src/app/shared/pipes/relative-time.pipe.ts
Normal 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 '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
webapp/src/assets/favicon.ico
Normal file
BIN
webapp/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
webapp/src/assets/logo.png
Normal file
BIN
webapp/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
4
webapp/src/environments/environment.prod.ts
Normal file
4
webapp/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: '/api/v2'
|
||||||
|
};
|
||||||
4
webapp/src/environments/environment.ts
Normal file
4
webapp/src/environments/environment.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'https://simplecloudnotifier.blackforestbytes.com/api/v2'
|
||||||
|
};
|
||||||
13
webapp/src/index.html
Normal file
13
webapp/src/index.html
Normal 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
6
webapp/src/main.ts
Normal 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
152
webapp/src/styles.scss
Normal 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
15
webapp/tsconfig.app.json
Normal 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
27
webapp/tsconfig.json
Normal 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
15
webapp/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user