Simple Managment webapp [LLM]
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
#
|
||||
|
||||
name: Build Docker and Deploy
|
||||
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
|
||||
run-name: "[cicd-server]: ${{ github.event.head_commit.message }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
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 .
|
||||
|
||||
127
webapp/angular.json
Normal file
127
webapp/angular.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"$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": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
BIN
webapp/public/favicon.ico
Normal file
BIN
webapp/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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');
|
||||
}
|
||||
35
webapp/src/app/core/models/message.model.ts
Normal file
35
webapp/src/app/core/models/message.model.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
9
webapp/src/app/core/models/sender-name.model.ts
Normal file
9
webapp/src/app/core/models/sender-name.model.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface SenderNameStatistics {
|
||||
name: string;
|
||||
last_timestamp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SenderNameListResponse {
|
||||
senders: SenderNameStatistics[];
|
||||
}
|
||||
35
webapp/src/app/core/models/subscription.model.ts
Normal file
35
webapp/src/app/core/models/subscription.model.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
192
webapp/src/app/core/services/api.service.ts
Normal file
192
webapp/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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,
|
||||
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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
3
webapp/src/app/core/services/index.ts
Normal file
3
webapp/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth.service';
|
||||
export * from './api.service';
|
||||
export * from './notification.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<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>
|
||||
|
||||
<nz-card nzTitle="Actions" class="mt-16">
|
||||
<div class="action-section">
|
||||
<button nz-button nzType="default" (click)="logout()">
|
||||
<span nz-icon nzType="logout"></span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nz-divider nzText="Danger Zone" nzOrientation="left"></nz-divider>
|
||||
|
||||
<div class="danger-section">
|
||||
<p>Deleting your account will permanently remove all your data including messages, channels, subscriptions, and keys.</p>
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you absolutely sure? This action cannot be undone."
|
||||
nzPopconfirmPlacement="top"
|
||||
(nzOnConfirm)="deleteAccount()"
|
||||
[nzLoading]="deleting()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</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,148 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { 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 { NzProgressModule } from 'ng-zorro-antd/progress';
|
||||
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 { 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,
|
||||
NzPopconfirmModule,
|
||||
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);
|
||||
private router = inject(Router);
|
||||
|
||||
user = signal<UserWithExtra | null>(null);
|
||||
loading = signal(true);
|
||||
deleting = signal(false);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Logout
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
// Delete account
|
||||
deleteAccount(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
this.apiService.deleteUser(userId).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Account deleted');
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: () => {
|
||||
this.deleting.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">
|
||||
<h1>SimpleCloudNotifier</h1>
|
||||
<p>Sign in to manage your notifications</p>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<nz-alert
|
||||
nzType="error"
|
||||
[nzMessage]="error()!"
|
||||
nzShowIcon
|
||||
class="mb-16"
|
||||
></nz-alert>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="login()">
|
||||
<nz-form-item>
|
||||
<nz-form-label>User ID</nz-form-label>
|
||||
<nz-form-control>
|
||||
<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>Admin Key</nz-form-label>
|
||||
<nz-form-control>
|
||||
<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 (with "A" permission) to access the dashboard.</p>
|
||||
</div>
|
||||
</nz-card>
|
||||
</div>
|
||||
58
webapp/src/app/features/auth/login/login.component.scss
Normal file
58
webapp/src/app/features/auth/login/login.component.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
.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;
|
||||
|
||||
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,250 @@
|
||||
<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>
|
||||
<span
|
||||
nz-icon
|
||||
nzType="qrcode"
|
||||
class="action-icon"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Show QR Code"
|
||||
(click)="showQrCode()"
|
||||
></span>
|
||||
</ng-template>
|
||||
<div class="key-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Regenerate subscribe key? Existing subscribers will need the new key."
|
||||
(nzOnConfirm)="regenerateSubscribeKey()"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</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"
|
||||
nzSize="small"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showQrModal"
|
||||
nzTitle="Subscribe QR Code"
|
||||
(nzOnCancel)="closeQrModal()"
|
||||
[nzFooter]="null"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<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>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
@@ -0,0 +1,52 @@
|
||||
.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-hint {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Component, inject, signal, 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 modal
|
||||
showQrModal = signal(false);
|
||||
qrCodeData = signal('');
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// QR Code
|
||||
showQrCode(): void {
|
||||
const channel = this.channel();
|
||||
if (!channel || !channel.subscribe_key) return;
|
||||
|
||||
const qrText = [
|
||||
'@scn.channel.subscribe',
|
||||
'v1',
|
||||
channel.display_name,
|
||||
channel.owner_user_id,
|
||||
channel.channel_id,
|
||||
channel.subscribe_key
|
||||
].join('\n');
|
||||
|
||||
this.qrCodeData.set(qrText);
|
||||
this.showQrModal.set(true);
|
||||
}
|
||||
|
||||
closeQrModal(): void {
|
||||
this.showQrModal.set(false);
|
||||
}
|
||||
|
||||
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,110 @@
|
||||
<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>
|
||||
<button nz-button nzType="primary" (click)="openCreateModal()">
|
||||
<span nz-icon nzType="plus"></span>
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nz-card class="filter-card">
|
||||
<nz-radio-group [(ngModel)]="selector" (ngModelChange)="onSelectorChange()">
|
||||
<label nz-radio-button nzValue="all">All</label>
|
||||
<label nz-radio-button nzValue="owned">Owned</label>
|
||||
<label nz-radio-button nzValue="subscribed">Subscribed</label>
|
||||
<label nz-radio-button nzValue="subscribed_any">Subscribed (Any)</label>
|
||||
</nz-radio-group>
|
||||
</nz-card>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#channelTable
|
||||
[nzData]="channels()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
nzSize="middle"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="25%">Name</th>
|
||||
<th nzWidth="20%">Internal Name</th>
|
||||
<th nzWidth="15%">Status</th>
|
||||
<th nzWidth="15%">Messages</th>
|
||||
<th nzWidth="25%">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>
|
||||
<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="5">
|
||||
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
</div>
|
||||
|
||||
<!-- Create Channel Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showCreateModal"
|
||||
nzTitle="Create Channel"
|
||||
(nzOnCancel)="closeCreateModal()"
|
||||
(nzOnOk)="createChannel()"
|
||||
[nzOkLoading]="creating()"
|
||||
[nzOkDisabled]="!newChannelName.trim()"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<nz-form-item>
|
||||
<nz-form-label>Channel Name</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Enter channel name"
|
||||
[(ngModel)]="newChannelName"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item class="mb-0">
|
||||
<label nz-checkbox [(ngModel)]="newChannelSubscribe">
|
||||
Subscribe to this channel
|
||||
</label>
|
||||
</nz-form-item>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
@@ -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,143 @@
|
||||
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 } 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 { NzRadioModule } from 'ng-zorro-antd/radio';
|
||||
import { NzModalModule, NzModalService } 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 { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ChannelWithSubscription, ChannelSelector } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzBadgeModule,
|
||||
NzEmptyModule,
|
||||
NzCardModule,
|
||||
NzRadioModule,
|
||||
NzModalModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzCheckboxModule,
|
||||
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 notification = inject(NotificationService);
|
||||
private modal = inject(NzModalService);
|
||||
private router = inject(Router);
|
||||
|
||||
channels = signal<ChannelWithSubscription[]>([]);
|
||||
loading = signal(false);
|
||||
selector: ChannelSelector = 'all';
|
||||
|
||||
// Create channel modal
|
||||
showCreateModal = signal(false);
|
||||
newChannelName = '';
|
||||
newChannelSubscribe = true;
|
||||
creating = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadChannels();
|
||||
}
|
||||
|
||||
loadChannels(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.apiService.getChannels(userId, this.selector).subscribe({
|
||||
next: (response) => {
|
||||
this.channels.set(response.channels);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSelectorChange(): void {
|
||||
this.loadChannels();
|
||||
}
|
||||
|
||||
viewChannel(channel: ChannelWithSubscription): void {
|
||||
this.router.navigate(['/channels', channel.channel_id]);
|
||||
}
|
||||
|
||||
openCreateModal(): void {
|
||||
this.newChannelName = '';
|
||||
this.newChannelSubscribe = true;
|
||||
this.showCreateModal.set(true);
|
||||
}
|
||||
|
||||
closeCreateModal(): void {
|
||||
this.showCreateModal.set(false);
|
||||
}
|
||||
|
||||
createChannel(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId || !this.newChannelName.trim()) return;
|
||||
|
||||
this.creating.set(true);
|
||||
this.apiService.createChannel(userId, {
|
||||
name: this.newChannelName.trim(),
|
||||
subscribe: this.newChannelSubscribe
|
||||
}).subscribe({
|
||||
next: (channel) => {
|
||||
this.notification.success('Channel created successfully');
|
||||
this.closeCreateModal();
|
||||
this.creating.set(false);
|
||||
this.loadChannels();
|
||||
},
|
||||
error: () => {
|
||||
this.creating.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,68 @@
|
||||
<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"
|
||||
nzSize="middle"
|
||||
>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
webapp/src/app/features/keys/key-list/key-list.component.html
Normal file
204
webapp/src/app/features/keys/key-list/key-list.component.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<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"
|
||||
nzSize="middle"
|
||||
>
|
||||
<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)"
|
||||
(nzCheckedChange)="onPermissionChange(opt.value, $event)"
|
||||
>
|
||||
<nz-tag [nzColor]="getPermissionColor(opt.value)">{{ opt.value }}</nz-tag>
|
||||
{{ opt.label }}
|
||||
<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,70 @@
|
||||
.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;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.perm-desc {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
198
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal file
198
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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 (!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,118 @@
|
||||
<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>
|
||||
|
||||
<nz-card class="filter-card">
|
||||
<div class="filter-bar">
|
||||
<nz-input-group nzSearch [nzAddOnAfter]="searchButton" style="width: 300px;">
|
||||
<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>
|
||||
|
||||
<nz-select
|
||||
[(ngModel)]="priorityFilter"
|
||||
nzPlaceHolder="Priority"
|
||||
nzAllowClear
|
||||
style="width: 150px;"
|
||||
(ngModelChange)="applyFilters()"
|
||||
>
|
||||
<nz-option [nzValue]="0" nzLabel="Low"></nz-option>
|
||||
<nz-option [nzValue]="1" nzLabel="Normal"></nz-option>
|
||||
<nz-option [nzValue]="2" nzLabel="High"></nz-option>
|
||||
</nz-select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Channel name"
|
||||
[(ngModel)]="channelFilter"
|
||||
style="width: 200px;"
|
||||
(keyup.enter)="applyFilters()"
|
||||
/>
|
||||
|
||||
@if (searchText || priorityFilter !== null || channelFilter) {
|
||||
<button nz-button (click)="clearFilters()">
|
||||
<span nz-icon nzType="close"></span>
|
||||
Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nz-card>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#messageTable
|
||||
[nzData]="messages()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
nzSize="middle"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="40%">Title</th>
|
||||
<th nzWidth="15%">Channel</th>
|
||||
<th nzWidth="15%">Sender</th>
|
||||
<th nzWidth="10%">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>
|
||||
|
||||
@if (nextPageToken()) {
|
||||
<div class="load-more">
|
||||
<button nz-button nzType="default" (click)="loadMore()" [nzLoading]="loading()">
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</nz-card>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzSelectModule } from 'ng-zorro-antd/select';
|
||||
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 { 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 { 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,
|
||||
NzSelectModule,
|
||||
NzTagModule,
|
||||
NzIconModule,
|
||||
NzEmptyModule,
|
||||
NzSpinModule,
|
||||
NzCardModule,
|
||||
NzToolTipModule,
|
||||
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);
|
||||
nextPageToken = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
searchText = '';
|
||||
priorityFilter: number | null = null;
|
||||
channelFilter = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
loadMessages(append = false): void {
|
||||
this.loading.set(true);
|
||||
|
||||
const params: MessageListParams = {
|
||||
page_size: 50,
|
||||
trimmed: true,
|
||||
};
|
||||
|
||||
if (this.searchText) {
|
||||
params.search = this.searchText;
|
||||
}
|
||||
if (this.priorityFilter !== null) {
|
||||
params.priority = this.priorityFilter;
|
||||
}
|
||||
if (this.channelFilter) {
|
||||
params.channel = this.channelFilter;
|
||||
}
|
||||
if (append && this.nextPageToken()) {
|
||||
params.next_page_token = this.nextPageToken()!;
|
||||
}
|
||||
|
||||
this.apiService.getMessages(params).subscribe({
|
||||
next: (response) => {
|
||||
if (append) {
|
||||
this.messages.update(msgs => [...msgs, ...response.messages]);
|
||||
} else {
|
||||
this.messages.set(response.messages);
|
||||
}
|
||||
this.nextPageToken.set(
|
||||
response.next_page_token && response.next_page_token !== '@end'
|
||||
? response.next_page_token
|
||||
: null
|
||||
);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchText = '';
|
||||
this.priorityFilter = null;
|
||||
this.channelFilter = '';
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.nextPageToken()) {
|
||||
this.loadMessages(true);
|
||||
}
|
||||
}
|
||||
|
||||
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,48 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Senders</h2>
|
||||
<button nz-button (click)="loadSenders()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#senderTable
|
||||
[nzData]="senders()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
nzSize="middle"
|
||||
>
|
||||
<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 senders(); 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-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,51 @@
|
||||
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 { ApiService } from '../../../core/services/api.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,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './sender-list.component.html',
|
||||
styleUrl: './sender-list.component.scss'
|
||||
})
|
||||
export class SenderListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
senders = signal<SenderNameStatistics[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSenders();
|
||||
}
|
||||
|
||||
loadSenders(): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getSenderNames().subscribe({
|
||||
next: (response) => {
|
||||
this.senders.set(response.senders);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<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-card>
|
||||
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
|
||||
<nz-tab nzTitle="All"></nz-tab>
|
||||
<nz-tab nzTitle="Outgoing"></nz-tab>
|
||||
<nz-tab nzTitle="Incoming"></nz-tab>
|
||||
</nz-tabset>
|
||||
|
||||
<nz-table
|
||||
#subscriptionTable
|
||||
[nzData]="subscriptions()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
nzSize="middle"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="15%">Direction</th>
|
||||
<th nzWidth="25%">Channel</th>
|
||||
<th nzWidth="20%">Subscriber / Owner</th>
|
||||
<th nzWidth="15%">Status</th>
|
||||
<th nzWidth="15%">Created</th>
|
||||
<th nzWidth="10%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<nz-tag [nzColor]="isOutgoing(sub) ? 'blue' : 'purple'">
|
||||
{{ getDirectionLabel(sub) }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ sub.channel_internal_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (isOutgoing(sub)) {
|
||||
<span class="label">Owner:</span>
|
||||
<span class="mono">{{ sub.channel_owner_user_id }}</span>
|
||||
} @else {
|
||||
<span class="label">Subscriber:</span>
|
||||
<span class="mono">{{ sub.subscriber_user_id }}</span>
|
||||
}
|
||||
</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="6">
|
||||
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
</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,32 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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 { 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 { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { Subscription, SubscriptionFilter } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
type TabDirection = 'both' | 'outgoing' | 'incoming';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subscription-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzEmptyModule,
|
||||
NzCardModule,
|
||||
NzTabsModule,
|
||||
NzPopconfirmModule,
|
||||
NzModalModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzToolTipModule,
|
||||
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);
|
||||
|
||||
subscriptions = signal<Subscription[]>([]);
|
||||
loading = signal(false);
|
||||
direction: TabDirection = 'both';
|
||||
|
||||
// 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 = {};
|
||||
if (this.direction !== 'both') {
|
||||
filter.direction = this.direction;
|
||||
}
|
||||
|
||||
this.apiService.getSubscriptions(userId, filter).subscribe({
|
||||
next: (response) => {
|
||||
this.subscriptions.set(response.subscriptions);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onTabChange(index: number): void {
|
||||
const directions: TabDirection[] = ['both', 'outgoing', 'incoming'];
|
||||
this.direction = directions[index];
|
||||
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' };
|
||||
}
|
||||
|
||||
getDirectionLabel(sub: Subscription): string {
|
||||
if (this.isOutgoing(sub)) {
|
||||
return 'Outgoing';
|
||||
}
|
||||
return 'Incoming';
|
||||
}
|
||||
}
|
||||
71
webapp/src/app/layout/main-layout/main-layout.component.html
Normal file
71
webapp/src/app/layout/main-layout/main-layout.component.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<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">
|
||||
@if (!isCollapsed()) {
|
||||
<span>SimpleCloudNotifier</span>
|
||||
} @else {
|
||||
<span>SCN</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>
|
||||
88
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal file
88
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
.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;
|
||||
padding: 0 16px;
|
||||
background: #001529;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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 '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
136
webapp/src/styles.scss
Normal file
136
webapp/src/styles.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
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