Compare commits
24 Commits
6306555a30
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
3c45191d11
|
|||
|
9db56f6db6
|
|||
|
5e6060e537
|
|||
|
4b8ebf15d2
|
|||
|
d26f18f356
|
|||
|
6090319b5f
|
|||
|
3ed323e056
|
|||
|
f41ef30121
|
|||
|
01df2b49f6
|
|||
|
8306992533
|
|||
|
d983737239
|
|||
|
7c88281f03
|
|||
|
308d6bbba0
|
|||
|
85e6e4adfb
|
|||
|
c860ef9c30
|
|||
|
e7f613b5dc
|
|||
| d932410802 | |||
| 26d2854617 | |||
|
b521f74951
|
|||
|
acc23c0d10
|
|||
|
693d2ad79e
|
|||
|
f19e8950e8
|
|||
|
64d0541dc6
|
|||
|
dfb4d9d9e5
|
@@ -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:
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
- run: cd "${{ gitea.workspace }}/scnserver" && make clean
|
||||
- run: cd "${{ gitea.workspace }}/scnserver" && make dgi
|
||||
- run: cd "${{ gitea.workspace }}/scnserver" && make docker
|
||||
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
|
||||
|
||||
@@ -60,21 +61,7 @@ jobs:
|
||||
run: go version
|
||||
|
||||
- name: Run tests
|
||||
run: cd "${{ gitea.workspace }}/scnserver" && make dgi && make swagger && SCN_TEST_LOGLEVEL=WARN make test
|
||||
|
||||
- name: Send failure mail
|
||||
if: failure()
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.fastmail.com
|
||||
server_port: 465
|
||||
secure: true
|
||||
username: ${{secrets.MAIL_USERNAME}}
|
||||
password: ${{secrets.MAIL_PASSWORD}}
|
||||
subject: Pipeline on '${{ gitea.repository }}' failed
|
||||
to: ${{ steps.commiter_info.outputs.MAIL }}
|
||||
from: Gitea Actions <gitea_actions@blackforestbytes.de>
|
||||
body: "Go to https://gogs.blackforestbytes.com/${{ gitea.repository }}/actions"
|
||||
run: cd "${{ gitea.workspace }}/scnserver" && make generate && SCN_TEST_LOGLEVEL=WARN make test
|
||||
|
||||
deploy_server:
|
||||
name: Deploy to Server
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,25 +30,7 @@ install-release: java gen
|
||||
flutter run --release -d 35221JEHN07157
|
||||
|
||||
release: java gen
|
||||
flutter build apk --release
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "_releases/v$(VERS).apk"
|
||||
@echo ""
|
||||
@echo "--> copied APK to _releases ( Version: $(VERS) )"
|
||||
@echo ""
|
||||
flutter build appbundle --release
|
||||
cp build/app/outputs/bundle/release/app-release.aab "_releases/v$(VERS).aab"
|
||||
cd "build/app/intermediates/merged_native_libs/release/out/lib" && zip -r "../../../../../../../_releases/v$(VERS).symbols.zip" .
|
||||
@echo ""
|
||||
@echo "--> copied AAB to _releases ( Version: $(VERS) )"
|
||||
@echo ""
|
||||
flutter build linux --release
|
||||
tar -czf "_releases/v$(VERS).tar.gz" -C build/linux/x64/release/bundle .
|
||||
@echo ""
|
||||
@echo "--> copied linux-binary to _releases ( Version: $(VERS) )"
|
||||
@echo ""
|
||||
@echo "#=> file://$(shell pwd)/_releases"
|
||||
@echo ""
|
||||
@echo "Done."
|
||||
@_utils/release.sh
|
||||
|
||||
test:
|
||||
dart analyze
|
||||
@@ -63,7 +45,6 @@ gen: java
|
||||
|
||||
# run `make run` in another terminal (or another variant of flutter run)
|
||||
autoreload:
|
||||
@
|
||||
@_utils/autoreload.sh
|
||||
|
||||
icons:
|
||||
|
||||
51
flutter/_utils/release.sh
Executable file
51
flutter/_utils/release.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -d ".git" ]]; then
|
||||
|
||||
echo "Must be called in project root"
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
VERS="$(cat pubspec.yaml | grep -oP '(?<=version: ).*' | sed 's/[\s]*//' | tr -d '\n' | tr -d '')"
|
||||
|
||||
VERS_BY_SPEC="$( echo -n "$VERS" | awk -F'+' '{print "v"$1}' )"
|
||||
VERS_BY_TAG="$(git describe --abbrev=0 --tags)"
|
||||
|
||||
if [[ "$VERS_BY_TAG" != "$VERS_BY_SPEC" ]]; then
|
||||
echo "Version in pubspec.yaml ($VERS_BY_SPEC) does not match latest git tag ($VERS_BY_TAG)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "(!) Make sure you've updated version-number in pubspec.yaml (current = ${VERS}) and created a tag (current = ${VERS_BY_TAG}) !"
|
||||
echo '> Press Enter to confirm...' && read -r
|
||||
echo ""
|
||||
|
||||
flutter build apk --release
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "_releases/v${VERS}.apk"
|
||||
|
||||
echo ""
|
||||
echo "--> copied APK to _releases ( Version: ${VERS} )"
|
||||
echo ""
|
||||
|
||||
flutter build appbundle --release
|
||||
cp build/app/outputs/bundle/release/app-release.aab "_releases/v${VERS}.aab"
|
||||
|
||||
pushd "build/app/intermediates/merged_native_libs/release/out/lib" || exit 1
|
||||
zip -r "../../../../../../../_releases/v${VERS}.symbols.zip" .
|
||||
popd || exit 1
|
||||
|
||||
echo ""
|
||||
echo "--> copied AAB to _releases ( Version: ${VERS} )"
|
||||
echo ""
|
||||
|
||||
flutter build linux --release
|
||||
tar -czf "_releases/v${VERS}.tar.gz" -C build/linux/x64/release/bundle .
|
||||
|
||||
echo ""
|
||||
echo "--> copied linux-binary to _releases ( Version: ${VERS} )"
|
||||
echo ""
|
||||
echo "#=> file://$(pwd)/_releases"
|
||||
echo ""
|
||||
echo "Done."
|
||||
@@ -3,6 +3,10 @@ include:
|
||||
- package:lints/recommended.yaml
|
||||
- package:flutter_lints/flutter.yaml
|
||||
|
||||
formatter:
|
||||
page_width: 512
|
||||
trailing_commas: preserve
|
||||
|
||||
linter:
|
||||
|
||||
|
||||
|
||||
@@ -166,20 +166,34 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
title: 'Sender',
|
||||
values: [message.senderName!],
|
||||
mainAction: () => {
|
||||
Navi.push(context, () => FilteredMessageViewPage(title: message.senderName!, alertText: 'All message sent from \'${message.senderName!}\'', filter: MessageFilter(senderNames: [message.senderName!])))
|
||||
Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: message.senderName!,
|
||||
alertText: 'All message sent from \'${message.senderName!}\'',
|
||||
filter: MessageFilter(senderNames: [message.senderName!]),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (cfg.showExtendedAttributes)
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
icon: FontAwesomeIcons.solidGearCode,
|
||||
title: 'KeyToken',
|
||||
title: 'Used Key',
|
||||
values: [message.usedKeyID, token?.name ?? '...'],
|
||||
mainAction: () {
|
||||
if (message.senderUserID == userAccUserID) {
|
||||
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
||||
} else {
|
||||
Navi.push(context, () => FilteredMessageViewPage(title: token?.name ?? message.usedKeyID, alertText: 'All message sent with the specified key', filter: MessageFilter(usedKeys: [message.usedKeyID])));
|
||||
Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: token?.name ?? message.usedKeyID,
|
||||
alertText: 'All message sent with the specified key',
|
||||
filter: MessageFilter(usedKeys: [message.usedKeyID]),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -187,13 +201,20 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
icon: FontAwesomeIcons.solidGearCode,
|
||||
title: 'KeyToken',
|
||||
title: 'Used Key',
|
||||
values: [token.name],
|
||||
mainAction: () {
|
||||
if (message.senderUserID == userAccUserID) {
|
||||
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
||||
} else {
|
||||
Navi.push(context, () => FilteredMessageViewPage(title: token.name, alertText: 'All message sent with key \'${token.name}\'', filter: MessageFilter(usedKeys: [message.usedKeyID])));
|
||||
Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: token.name,
|
||||
alertText: 'All message sent with key \'${token.name}\'',
|
||||
filter: MessageFilter(usedKeys: [message.usedKeyID]),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -208,19 +229,21 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
}
|
||||
: null,
|
||||
),
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
icon: FontAwesomeIcons.solidTimer,
|
||||
title: 'Timestamp',
|
||||
values: [message.timestamp],
|
||||
),
|
||||
UI.metaCard(context: context, icon: FontAwesomeIcons.solidTimer, title: 'Timestamp', values: [message.timestamp]),
|
||||
if (cfg.showExtendedAttributes)
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
icon: FontAwesomeIcons.solidUser,
|
||||
title: 'User',
|
||||
values: [user?.userID ?? message.senderUserID, if (user?.username != null) user?.username ?? ''],
|
||||
mainAction: () => Navi.push(context, () => FilteredMessageViewPage(title: user?.username ?? message.senderUserID, alertText: 'All message sent by the specified account', filter: MessageFilter(senderUserID: [message.senderUserID]))),
|
||||
mainAction: () => Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: user?.username ?? message.senderUserID,
|
||||
alertText: 'All message sent by the specified account',
|
||||
filter: MessageFilter(senderUserID: [message.senderUserID]),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!cfg.showExtendedAttributes)
|
||||
UI.metaCard(
|
||||
@@ -228,14 +251,28 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
icon: FontAwesomeIcons.solidUser,
|
||||
title: 'User',
|
||||
values: [user?.username ?? user?.userID ?? message.senderUserID],
|
||||
mainAction: () => Navi.push(context, () => FilteredMessageViewPage(title: user?.username ?? message.senderUserID, alertText: 'All message sent by the specified account', filter: MessageFilter(senderUserID: [message.senderUserID]))),
|
||||
mainAction: () => Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: user?.username ?? message.senderUserID,
|
||||
alertText: 'All message sent by the specified account',
|
||||
filter: MessageFilter(senderUserID: [message.senderUserID]),
|
||||
),
|
||||
),
|
||||
),
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
icon: FontAwesomeIcons.solidBolt,
|
||||
title: 'Priority',
|
||||
values: [_prettyPrintPriority(message.priority)],
|
||||
mainAction: () => Navi.push(context, () => FilteredMessageViewPage(title: "Priority ${message.priority}", alertText: 'All message sent with priority ' + _prettyPrintPriority(message.priority), filter: MessageFilter(priority: [message.priority]))),
|
||||
mainAction: () => Navi.push(
|
||||
context,
|
||||
() => FilteredMessageViewPage(
|
||||
title: "Priority ${message.priority}",
|
||||
alertText: 'All message sent with priority ' + _prettyPrintPriority(message.priority),
|
||||
filter: MessageFilter(priority: [message.priority]),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.senderUserID == userAccUserID)
|
||||
UI.button(
|
||||
@@ -243,7 +280,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
onPressed: () {
|
||||
Toaster.info("Not Implemented", "... will be implemented in a later version"); // TODO
|
||||
},
|
||||
color: Colors.red[900]),
|
||||
color: Colors.red[900],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -261,16 +299,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
thumbVisibility: false,
|
||||
interactive: true,
|
||||
controller: _controller,
|
||||
child: SingleChildScrollView(
|
||||
controller: _controller,
|
||||
child: child,
|
||||
),
|
||||
child: SingleChildScrollView(controller: _controller, child: child),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SingleChildScrollView(
|
||||
child: child,
|
||||
);
|
||||
return SingleChildScrollView(child: child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +317,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
UI.channelChip(
|
||||
context: context,
|
||||
text: _resolveChannelName(channel, message),
|
||||
margin: const EdgeInsets.fromLTRB(0, 0, 4, 0),
|
||||
fontSize: 16,
|
||||
),
|
||||
UI.channelChip(context: context, text: _resolveChannelName(channel, message), margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), fontSize: 16),
|
||||
Expanded(child: SizedBox()),
|
||||
Text(dateFormat.format(DateTime.parse(message.timestamp)), style: const TextStyle(fontSize: 14)),
|
||||
],
|
||||
@@ -337,12 +365,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
),
|
||||
borderColor: (message.priority == 2) ? Colors.red[900] : null,
|
||||
)
|
||||
: UI.box(
|
||||
context: context,
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Text(message.content ?? ''),
|
||||
borderColor: (message.priority == 2) ? Colors.red[900] : null,
|
||||
)
|
||||
: UI.box(context: context, padding: const EdgeInsets.all(4), child: Text(message.content ?? ''), borderColor: (message.priority == 2) ? Colors.red[900] : null),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class AppSettings extends ChangeNotifier {
|
||||
dateFormat = AppSettingsDateFormat.ISO;
|
||||
messagePreviewLength = 3;
|
||||
showInfoAlerts = true;
|
||||
showExtendedAttributes = true;
|
||||
showExtendedAttributes = false;
|
||||
|
||||
notification0 = AppNotificationSettings();
|
||||
notification1 = AppNotificationSettings();
|
||||
@@ -102,7 +102,7 @@ class AppSettings extends ChangeNotifier {
|
||||
dateFormat = AppSettingsDateFormat.parse(Globals().sharedPrefs.getString('settings.dateFormat')) ?? dateFormat;
|
||||
messagePreviewLength = Globals().sharedPrefs.getInt('settings.messagePreviewLength') ?? messagePreviewLength;
|
||||
showInfoAlerts = Globals().sharedPrefs.getBool('settings.showInfoAlerts') ?? showInfoAlerts;
|
||||
showInfoAlerts = Globals().sharedPrefs.getBool('settings.showExtendedAttributes') ?? showExtendedAttributes;
|
||||
showExtendedAttributes = Globals().sharedPrefs.getBool('settings.showExtendedAttributes') ?? showExtendedAttributes;
|
||||
|
||||
notification0 = AppNotificationSettings.load(Globals().sharedPrefs, 'settings.notification0');
|
||||
notification1 = AppNotificationSettings.load(Globals().sharedPrefs, 'settings.notification1');
|
||||
|
||||
@@ -2,7 +2,7 @@ name: simplecloudnotifier
|
||||
description: "Receive push messages"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 2.1.0+502
|
||||
version: 2.1.1+509
|
||||
|
||||
environment:
|
||||
sdk: '>=3.9.0 <4.0.0'
|
||||
|
||||
5
scnserver/.gitignore
vendored
5
scnserver/.gitignore
vendored
@@ -24,6 +24,11 @@ identifier.sqlite
|
||||
|
||||
scn_send.sh
|
||||
|
||||
swagger/swagger.json
|
||||
swagger/swagger.yaml
|
||||
|
||||
**/*_gen.go
|
||||
|
||||
##############
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists
|
||||
|
||||
COPY --from=builder /buildsrc/_build/scn_backend /app/server
|
||||
COPY --from=builder /buildsrc/DOCKER_GIT_INFO /DOCKER_GIT_INFO
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ swagger-setup:
|
||||
swagger: swagger-setup
|
||||
".swaggobin/swag_$(SWAGGO_VERSION)" init -generalInfo ./api/router.go --propertyStrategy camelcase --output ./swagger/ --outputTypes "json,yaml"
|
||||
|
||||
generate: dgi enums ids swagger
|
||||
|
||||
pygmentize: website/scn_send.html
|
||||
|
||||
website/scn_send.html: ../scn_send.sh
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
//
|
||||
// @Param uid path string true "UserID"
|
||||
//
|
||||
// @Success 200 {object} handler.ListUserKeys.response
|
||||
// @Success 200 {object} handler.ListUserSenderNames.response
|
||||
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
||||
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
|
||||
// @Failure 404 {object} ginresp.apiError "message not found"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/ginext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListUserSubscriptions swaggerdoc
|
||||
@@ -40,7 +41,8 @@ import (
|
||||
// @Tags API-v2
|
||||
//
|
||||
// @Param uid path string true "UserID"
|
||||
// @Param selector query string true "Filter subscriptions (default: outgoing_all)" Enums(outgoing_all, outgoing_confirmed, outgoing_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed)
|
||||
//
|
||||
// @Param query_data query handler.ListUserSubscriptions.query false " "
|
||||
//
|
||||
// @Success 200 {object} handler.ListUserSubscriptions.response
|
||||
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"blackforestbytes.com/simplecloudnotifier/website"
|
||||
"errors"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/ginext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/rext"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebsiteHandler struct {
|
||||
@@ -77,6 +78,18 @@ func (h WebsiteHandler) MessageSent(pctx ginext.PreContext) ginext.HTTPResponse
|
||||
})
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) PrivacyPolicy(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||
ctx, g, errResp := pctx.Start()
|
||||
if errResp != nil {
|
||||
return *errResp
|
||||
}
|
||||
defer ctx.Cancel()
|
||||
|
||||
return h.app.DoRequest(ctx, g, models.TLockNone, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
||||
return h.serveAsset(g, "privacy_policy.html", true)
|
||||
})
|
||||
}
|
||||
|
||||
func (h WebsiteHandler) FaviconIco(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||
ctx, g, errResp := pctx.Start()
|
||||
if errResp != nil {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"blackforestbytes.com/simplecloudnotifier/api/handler"
|
||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"blackforestbytes.com/simplecloudnotifier/swagger"
|
||||
"errors"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/ginext"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -98,6 +99,10 @@ func (r *Router) Init(e *ginext.GinWrapper) error {
|
||||
frontend.GET("/message_sent.php").Handle(r.websiteHandler.MessageSent)
|
||||
frontend.GET("/message_sent.html").Handle(r.websiteHandler.MessageSent)
|
||||
|
||||
frontend.GET("/privacy_policy").Handle(r.websiteHandler.PrivacyPolicy)
|
||||
frontend.GET("/privacy_policy.php").Handle(r.websiteHandler.PrivacyPolicy)
|
||||
frontend.GET("/privacy_policy.html").Handle(r.websiteHandler.PrivacyPolicy)
|
||||
|
||||
frontend.GET("/favicon.ico").Handle(r.websiteHandler.FaviconIco)
|
||||
frontend.GET("/favicon.png").Handle(r.websiteHandler.FaviconPNG)
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
)
|
||||
|
||||
type Mode string //@enum:type
|
||||
@@ -13,12 +17,14 @@ type Mode string //@enum:type
|
||||
const (
|
||||
CTMStart = "START"
|
||||
CTMNormal = "NORMAL"
|
||||
CTMPaginated = "PAGINATED"
|
||||
CTMEnd = "END"
|
||||
)
|
||||
|
||||
type CursorToken struct {
|
||||
Mode Mode
|
||||
Timestamp int64
|
||||
Timestamp *int64
|
||||
Page *int
|
||||
Id string
|
||||
Direction string
|
||||
FilterHash string
|
||||
@@ -34,7 +40,8 @@ type cursorTokenSerialize struct {
|
||||
func Start() CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMStart,
|
||||
Timestamp: 0,
|
||||
Timestamp: langext.Ptr[int64](0),
|
||||
Page: nil,
|
||||
Id: "",
|
||||
Direction: "",
|
||||
FilterHash: "",
|
||||
@@ -44,7 +51,8 @@ func Start() CursorToken {
|
||||
func End() CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMEnd,
|
||||
Timestamp: 0,
|
||||
Timestamp: nil,
|
||||
Page: nil,
|
||||
Id: "",
|
||||
Direction: "",
|
||||
FilterHash: "",
|
||||
@@ -54,13 +62,22 @@ func End() CursorToken {
|
||||
func Normal(ts time.Time, id string, dir string, filter string) CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMNormal,
|
||||
Timestamp: ts.UnixMilli(),
|
||||
Timestamp: langext.Ptr(ts.UnixMilli()),
|
||||
Page: nil,
|
||||
Id: id,
|
||||
Direction: dir,
|
||||
FilterHash: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func Paginated(p int) CursorToken {
|
||||
return CursorToken{
|
||||
Mode: CTMPaginated,
|
||||
Timestamp: nil,
|
||||
Page: langext.Ptr(p),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CursorToken) Token() string {
|
||||
if c.Mode == CTMStart {
|
||||
return "@start"
|
||||
@@ -69,6 +86,10 @@ func (c *CursorToken) Token() string {
|
||||
return "@end"
|
||||
}
|
||||
|
||||
if c.Page != nil {
|
||||
return fmt.Sprintf("$%d", *c.Page)
|
||||
}
|
||||
|
||||
// We kinda manually implement omitempty for the CursorToken here
|
||||
// because omitempty does not work for time.Time and otherwise we would always
|
||||
// get weird time values when decoding a token that initially didn't have an Timestamp set
|
||||
@@ -80,8 +101,8 @@ func (c *CursorToken) Token() string {
|
||||
sertok.Id = &c.Id
|
||||
}
|
||||
|
||||
if c.Timestamp != 0 {
|
||||
sertok.Timestamp = &c.Timestamp
|
||||
if c.Timestamp != nil && *c.Timestamp != 0 {
|
||||
sertok.Timestamp = langext.Ptr(*c.Timestamp)
|
||||
}
|
||||
|
||||
if c.Direction != "" {
|
||||
@@ -111,6 +132,14 @@ func Decode(tok string) (CursorToken, error) {
|
||||
return End(), nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(tok, "$") {
|
||||
p, err := strconv.ParseInt(tok[1:], 10, 0)
|
||||
if err != nil {
|
||||
return CursorToken{}, errors.New("could not decode paginated token")
|
||||
}
|
||||
return Paginated(int(p)), nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(tok, "tok_") {
|
||||
return CursorToken{}, errors.New("could not decode token, missing prefix")
|
||||
}
|
||||
@@ -129,7 +158,7 @@ func Decode(tok string) (CursorToken, error) {
|
||||
token := CursorToken{Mode: CTMNormal}
|
||||
|
||||
if tokenDeserialize.Timestamp != nil {
|
||||
token.Timestamp = *tokenDeserialize.Timestamp
|
||||
token.Timestamp = langext.Ptr(*tokenDeserialize.Timestamp)
|
||||
}
|
||||
if tokenDeserialize.Id != nil {
|
||||
token.Id = *tokenDeserialize.Id
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package primary
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"blackforestbytes.com/simplecloudnotifier/db"
|
||||
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
|
||||
"blackforestbytes.com/simplecloudnotifier/models"
|
||||
"errors"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *Database) GetMessageByUserMessageID(ctx db.TxContext, usrMsgId string) (*models.Message, error) {
|
||||
@@ -87,26 +88,32 @@ func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter,
|
||||
return nil, ct.CursorToken{}, 0, err
|
||||
}
|
||||
|
||||
pageCond := "1=1"
|
||||
if inTok.Mode == ct.CTMNormal {
|
||||
pageCond = "timestamp_real < :tokts OR (timestamp_real = :tokts AND message_id < :tokid )"
|
||||
}
|
||||
|
||||
filterCond, filterJoin, prepParams, err := filter.SQL()
|
||||
|
||||
orderClause := ""
|
||||
pageCond := "1=1"
|
||||
|
||||
limitCond := ""
|
||||
if pageSize != nil {
|
||||
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC LIMIT :lim"
|
||||
limitCond = "LIMIT :lim"
|
||||
prepParams["lim"] = *pageSize + 1
|
||||
} else {
|
||||
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
|
||||
}
|
||||
|
||||
sqlQueryList := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
|
||||
sqlQueryCount := "SELECT " + " COUNT(*) AS count FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
|
||||
|
||||
if inTok.Mode == ct.CTMNormal {
|
||||
pageCond = "timestamp_real < :tokts OR (timestamp_real = :tokts AND message_id < :tokid )"
|
||||
prepParams["tokts"] = inTok.Timestamp
|
||||
prepParams["tokid"] = inTok.Id
|
||||
} else if inTok.Mode == ct.CTMPaginated {
|
||||
if pageSize != nil {
|
||||
limitCond = "LIMIT :lim OFFSET :off"
|
||||
prepParams["lim"] = *pageSize + 1
|
||||
prepParams["off"] = (*pageSize) * (*inTok.Page)
|
||||
}
|
||||
}
|
||||
|
||||
orderClause := "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
|
||||
|
||||
sqlQueryList := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause + " " + limitCond
|
||||
sqlQueryCount := "SELECT " + " COUNT(*) AS count FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
|
||||
|
||||
if inTok.Mode == ct.CTMEnd {
|
||||
|
||||
@@ -132,7 +139,12 @@ func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter,
|
||||
return nil, ct.CursorToken{}, 0, err
|
||||
}
|
||||
|
||||
outToken := ct.Normal(dataList[*pageSize-1].Timestamp(), dataList[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
|
||||
var outToken ct.CursorToken
|
||||
if inTok.Mode == ct.CTMPaginated {
|
||||
outToken = ct.Paginated(*inTok.Page + 1)
|
||||
} else {
|
||||
outToken = ct.Normal(dataList[*pageSize-1].Timestamp(), dataList[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
|
||||
}
|
||||
|
||||
return dataList[0:*pageSize], outToken, dataCount.Count, nil
|
||||
}
|
||||
|
||||
@@ -52,14 +52,29 @@ func (db *Database) ListRequestLogs(ctx context.Context, filter models.RequestLo
|
||||
return make([]models.RequestLog, 0), ct.End(), nil
|
||||
}
|
||||
|
||||
pageCond := "1=1"
|
||||
if inTok.Mode == ct.CTMNormal {
|
||||
pageCond = "timestamp_created < :tokts OR (timestamp_created = :tokts AND request_id < :tokid )"
|
||||
}
|
||||
|
||||
filterCond, filterJoin, prepParams, err := filter.SQL()
|
||||
|
||||
orderClause := ""
|
||||
pageCond := "1=1"
|
||||
|
||||
limitCond := ""
|
||||
if pageSize != nil {
|
||||
limitCond = "LIMIT :lim"
|
||||
prepParams["lim"] = *pageSize + 1
|
||||
}
|
||||
|
||||
if inTok.Mode == ct.CTMNormal {
|
||||
pageCond = "timestamp_created < :tokts OR (timestamp_created = :tokts AND request_id < :tokid )"
|
||||
prepParams["tokts"] = inTok.Timestamp
|
||||
prepParams["tokid"] = inTok.Id
|
||||
} else if inTok.Mode == ct.CTMPaginated {
|
||||
if pageSize != nil {
|
||||
limitCond = "LIMIT :lim OFFSET :off"
|
||||
prepParams["lim"] = *pageSize + 1
|
||||
prepParams["off"] = (*pageSize) * (*inTok.Page)
|
||||
}
|
||||
}
|
||||
|
||||
orderClause := "ORDER BY timestamp_created DESC, request_id DESC"
|
||||
if pageSize != nil {
|
||||
orderClause = "ORDER BY timestamp_created DESC, request_id DESC LIMIT :lim"
|
||||
prepParams["lim"] = *pageSize + 1
|
||||
@@ -67,7 +82,7 @@ func (db *Database) ListRequestLogs(ctx context.Context, filter models.RequestLo
|
||||
orderClause = "ORDER BY timestamp_created DESC, request_id DESC"
|
||||
}
|
||||
|
||||
sqlQuery := "SELECT " + "requests.*" + " FROM requests " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
|
||||
sqlQuery := "SELECT " + "requests.*" + " FROM requests " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause + " " + limitCond
|
||||
|
||||
prepParams["tokts"] = inTok.Timestamp
|
||||
prepParams["tokid"] = inTok.Id
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
module blackforestbytes.com/simplecloudnotifier
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.575
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.614
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
@@ -18,20 +18,22 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -39,24 +41,25 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
|
||||
109
scnserver/go.sum
109
scnserver/go.sum
@@ -1,27 +1,27 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.575 h1:scgvSaNZQ+C0pMbfPbsxF/hE3+rgLotHe+6Lbl5+mJU=
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.575/go.mod h1:Rj+bq1jLkgvXYe2sthg5UtXHf22nFvmTLeo+54fbYq8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.614 h1:LMjy2iHEPHRfLCXhguG9mQ+cgRypy0o2OAKztzZXmqk=
|
||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.614/go.mod h1:LTkvxOvjXqkfxjxMOCD+qUlQ6Cu5uPRXQ0rZodFl7Rw=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -30,17 +30,19 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
@@ -50,12 +52,10 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -81,6 +81,10 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
@@ -90,48 +94,51 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/viney-shih/go-lock v1.1.2 h1:3TdGTiHZCPqBdTvFbQZQN/TRZzKF3KWw2rFEyKz3YqA=
|
||||
github.com/viney-shih/go-lock v1.1.2/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -140,25 +147,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/loremipsum.v1 v1.1.2 h1:12APklfJKuGszqZsrArW5QoQh03/W+qyCCjvnDuS6Tw=
|
||||
gopkg.in/loremipsum.v1 v1.1.2/go.mod h1:TuRvzFuzuejXj+odBU6Tubp/EPUyGb9wmSvHenyP2Ts=
|
||||
@@ -174,4 +182,3 @@ modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
// Code generated by enum-generate.go DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import "git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
import "git.blackforestbytes.com/BlackForestBytes/goext/enums"
|
||||
|
||||
const ChecksumEnumGenerator = "1e8100b30bf6c946a1dfdc273b41efcaa91f33eab2bda12ce5dfa853741ac90b" // GoExtVersion: 0.0.575
|
||||
|
||||
// ================================ ClientType ================================
|
||||
//
|
||||
// File: client.go
|
||||
// StringEnum: true
|
||||
// DescrEnum: false
|
||||
// DataEnum: false
|
||||
//
|
||||
|
||||
var __ClientTypeValues = []ClientType{
|
||||
ClientTypeAndroid,
|
||||
ClientTypeIOS,
|
||||
ClientTypeLinux,
|
||||
ClientTypeMacOS,
|
||||
ClientTypeWindows,
|
||||
}
|
||||
|
||||
var __ClientTypeVarnames = map[ClientType]string{
|
||||
ClientTypeAndroid: "ClientTypeAndroid",
|
||||
ClientTypeIOS: "ClientTypeIOS",
|
||||
ClientTypeLinux: "ClientTypeLinux",
|
||||
ClientTypeMacOS: "ClientTypeMacOS",
|
||||
ClientTypeWindows: "ClientTypeWindows",
|
||||
}
|
||||
|
||||
func (e ClientType) Valid() bool {
|
||||
return langext.InArray(e, __ClientTypeValues)
|
||||
}
|
||||
|
||||
func (e ClientType) Values() []ClientType {
|
||||
return __ClientTypeValues
|
||||
}
|
||||
|
||||
func (e ClientType) ValuesAny() []any {
|
||||
return langext.ArrCastToAny(__ClientTypeValues)
|
||||
}
|
||||
|
||||
func (e ClientType) ValuesMeta() []enums.EnumMetaValue {
|
||||
return ClientTypeValuesMeta()
|
||||
}
|
||||
|
||||
func (e ClientType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e ClientType) VarName() string {
|
||||
if d, ok := __ClientTypeVarnames[e]; ok {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e ClientType) TypeName() string {
|
||||
return "ClientType"
|
||||
}
|
||||
|
||||
func (e ClientType) PackageName() string {
|
||||
return "models"
|
||||
}
|
||||
|
||||
func (e ClientType) Meta() enums.EnumMetaValue {
|
||||
return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil}
|
||||
}
|
||||
|
||||
func ParseClientType(vv string) (ClientType, bool) {
|
||||
for _, ev := range __ClientTypeValues {
|
||||
if string(ev) == vv {
|
||||
return ev, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ClientTypeValues() []ClientType {
|
||||
return __ClientTypeValues
|
||||
}
|
||||
|
||||
func ClientTypeValuesMeta() []enums.EnumMetaValue {
|
||||
return []enums.EnumMetaValue{
|
||||
ClientTypeAndroid.Meta(),
|
||||
ClientTypeIOS.Meta(),
|
||||
ClientTypeLinux.Meta(),
|
||||
ClientTypeMacOS.Meta(),
|
||||
ClientTypeWindows.Meta(),
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ DeliveryStatus ================================
|
||||
//
|
||||
// File: delivery.go
|
||||
// StringEnum: true
|
||||
// DescrEnum: false
|
||||
// DataEnum: false
|
||||
//
|
||||
|
||||
var __DeliveryStatusValues = []DeliveryStatus{
|
||||
DeliveryStatusRetry,
|
||||
DeliveryStatusSuccess,
|
||||
DeliveryStatusFailed,
|
||||
}
|
||||
|
||||
var __DeliveryStatusVarnames = map[DeliveryStatus]string{
|
||||
DeliveryStatusRetry: "DeliveryStatusRetry",
|
||||
DeliveryStatusSuccess: "DeliveryStatusSuccess",
|
||||
DeliveryStatusFailed: "DeliveryStatusFailed",
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) Valid() bool {
|
||||
return langext.InArray(e, __DeliveryStatusValues)
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) Values() []DeliveryStatus {
|
||||
return __DeliveryStatusValues
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) ValuesAny() []any {
|
||||
return langext.ArrCastToAny(__DeliveryStatusValues)
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) ValuesMeta() []enums.EnumMetaValue {
|
||||
return DeliveryStatusValuesMeta()
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) VarName() string {
|
||||
if d, ok := __DeliveryStatusVarnames[e]; ok {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) TypeName() string {
|
||||
return "DeliveryStatus"
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) PackageName() string {
|
||||
return "models"
|
||||
}
|
||||
|
||||
func (e DeliveryStatus) Meta() enums.EnumMetaValue {
|
||||
return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil}
|
||||
}
|
||||
|
||||
func ParseDeliveryStatus(vv string) (DeliveryStatus, bool) {
|
||||
for _, ev := range __DeliveryStatusValues {
|
||||
if string(ev) == vv {
|
||||
return ev, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func DeliveryStatusValues() []DeliveryStatus {
|
||||
return __DeliveryStatusValues
|
||||
}
|
||||
|
||||
func DeliveryStatusValuesMeta() []enums.EnumMetaValue {
|
||||
return []enums.EnumMetaValue{
|
||||
DeliveryStatusRetry.Meta(),
|
||||
DeliveryStatusSuccess.Meta(),
|
||||
DeliveryStatusFailed.Meta(),
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ TokenPerm ================================
|
||||
//
|
||||
// File: keytoken.go
|
||||
// StringEnum: true
|
||||
// DescrEnum: true
|
||||
// DataEnum: false
|
||||
//
|
||||
|
||||
var __TokenPermValues = []TokenPerm{
|
||||
PermAdmin,
|
||||
PermChannelRead,
|
||||
PermChannelSend,
|
||||
PermUserRead,
|
||||
}
|
||||
|
||||
var __TokenPermDescriptions = map[TokenPerm]string{
|
||||
PermAdmin: "Edit userdata (+ includes all other permissions)",
|
||||
PermChannelRead: "Read messages",
|
||||
PermChannelSend: "Send messages",
|
||||
PermUserRead: "Read userdata",
|
||||
}
|
||||
|
||||
var __TokenPermVarnames = map[TokenPerm]string{
|
||||
PermAdmin: "PermAdmin",
|
||||
PermChannelRead: "PermChannelRead",
|
||||
PermChannelSend: "PermChannelSend",
|
||||
PermUserRead: "PermUserRead",
|
||||
}
|
||||
|
||||
func (e TokenPerm) Valid() bool {
|
||||
return langext.InArray(e, __TokenPermValues)
|
||||
}
|
||||
|
||||
func (e TokenPerm) Values() []TokenPerm {
|
||||
return __TokenPermValues
|
||||
}
|
||||
|
||||
func (e TokenPerm) ValuesAny() []any {
|
||||
return langext.ArrCastToAny(__TokenPermValues)
|
||||
}
|
||||
|
||||
func (e TokenPerm) ValuesMeta() []enums.EnumMetaValue {
|
||||
return TokenPermValuesMeta()
|
||||
}
|
||||
|
||||
func (e TokenPerm) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e TokenPerm) Description() string {
|
||||
if d, ok := __TokenPermDescriptions[e]; ok {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e TokenPerm) VarName() string {
|
||||
if d, ok := __TokenPermVarnames[e]; ok {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e TokenPerm) TypeName() string {
|
||||
return "TokenPerm"
|
||||
}
|
||||
|
||||
func (e TokenPerm) PackageName() string {
|
||||
return "models"
|
||||
}
|
||||
|
||||
func (e TokenPerm) Meta() enums.EnumMetaValue {
|
||||
return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: langext.Ptr(e.Description())}
|
||||
}
|
||||
|
||||
func (e TokenPerm) DescriptionMeta() enums.EnumDescriptionMetaValue {
|
||||
return enums.EnumDescriptionMetaValue{VarName: e.VarName(), Value: e, Description: e.Description()}
|
||||
}
|
||||
|
||||
func ParseTokenPerm(vv string) (TokenPerm, bool) {
|
||||
for _, ev := range __TokenPermValues {
|
||||
if string(ev) == vv {
|
||||
return ev, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TokenPermValues() []TokenPerm {
|
||||
return __TokenPermValues
|
||||
}
|
||||
|
||||
func TokenPermValuesMeta() []enums.EnumMetaValue {
|
||||
return []enums.EnumMetaValue{
|
||||
PermAdmin.Meta(),
|
||||
PermChannelRead.Meta(),
|
||||
PermChannelSend.Meta(),
|
||||
PermUserRead.Meta(),
|
||||
}
|
||||
}
|
||||
|
||||
func TokenPermValuesDescriptionMeta() []enums.EnumDescriptionMetaValue {
|
||||
return []enums.EnumDescriptionMetaValue{
|
||||
PermAdmin.DescriptionMeta(),
|
||||
PermChannelRead.DescriptionMeta(),
|
||||
PermChannelSend.DescriptionMeta(),
|
||||
PermUserRead.DescriptionMeta(),
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ TransactionLockMode ================================
|
||||
//
|
||||
// File: lock.go
|
||||
// StringEnum: true
|
||||
// DescrEnum: false
|
||||
// DataEnum: false
|
||||
//
|
||||
|
||||
var __TransactionLockModeValues = []TransactionLockMode{
|
||||
TLockNone,
|
||||
TLockRead,
|
||||
TLockReadWrite,
|
||||
}
|
||||
|
||||
var __TransactionLockModeVarnames = map[TransactionLockMode]string{
|
||||
TLockNone: "TLockNone",
|
||||
TLockRead: "TLockRead",
|
||||
TLockReadWrite: "TLockReadWrite",
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) Valid() bool {
|
||||
return langext.InArray(e, __TransactionLockModeValues)
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) Values() []TransactionLockMode {
|
||||
return __TransactionLockModeValues
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) ValuesAny() []any {
|
||||
return langext.ArrCastToAny(__TransactionLockModeValues)
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) ValuesMeta() []enums.EnumMetaValue {
|
||||
return TransactionLockModeValuesMeta()
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) VarName() string {
|
||||
if d, ok := __TransactionLockModeVarnames[e]; ok {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) TypeName() string {
|
||||
return "TransactionLockMode"
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) PackageName() string {
|
||||
return "models"
|
||||
}
|
||||
|
||||
func (e TransactionLockMode) Meta() enums.EnumMetaValue {
|
||||
return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil}
|
||||
}
|
||||
|
||||
func ParseTransactionLockMode(vv string) (TransactionLockMode, bool) {
|
||||
for _, ev := range __TransactionLockModeValues {
|
||||
if string(ev) == vv {
|
||||
return ev, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TransactionLockModeValues() []TransactionLockMode {
|
||||
return __TransactionLockModeValues
|
||||
}
|
||||
|
||||
func TransactionLockModeValuesMeta() []enums.EnumMetaValue {
|
||||
return []enums.EnumMetaValue{
|
||||
TLockNone.Meta(),
|
||||
TLockRead.Meta(),
|
||||
TLockReadWrite.Meta(),
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ ================= ================================
|
||||
|
||||
func AllPackageEnums() []enums.Enum {
|
||||
return []enums.Enum{
|
||||
ClientTypeAndroid, // ClientType
|
||||
DeliveryStatusRetry, // DeliveryStatus
|
||||
PermAdmin, // TokenPerm
|
||||
TLockNone, // TransactionLockMode
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
// Code generated by csid-generate.go DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import "crypto/rand"
|
||||
import "crypto/sha256"
|
||||
import "fmt"
|
||||
import "github.com/go-playground/validator/v10"
|
||||
import "github.com/rs/zerolog/log"
|
||||
import "git.blackforestbytes.com/BlackForestBytes/goext/exerr"
|
||||
import "git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
import "git.blackforestbytes.com/BlackForestBytes/goext/rext"
|
||||
import "math/big"
|
||||
import "reflect"
|
||||
import "regexp"
|
||||
import "strings"
|
||||
|
||||
const ChecksumCharsetIDGenerator = "1e8100b30bf6c946a1dfdc273b41efcaa91f33eab2bda12ce5dfa853741ac90b" // GoExtVersion: 0.0.575
|
||||
|
||||
const idlen = 24
|
||||
|
||||
const checklen = 1
|
||||
|
||||
const idCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const idCharsetLen = len(idCharset)
|
||||
|
||||
var charSetReverseMap = generateCharsetMap()
|
||||
|
||||
const (
|
||||
prefixUserID = "USR"
|
||||
prefixChannelID = "CHA"
|
||||
prefixDeliveryID = "DEL"
|
||||
prefixMessageID = "MSG"
|
||||
prefixSubscriptionID = "SUB"
|
||||
prefixClientID = "CLN"
|
||||
prefixRequestID = "REQ"
|
||||
prefixKeyTokenID = "TOK"
|
||||
)
|
||||
|
||||
var (
|
||||
regexUserID = generateRegex(prefixUserID)
|
||||
regexChannelID = generateRegex(prefixChannelID)
|
||||
regexDeliveryID = generateRegex(prefixDeliveryID)
|
||||
regexMessageID = generateRegex(prefixMessageID)
|
||||
regexSubscriptionID = generateRegex(prefixSubscriptionID)
|
||||
regexClientID = generateRegex(prefixClientID)
|
||||
regexRequestID = generateRegex(prefixRequestID)
|
||||
regexKeyTokenID = generateRegex(prefixKeyTokenID)
|
||||
)
|
||||
|
||||
func generateRegex(prefix string) rext.Regex {
|
||||
return rext.W(regexp.MustCompile(fmt.Sprintf("^%s[%s]{%d}[%s]{%d}$", prefix, idCharset, idlen-len(prefix)-checklen, idCharset, checklen)))
|
||||
}
|
||||
|
||||
func generateCharsetMap() []int {
|
||||
result := make([]int, 128)
|
||||
for i := 0; i < len(result); i++ {
|
||||
result[i] = -1
|
||||
}
|
||||
for idx, chr := range idCharset {
|
||||
result[int(chr)] = idx
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func generateID(prefix string) string {
|
||||
k := ""
|
||||
csMax := big.NewInt(int64(idCharsetLen))
|
||||
checksum := 0
|
||||
for i := 0; i < idlen-len(prefix)-checklen; i++ {
|
||||
v, err := rand.Int(rand.Reader, csMax)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v64 := v.Int64()
|
||||
k += string(idCharset[v64])
|
||||
checksum = (checksum + int(v64)) % (idCharsetLen)
|
||||
}
|
||||
checkstr := string(idCharset[checksum%idCharsetLen])
|
||||
return prefix + k + checkstr
|
||||
}
|
||||
|
||||
func generateIDFromSeed(prefix string, seed string) string {
|
||||
h := sha256.New()
|
||||
|
||||
iddata := ""
|
||||
for len(iddata) < idlen-len(prefix)-checklen {
|
||||
h.Write([]byte(seed))
|
||||
bs := h.Sum(nil)
|
||||
iddata += langext.NewAnyBaseConverter(idCharset).Encode(bs)
|
||||
}
|
||||
|
||||
checksum := 0
|
||||
for i := 0; i < idlen-len(prefix)-checklen; i++ {
|
||||
ichr := int(iddata[i])
|
||||
checksum = (checksum + charSetReverseMap[ichr]) % (idCharsetLen)
|
||||
}
|
||||
|
||||
checkstr := string(idCharset[checksum%idCharsetLen])
|
||||
|
||||
return prefix + iddata[:(idlen-len(prefix)-checklen)] + checkstr
|
||||
}
|
||||
|
||||
func validateID(prefix string, value string) error {
|
||||
if len(value) != idlen {
|
||||
return exerr.New(exerr.TypeInvalidCSID, "id has the wrong length").Str("value", value).Build()
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return exerr.New(exerr.TypeInvalidCSID, "id is missing the correct prefix").Str("value", value).Str("prefix", prefix).Build()
|
||||
}
|
||||
|
||||
checksum := 0
|
||||
for i := len(prefix); i < len(value)-checklen; i++ {
|
||||
ichr := int(value[i])
|
||||
if ichr < 0 || ichr >= len(charSetReverseMap) || charSetReverseMap[ichr] == -1 {
|
||||
return exerr.New(exerr.TypeInvalidCSID, "id contains invalid characters").Str("value", value).Build()
|
||||
}
|
||||
checksum = (checksum + charSetReverseMap[ichr]) % (idCharsetLen)
|
||||
}
|
||||
|
||||
checkstr := string(idCharset[checksum%idCharsetLen])
|
||||
|
||||
if !strings.HasSuffix(value, checkstr) {
|
||||
return exerr.New(exerr.TypeInvalidCSID, "id checkstring is invalid").Str("value", value).Str("checkstr", checkstr).Build()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRawData(prefix string, value string) string {
|
||||
if len(value) != idlen {
|
||||
return ""
|
||||
}
|
||||
return value[len(prefix) : idlen-checklen]
|
||||
}
|
||||
|
||||
func getCheckString(prefix string, value string) string {
|
||||
if len(value) != idlen {
|
||||
return ""
|
||||
}
|
||||
return value[idlen-checklen:]
|
||||
}
|
||||
|
||||
func ValidateEntityID(vfl validator.FieldLevel) bool {
|
||||
if !vfl.Field().CanInterface() {
|
||||
log.Error().Msgf("Failed to validate EntityID (cannot interface ?!?)")
|
||||
return false
|
||||
}
|
||||
|
||||
ifvalue := vfl.Field().Interface()
|
||||
|
||||
if value1, ok := ifvalue.(EntityID); ok {
|
||||
|
||||
if vfl.Field().Type().Kind() == reflect.Pointer && langext.IsNil(value1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if err := value1.Valid(); err != nil {
|
||||
log.Debug().Msgf("Failed to validate EntityID '%s' (%s)", value1.String(), err.Error())
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Error().Msgf("Failed to validate EntityID (wrong type: %T)", ifvalue)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ UserID (ids.go) ================================
|
||||
|
||||
func NewUserID() UserID {
|
||||
return UserID(generateID(prefixUserID))
|
||||
}
|
||||
|
||||
func (id UserID) Valid() error {
|
||||
return validateID(prefixUserID, string(id))
|
||||
}
|
||||
|
||||
func (i UserID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i UserID) Prefix() string {
|
||||
return prefixUserID
|
||||
}
|
||||
|
||||
func (id UserID) Raw() string {
|
||||
return getRawData(prefixUserID, string(id))
|
||||
}
|
||||
|
||||
func (id UserID) CheckString() string {
|
||||
return getCheckString(prefixUserID, string(id))
|
||||
}
|
||||
|
||||
func (id UserID) Regex() rext.Regex {
|
||||
return regexUserID
|
||||
}
|
||||
|
||||
// ================================ ChannelID (ids.go) ================================
|
||||
|
||||
func NewChannelID() ChannelID {
|
||||
return ChannelID(generateID(prefixChannelID))
|
||||
}
|
||||
|
||||
func (id ChannelID) Valid() error {
|
||||
return validateID(prefixChannelID, string(id))
|
||||
}
|
||||
|
||||
func (i ChannelID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i ChannelID) Prefix() string {
|
||||
return prefixChannelID
|
||||
}
|
||||
|
||||
func (id ChannelID) Raw() string {
|
||||
return getRawData(prefixChannelID, string(id))
|
||||
}
|
||||
|
||||
func (id ChannelID) CheckString() string {
|
||||
return getCheckString(prefixChannelID, string(id))
|
||||
}
|
||||
|
||||
func (id ChannelID) Regex() rext.Regex {
|
||||
return regexChannelID
|
||||
}
|
||||
|
||||
// ================================ DeliveryID (ids.go) ================================
|
||||
|
||||
func NewDeliveryID() DeliveryID {
|
||||
return DeliveryID(generateID(prefixDeliveryID))
|
||||
}
|
||||
|
||||
func (id DeliveryID) Valid() error {
|
||||
return validateID(prefixDeliveryID, string(id))
|
||||
}
|
||||
|
||||
func (i DeliveryID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i DeliveryID) Prefix() string {
|
||||
return prefixDeliveryID
|
||||
}
|
||||
|
||||
func (id DeliveryID) Raw() string {
|
||||
return getRawData(prefixDeliveryID, string(id))
|
||||
}
|
||||
|
||||
func (id DeliveryID) CheckString() string {
|
||||
return getCheckString(prefixDeliveryID, string(id))
|
||||
}
|
||||
|
||||
func (id DeliveryID) Regex() rext.Regex {
|
||||
return regexDeliveryID
|
||||
}
|
||||
|
||||
// ================================ MessageID (ids.go) ================================
|
||||
|
||||
func NewMessageID() MessageID {
|
||||
return MessageID(generateID(prefixMessageID))
|
||||
}
|
||||
|
||||
func (id MessageID) Valid() error {
|
||||
return validateID(prefixMessageID, string(id))
|
||||
}
|
||||
|
||||
func (i MessageID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i MessageID) Prefix() string {
|
||||
return prefixMessageID
|
||||
}
|
||||
|
||||
func (id MessageID) Raw() string {
|
||||
return getRawData(prefixMessageID, string(id))
|
||||
}
|
||||
|
||||
func (id MessageID) CheckString() string {
|
||||
return getCheckString(prefixMessageID, string(id))
|
||||
}
|
||||
|
||||
func (id MessageID) Regex() rext.Regex {
|
||||
return regexMessageID
|
||||
}
|
||||
|
||||
// ================================ SubscriptionID (ids.go) ================================
|
||||
|
||||
func NewSubscriptionID() SubscriptionID {
|
||||
return SubscriptionID(generateID(prefixSubscriptionID))
|
||||
}
|
||||
|
||||
func (id SubscriptionID) Valid() error {
|
||||
return validateID(prefixSubscriptionID, string(id))
|
||||
}
|
||||
|
||||
func (i SubscriptionID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i SubscriptionID) Prefix() string {
|
||||
return prefixSubscriptionID
|
||||
}
|
||||
|
||||
func (id SubscriptionID) Raw() string {
|
||||
return getRawData(prefixSubscriptionID, string(id))
|
||||
}
|
||||
|
||||
func (id SubscriptionID) CheckString() string {
|
||||
return getCheckString(prefixSubscriptionID, string(id))
|
||||
}
|
||||
|
||||
func (id SubscriptionID) Regex() rext.Regex {
|
||||
return regexSubscriptionID
|
||||
}
|
||||
|
||||
// ================================ ClientID (ids.go) ================================
|
||||
|
||||
func NewClientID() ClientID {
|
||||
return ClientID(generateID(prefixClientID))
|
||||
}
|
||||
|
||||
func (id ClientID) Valid() error {
|
||||
return validateID(prefixClientID, string(id))
|
||||
}
|
||||
|
||||
func (i ClientID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i ClientID) Prefix() string {
|
||||
return prefixClientID
|
||||
}
|
||||
|
||||
func (id ClientID) Raw() string {
|
||||
return getRawData(prefixClientID, string(id))
|
||||
}
|
||||
|
||||
func (id ClientID) CheckString() string {
|
||||
return getCheckString(prefixClientID, string(id))
|
||||
}
|
||||
|
||||
func (id ClientID) Regex() rext.Regex {
|
||||
return regexClientID
|
||||
}
|
||||
|
||||
// ================================ RequestID (ids.go) ================================
|
||||
|
||||
func NewRequestID() RequestID {
|
||||
return RequestID(generateID(prefixRequestID))
|
||||
}
|
||||
|
||||
func (id RequestID) Valid() error {
|
||||
return validateID(prefixRequestID, string(id))
|
||||
}
|
||||
|
||||
func (i RequestID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i RequestID) Prefix() string {
|
||||
return prefixRequestID
|
||||
}
|
||||
|
||||
func (id RequestID) Raw() string {
|
||||
return getRawData(prefixRequestID, string(id))
|
||||
}
|
||||
|
||||
func (id RequestID) CheckString() string {
|
||||
return getCheckString(prefixRequestID, string(id))
|
||||
}
|
||||
|
||||
func (id RequestID) Regex() rext.Regex {
|
||||
return regexRequestID
|
||||
}
|
||||
|
||||
// ================================ KeyTokenID (ids.go) ================================
|
||||
|
||||
func NewKeyTokenID() KeyTokenID {
|
||||
return KeyTokenID(generateID(prefixKeyTokenID))
|
||||
}
|
||||
|
||||
func (id KeyTokenID) Valid() error {
|
||||
return validateID(prefixKeyTokenID, string(id))
|
||||
}
|
||||
|
||||
func (i KeyTokenID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
func (i KeyTokenID) Prefix() string {
|
||||
return prefixKeyTokenID
|
||||
}
|
||||
|
||||
func (id KeyTokenID) Raw() string {
|
||||
return getRawData(prefixKeyTokenID, string(id))
|
||||
}
|
||||
|
||||
func (id KeyTokenID) CheckString() string {
|
||||
return getCheckString(prefixKeyTokenID, string(id))
|
||||
}
|
||||
|
||||
func (id KeyTokenID) Regex() rext.Regex {
|
||||
return regexKeyTokenID
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1313,3 +1313,253 @@ func TestListMessagesSubscriptionStatusAllNoSubscription(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirect(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type msg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelInternalName string `json:"channel_internal_name"`
|
||||
Content string `json:"content"`
|
||||
MessageId string `json:"message_id"`
|
||||
OwnerUserId string `json:"owner_user_id"`
|
||||
Priority int `json:"priority"`
|
||||
SenderIp string `json:"sender_ip"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderUserId string `json:"sender_user_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
UsrMessageId string `json:"usr_message_id"`
|
||||
}
|
||||
type mglist struct {
|
||||
Messages []msg `json:"messages"`
|
||||
NPT string `json:"next_page_token"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// User 16 has 23 messages: "Lorem Ipsum 01" through "Lorem Ipsum 23"
|
||||
// With page_size=10:
|
||||
// Page 0 ($0): messages 23-14 (10 items)
|
||||
// Page 1 ($1): messages 13-04 (10 items)
|
||||
// Page 2 ($2): messages 03-01 (3 items)
|
||||
|
||||
// Test $0 - first page (same as @start)
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$0"))
|
||||
tt.AssertEqual(t, "msgList.len", 10, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList.PageSize", 10, msgList.PageSize)
|
||||
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 23", msgList.Messages[0].Title)
|
||||
tt.AssertEqual(t, "msgList[9]", "Lorem Ipsum 14", msgList.Messages[9].Title)
|
||||
tt.AssertEqual(t, "msgList.NPT", "$1", msgList.NPT)
|
||||
}
|
||||
|
||||
// Test $1 - second page
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$1"))
|
||||
tt.AssertEqual(t, "msgList.len", 10, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList.PageSize", 10, msgList.PageSize)
|
||||
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 13", msgList.Messages[0].Title)
|
||||
tt.AssertEqual(t, "msgList[9]", "Lorem Ipsum 04", msgList.Messages[9].Title)
|
||||
tt.AssertEqual(t, "msgList.NPT", "$2", msgList.NPT)
|
||||
}
|
||||
|
||||
// Test $2 - third/last page
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$2"))
|
||||
tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList.PageSize", 10, msgList.PageSize)
|
||||
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 03", msgList.Messages[0].Title)
|
||||
tt.AssertEqual(t, "msgList[2]", "Lorem Ipsum 01", msgList.Messages[2].Title)
|
||||
tt.AssertEqual(t, "msgList.NPT", "@end", msgList.NPT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirectJumpToMiddle(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type msg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelInternalName string `json:"channel_internal_name"`
|
||||
Content string `json:"content"`
|
||||
MessageId string `json:"message_id"`
|
||||
OwnerUserId string `json:"owner_user_id"`
|
||||
Priority int `json:"priority"`
|
||||
SenderIp string `json:"sender_ip"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderUserId string `json:"sender_user_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
UsrMessageId string `json:"usr_message_id"`
|
||||
}
|
||||
type mglist struct {
|
||||
Messages []msg `json:"messages"`
|
||||
NPT string `json:"next_page_token"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Jump directly to page 1 (second page) without going through page 0
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$1"))
|
||||
tt.AssertEqual(t, "msgList.len", 10, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 13", msgList.Messages[0].Title)
|
||||
tt.AssertEqual(t, "msgList.NPT", "$2", msgList.NPT)
|
||||
}
|
||||
|
||||
// Jump directly to page 2 (third page) without going through pages 0 and 1
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$2"))
|
||||
tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 03", msgList.Messages[0].Title)
|
||||
tt.AssertEqual(t, "msgList.NPT", "@end", msgList.NPT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirectBeyondEnd(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type msg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelInternalName string `json:"channel_internal_name"`
|
||||
Content string `json:"content"`
|
||||
MessageId string `json:"message_id"`
|
||||
OwnerUserId string `json:"owner_user_id"`
|
||||
Priority int `json:"priority"`
|
||||
SenderIp string `json:"sender_ip"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderUserId string `json:"sender_user_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
UsrMessageId string `json:"usr_message_id"`
|
||||
}
|
||||
type mglist struct {
|
||||
Messages []msg `json:"messages"`
|
||||
NPT string `json:"next_page_token"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Request a page beyond the last available data (page 10 when only 3 pages exist)
|
||||
{
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$10"))
|
||||
tt.AssertEqual(t, "msgList.len", 0, len(msgList.Messages))
|
||||
tt.AssertEqual(t, "msgList.NPT", "@end", msgList.NPT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirectWithFilters(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type msg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelInternalName string `json:"channel_internal_name"`
|
||||
Content string `json:"content"`
|
||||
MessageId string `json:"message_id"`
|
||||
OwnerUserId string `json:"owner_user_id"`
|
||||
Priority int `json:"priority"`
|
||||
SenderIp string `json:"sender_ip"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderUserId string `json:"sender_user_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
UsrMessageId string `json:"usr_message_id"`
|
||||
}
|
||||
type mglist struct {
|
||||
Messages []msg `json:"messages"`
|
||||
NPT string `json:"next_page_token"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// Test pagination with a filter applied
|
||||
// User 0 has 22 messages, filter by priority=1 should give 11 messages
|
||||
{
|
||||
// First page
|
||||
msgList0 := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s&priority=1", 5, "$0"))
|
||||
tt.AssertEqual(t, "msgList0.len", 5, len(msgList0.Messages))
|
||||
tt.AssertEqual(t, "msgList0.NPT", "$1", msgList0.NPT)
|
||||
|
||||
// Second page
|
||||
msgList1 := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s&priority=1", 5, "$1"))
|
||||
tt.AssertEqual(t, "msgList1.len", 5, len(msgList1.Messages))
|
||||
tt.AssertEqual(t, "msgList1.NPT", "$2", msgList1.NPT)
|
||||
|
||||
// Third page (last)
|
||||
msgList2 := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s&priority=1", 5, "$2"))
|
||||
tt.AssertEqual(t, "msgList2.len", 1, len(msgList2.Messages))
|
||||
tt.AssertEqual(t, "msgList2.NPT", "@end", msgList2.NPT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirectChainedNavigation(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
type msg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelInternalName string `json:"channel_internal_name"`
|
||||
Content string `json:"content"`
|
||||
MessageId string `json:"message_id"`
|
||||
OwnerUserId string `json:"owner_user_id"`
|
||||
Priority int `json:"priority"`
|
||||
SenderIp string `json:"sender_ip"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderUserId string `json:"sender_user_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Title string `json:"title"`
|
||||
Trimmed bool `json:"trimmed"`
|
||||
UsrMessageId string `json:"usr_message_id"`
|
||||
}
|
||||
type mglist struct {
|
||||
Messages []msg `json:"messages"`
|
||||
NPT string `json:"next_page_token"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Test that following the returned NPT from $0 correctly navigates through all pages
|
||||
var allMessages []msg
|
||||
npt := "$0"
|
||||
|
||||
for npt != "@end" {
|
||||
msgList := tt.RequestAuthGet[mglist](t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, npt))
|
||||
allMessages = append(allMessages, msgList.Messages...)
|
||||
npt = msgList.NPT
|
||||
}
|
||||
|
||||
// User 16 has 23 messages total
|
||||
tt.AssertEqual(t, "total messages", 23, len(allMessages))
|
||||
tt.AssertEqual(t, "first message", "Lorem Ipsum 23", allMessages[0].Title)
|
||||
tt.AssertEqual(t, "last message", "Lorem Ipsum 01", allMessages[22].Title)
|
||||
}
|
||||
|
||||
func TestListMessagesPaginatedDirectInvalidToken(t *testing.T) {
|
||||
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
|
||||
defer stop()
|
||||
|
||||
data := tt.InitDefaultData(t, ws)
|
||||
|
||||
// Test invalid paginated token (non-numeric after $)
|
||||
tt.RequestAuthGetShouldFail(t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$abc"), 400, apierr.PAGETOKEN_ERROR)
|
||||
|
||||
// Test invalid paginated token (empty after $)
|
||||
tt.RequestAuthGetShouldFail(t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$"), 400, apierr.PAGETOKEN_ERROR)
|
||||
|
||||
// Test invalid paginated token (float)
|
||||
tt.RequestAuthGetShouldFail(t, data.User[16].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?page_size=%d&next_page_token=%s", 10, "$1.5"), 400, apierr.PAGETOKEN_ERROR)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<form id="mainpnl">
|
||||
|
||||
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered edge-btn" id="tl_link1"><span class="icn-google-play"></span></a>
|
||||
<a tabindex="-1" href="#" class="button bordered edge-btn" id="tl_link2"><span class="icn-app-store"></span></a>
|
||||
<a tabindex="-1" href="https://apps.apple.com/us/app/simplecloudnotifier/id6455594868" class="button bordered edge-btn" id="tl_link2"><span class="icn-app-store"></span></a>
|
||||
|
||||
<a tabindex="-1" href="/api" class="button bordered edge-btn" id="tr_link">API</a>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div id="mainpnl">
|
||||
|
||||
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered edge-btn" id="tl_link1"><span class="icn-google-play"></span></a>
|
||||
<a tabindex="-1" href="#" class="button bordered edge-btn" id="tl_link2"><span class="icn-app-store"></span></a>
|
||||
<a tabindex="-1" href="https://apps.apple.com/us/app/simplecloudnotifier/id6455594868" class="button bordered edge-btn" id="tl_link2"><span class="icn-app-store"></span></a>
|
||||
|
||||
<a tabindex="-1" href="/" class="button bordered edge-btn" id="tr_link">Send</a>
|
||||
|
||||
|
||||
54
scnserver/website/privacy_policy.html
Normal file
54
scnserver/website/privacy_policy.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template|header.[theme].html}}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="copyinfo">
|
||||
<a tabindex="-1" href="https://www.blackforestbytes.com">© blackforestbytes</a>
|
||||
<a tabindex="-1" href="https://www.mikescher.com">made by Mike Schwörer</a>
|
||||
</div>
|
||||
|
||||
{{template|theme_switch.[theme].html}}
|
||||
|
||||
<div id="mainpnl">
|
||||
|
||||
<strong>Privacy Policy</strong>
|
||||
<p>This privacy policy applies to the SimpleCloudNotifier app (hereby referred to as "Application") for mobile devices that was created by blackforestbytes GmbH (hereby referred to as "Service Provider") as a Free service. This service is intended for use "AS IS".</p>
|
||||
<br>
|
||||
<strong>What information does the Application obtain and how is it used?</strong>
|
||||
<p>The Application does not obtain any information when you download and use it. Registration is not required to use the Application.</p>
|
||||
<br>
|
||||
<strong>Does the Application collect precise real time location information of the device?</strong>
|
||||
<p>This Application does not collect precise information about the location of your mobile device.</p>
|
||||
<br>
|
||||
<strong>Do third parties see and/or have access to information obtained by the Application?</strong>
|
||||
<p>Since the Application does not collect any information, no data is shared with third parties.</p>
|
||||
<br>
|
||||
<strong>What are my opt-out rights?</strong>
|
||||
<p>You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network.</p>
|
||||
<br>
|
||||
<strong>Children</strong>
|
||||
<p>The Application is not used to knowingly solicit data from or market to children under the age of 13.</p>
|
||||
<br>
|
||||
<p>The Service Provider does not knowingly collect personally identifiable information from children. The Service Provider encourages all children to never submit any personally identifiable information through the Application and/or Services. The Service Provider encourage parents and legal guardians to monitor their children's Internet usage and to help enforce this Policy by instructing their children never to provide personally identifiable information through the Application and/or Services without their permission. If you have reason to believe that a child has provided personally identifiable information to the Service Provider through the Application and/or Services, please contact the Service Provider (playstore_scn@blackforestbytes.de) so that they will be able to take the necessary actions. You must also be at least 16 years of age to consent to the processing of your personally identifiable information in your country (in some countries we may allow your parent or guardian to do so on your behalf).</p>
|
||||
<br>
|
||||
<strong>Security</strong>
|
||||
<p>The Service Provider is concerned about safeguarding the confidentiality of your information. However, since the Application does not collect any information, there is no risk of your data being accessed by unauthorized individuals.</p>
|
||||
<br>
|
||||
<strong>Changes</strong>
|
||||
<p>This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to their Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.</p>
|
||||
<br>
|
||||
<p>This privacy policy is effective as of 2025-11-10</p>
|
||||
<br>
|
||||
<strong>Your Consent</strong>
|
||||
<p>By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by the Service Provider.</p>
|
||||
<br><strong>Contact Us</strong>
|
||||
<p>If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at playstore_scn@blackforestbytes.de.</p>
|
||||
<hr>
|
||||
<p>This privacy policy page was generated by <a href="https://app-privacy-policy-generator.nisrulz.com/" target="_blank" rel="noopener noreferrer">App Privacy Policy Generator</a></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
11
webapp/.dockerignore
Normal file
11
webapp/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.angular
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
17
webapp/.editorconfig
Normal file
17
webapp/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
45
webapp/.gitignore
vendored
Normal file
45
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
.vscode
|
||||
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
1
webapp/.nvmrc
Normal file
1
webapp/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
20
webapp/Dockerfile
Normal file
20
webapp/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist/scn-webapp/browser /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
40
webapp/Makefile
Normal file
40
webapp/Makefile
Normal file
@@ -0,0 +1,40 @@
|
||||
DOCKER_REPO="registry.blackforestbytes.com"
|
||||
DOCKER_NAME=mikescher/simplecloudnotifier-webapp
|
||||
|
||||
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
HASH=$(shell git rev-parse HEAD)
|
||||
|
||||
run:
|
||||
. ${HOME}/.nvm/nvm.sh && nvm use && npm i && npm run dev
|
||||
|
||||
setup:
|
||||
npm install
|
||||
|
||||
build:
|
||||
npm install
|
||||
npm run build:loc
|
||||
|
||||
clean:
|
||||
rm -rf ./node_modules
|
||||
rm -rf ./dist
|
||||
|
||||
docker:
|
||||
docker build \
|
||||
-t $(DOCKER_NAME):$(HASH) \
|
||||
-t $(DOCKER_NAME):$(NAMESPACE)-latest \
|
||||
-t $(DOCKER_NAME):latest \
|
||||
-t $(DOCKER_REPO)/$(DOCKER_NAME):$(HASH) \
|
||||
-t $(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest \
|
||||
-t $(DOCKER_REPO)/$(DOCKER_NAME):latest \
|
||||
-f Dockerfile \
|
||||
.
|
||||
|
||||
push-docker:
|
||||
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)
|
||||
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest
|
||||
docker image push $(DOCKER_REPO)/$(DOCKER_NAME):latest
|
||||
|
||||
lint:
|
||||
. ${HOME}/.nvm/nvm.sh && nvm use && npx eslint .
|
||||
|
||||
100
webapp/angular.json
Normal file
100
webapp/angular.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"scn-webapp": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/scn-webapp",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{"input": "src/assets", "output": ".", "glob": "**/*" }
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1.5MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kB",
|
||||
"maximumError": "16kB"
|
||||
}
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"qrcode"
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "scn-webapp:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "scn-webapp:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
webapp/nginx.conf
Normal file
15
webapp/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|map|jpe?g|png|gif|ico|svg|woff2?|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
15304
webapp/package-lock.json
generated
Normal file
15304
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
webapp/package.json
Normal file
41
webapp/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "scn-webapp",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"ng-zorro-antd": "^19.3.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.19",
|
||||
"@angular/cli": "^19.2.19",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
12
webapp/src/app/app.component.ts
Normal file
12
webapp/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NzMessageModule } from 'ng-zorro-antd/message';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NzMessageModule],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
styles: []
|
||||
})
|
||||
export class AppComponent {}
|
||||
93
webapp/src/app/app.config.ts
Normal file
93
webapp/src/app/app.config.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { en_US, provideNzI18n } from 'ng-zorro-antd/i18n';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import en from '@angular/common/locales/en';
|
||||
import { provideNzIcons } from 'ng-zorro-antd/icon';
|
||||
import { IconDefinition } from '@ant-design/icons-angular';
|
||||
import {
|
||||
MenuFoldOutline,
|
||||
MenuUnfoldOutline,
|
||||
DashboardOutline,
|
||||
MailOutline,
|
||||
KeyOutline,
|
||||
TeamOutline,
|
||||
UserOutline,
|
||||
SettingOutline,
|
||||
LogoutOutline,
|
||||
SendOutline,
|
||||
BellOutline,
|
||||
CopyOutline,
|
||||
QrcodeOutline,
|
||||
DeleteOutline,
|
||||
EditOutline,
|
||||
PlusOutline,
|
||||
CheckOutline,
|
||||
CloseOutline,
|
||||
SearchOutline,
|
||||
FilterOutline,
|
||||
ReloadOutline,
|
||||
EyeOutline,
|
||||
EyeInvisibleOutline,
|
||||
AndroidOutline,
|
||||
AppleOutline,
|
||||
WindowsOutline,
|
||||
DesktopOutline,
|
||||
LinkOutline,
|
||||
InfoCircleOutline,
|
||||
ExclamationCircleOutline,
|
||||
CheckCircleOutline,
|
||||
} from '@ant-design/icons-angular/icons';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { errorInterceptor } from './core/interceptors/error.interceptor';
|
||||
|
||||
registerLocaleData(en);
|
||||
|
||||
const icons: IconDefinition[] = [
|
||||
MenuFoldOutline,
|
||||
MenuUnfoldOutline,
|
||||
DashboardOutline,
|
||||
MailOutline,
|
||||
KeyOutline,
|
||||
TeamOutline,
|
||||
UserOutline,
|
||||
SettingOutline,
|
||||
LogoutOutline,
|
||||
SendOutline,
|
||||
BellOutline,
|
||||
CopyOutline,
|
||||
QrcodeOutline,
|
||||
DeleteOutline,
|
||||
EditOutline,
|
||||
PlusOutline,
|
||||
CheckOutline,
|
||||
CloseOutline,
|
||||
SearchOutline,
|
||||
FilterOutline,
|
||||
ReloadOutline,
|
||||
EyeOutline,
|
||||
EyeInvisibleOutline,
|
||||
AndroidOutline,
|
||||
AppleOutline,
|
||||
WindowsOutline,
|
||||
DesktopOutline,
|
||||
LinkOutline,
|
||||
InfoCircleOutline,
|
||||
ExclamationCircleOutline,
|
||||
CheckCircleOutline,
|
||||
];
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||
provideAnimationsAsync(),
|
||||
provideNzI18n(en_US),
|
||||
provideNzIcons(icons),
|
||||
]
|
||||
};
|
||||
55
webapp/src/app/app.routes.ts
Normal file
55
webapp/src/app/app.routes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { MainLayoutComponent } from './layout/main-layout/main-layout.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login/login.component').then(m => m.LoginComponent)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: MainLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'messages', pathMatch: 'full' },
|
||||
{
|
||||
path: 'messages',
|
||||
loadComponent: () => import('./features/messages/message-list/message-list.component').then(m => m.MessageListComponent)
|
||||
},
|
||||
{
|
||||
path: 'messages/:id',
|
||||
loadComponent: () => import('./features/messages/message-detail/message-detail.component').then(m => m.MessageDetailComponent)
|
||||
},
|
||||
{
|
||||
path: 'channels',
|
||||
loadComponent: () => import('./features/channels/channel-list/channel-list.component').then(m => m.ChannelListComponent)
|
||||
},
|
||||
{
|
||||
path: 'channels/:id',
|
||||
loadComponent: () => import('./features/channels/channel-detail/channel-detail.component').then(m => m.ChannelDetailComponent)
|
||||
},
|
||||
{
|
||||
path: 'subscriptions',
|
||||
loadComponent: () => import('./features/subscriptions/subscription-list/subscription-list.component').then(m => m.SubscriptionListComponent)
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
loadComponent: () => import('./features/keys/key-list/key-list.component').then(m => m.KeyListComponent)
|
||||
},
|
||||
{
|
||||
path: 'clients',
|
||||
loadComponent: () => import('./features/clients/client-list/client-list.component').then(m => m.ClientListComponent)
|
||||
},
|
||||
{
|
||||
path: 'senders',
|
||||
loadComponent: () => import('./features/senders/sender-list/sender-list.component').then(m => m.SenderListComponent)
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
loadComponent: () => import('./features/account/account-info/account-info.component').then(m => m.AccountInfoComponent)
|
||||
},
|
||||
]
|
||||
},
|
||||
{ path: '**', redirectTo: 'messages' }
|
||||
];
|
||||
15
webapp/src/app/core/guards/auth.guard.ts
Normal file
15
webapp/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
|
||||
return false;
|
||||
};
|
||||
19
webapp/src/app/core/interceptors/auth.interceptor.ts
Normal file
19
webapp/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
const authHeader = authService.getAuthHeader();
|
||||
|
||||
if (authHeader) {
|
||||
const clonedReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: authHeader
|
||||
}
|
||||
});
|
||||
return next(clonedReq);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
35
webapp/src/app/core/interceptors/error.interceptor.ts
Normal file
35
webapp/src/app/core/interceptors/error.interceptor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { isApiError } from '../models';
|
||||
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const router = inject(Router);
|
||||
const authService = inject(AuthService);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
authService.logout();
|
||||
router.navigate(['/login']);
|
||||
notification.error('Session expired. Please login again.');
|
||||
} else if (error.status === 403) {
|
||||
notification.error('Access denied. Insufficient permissions.');
|
||||
} else if (error.status === 404) {
|
||||
notification.error('Resource not found.');
|
||||
} else if (error.status >= 500) {
|
||||
notification.error('Server error. Please try again later.');
|
||||
} else if (error.error && isApiError(error.error)) {
|
||||
notification.error(error.error.message);
|
||||
} else {
|
||||
notification.error('An unexpected error occurred.');
|
||||
}
|
||||
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
15
webapp/src/app/core/models/api-response.model.ts
Normal file
15
webapp/src/app/core/models/api-response.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: number;
|
||||
errhighlight: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isApiError(response: unknown): response is ApiError {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'success' in response &&
|
||||
(response as ApiError).success === false
|
||||
);
|
||||
}
|
||||
43
webapp/src/app/core/models/channel.model.ts
Normal file
43
webapp/src/app/core/models/channel.model.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Subscription } from './subscription.model';
|
||||
|
||||
export interface Channel {
|
||||
channel_id: string;
|
||||
owner_user_id: string;
|
||||
internal_name: string;
|
||||
display_name: string;
|
||||
description_name: string | null;
|
||||
subscribe_key?: string;
|
||||
send_key?: string;
|
||||
timestamp_created: string;
|
||||
timestamp_lastsent: string | null;
|
||||
messages_sent: number;
|
||||
}
|
||||
|
||||
export interface ChannelWithSubscription extends Channel {
|
||||
subscription: Subscription | null;
|
||||
}
|
||||
|
||||
export interface ChannelPreview {
|
||||
channel_id: string;
|
||||
owner_user_id: string;
|
||||
internal_name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export type ChannelSelector = 'owned' | 'subscribed' | 'all' | 'subscribed_any' | 'all_any';
|
||||
|
||||
export interface CreateChannelRequest {
|
||||
name: string;
|
||||
subscribe?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateChannelRequest {
|
||||
display_name?: string;
|
||||
description_name?: string;
|
||||
subscribe_key?: string;
|
||||
send_key?: string;
|
||||
}
|
||||
|
||||
export interface ChannelListResponse {
|
||||
channels: ChannelWithSubscription[];
|
||||
}
|
||||
33
webapp/src/app/core/models/client.model.ts
Normal file
33
webapp/src/app/core/models/client.model.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type ClientType = 'ANDROID' | 'IOS' | 'LINUX' | 'MACOS' | 'WINDOWS';
|
||||
|
||||
export interface Client {
|
||||
client_id: string;
|
||||
user_id: string;
|
||||
type: ClientType;
|
||||
fcm_token: string;
|
||||
timestamp_created: string;
|
||||
agent_model: string;
|
||||
agent_version: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface ClientListResponse {
|
||||
clients: Client[];
|
||||
}
|
||||
|
||||
export function getClientTypeIcon(type: ClientType): string {
|
||||
switch (type) {
|
||||
case 'ANDROID':
|
||||
return 'android';
|
||||
case 'IOS':
|
||||
return 'apple';
|
||||
case 'MACOS':
|
||||
return 'apple';
|
||||
case 'WINDOWS':
|
||||
return 'windows';
|
||||
case 'LINUX':
|
||||
return 'desktop';
|
||||
default:
|
||||
return 'desktop';
|
||||
}
|
||||
}
|
||||
8
webapp/src/app/core/models/index.ts
Normal file
8
webapp/src/app/core/models/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './user.model';
|
||||
export * from './message.model';
|
||||
export * from './channel.model';
|
||||
export * from './subscription.model';
|
||||
export * from './key-token.model';
|
||||
export * from './client.model';
|
||||
export * from './sender-name.model';
|
||||
export * from './api-response.model';
|
||||
51
webapp/src/app/core/models/key-token.model.ts
Normal file
51
webapp/src/app/core/models/key-token.model.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface KeyToken {
|
||||
keytoken_id: string;
|
||||
name: string;
|
||||
timestamp_created: string;
|
||||
timestamp_lastused: string | null;
|
||||
owner_user_id: string;
|
||||
all_channels: boolean;
|
||||
channels: string[];
|
||||
token?: string;
|
||||
permissions: string;
|
||||
messages_sent: number;
|
||||
}
|
||||
|
||||
export interface KeyTokenPreview {
|
||||
keytoken_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type TokenPermission = 'A' | 'CR' | 'CS' | 'UR';
|
||||
|
||||
export interface CreateKeyRequest {
|
||||
name: string;
|
||||
permissions: string;
|
||||
all_channels?: boolean;
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateKeyRequest {
|
||||
name?: string;
|
||||
permissions?: string;
|
||||
all_channels?: boolean;
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
export interface KeyListResponse {
|
||||
keys: KeyToken[];
|
||||
}
|
||||
|
||||
export function parsePermissions(permissions: string): TokenPermission[] {
|
||||
if (!permissions) return [];
|
||||
return permissions.split(';').filter(p => p) as TokenPermission[];
|
||||
}
|
||||
|
||||
export function hasPermission(permissions: string, required: TokenPermission): boolean {
|
||||
const perms = parsePermissions(permissions);
|
||||
return perms.includes(required) || perms.includes('A');
|
||||
}
|
||||
|
||||
export function isAdminKey(key: KeyToken): boolean {
|
||||
return hasPermission(key.permissions, 'A');
|
||||
}
|
||||
36
webapp/src/app/core/models/message.model.ts
Normal file
36
webapp/src/app/core/models/message.model.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface Message {
|
||||
message_id: string;
|
||||
sender_user_id: string;
|
||||
channel_internal_name: string;
|
||||
channel_owner_user_id: string;
|
||||
channel_id: string;
|
||||
sender_name: string | null;
|
||||
sender_ip: string;
|
||||
timestamp: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
priority: number;
|
||||
usr_message_id: string | null;
|
||||
used_key_id: string;
|
||||
trimmed: boolean;
|
||||
}
|
||||
|
||||
export interface MessageListParams {
|
||||
after?: string;
|
||||
before?: string;
|
||||
channel_id?: string[];
|
||||
priority?: number[];
|
||||
search?: string;
|
||||
sender?: string[];
|
||||
subscription_status?: 'all' | 'confirmed' | 'unconfirmed';
|
||||
trimmed?: boolean;
|
||||
page_size?: number;
|
||||
next_page_token?: string;
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
messages: Message[];
|
||||
next_page_token: string;
|
||||
page_size: number;
|
||||
total_count: number;
|
||||
}
|
||||
10
webapp/src/app/core/models/sender-name.model.ts
Normal file
10
webapp/src/app/core/models/sender-name.model.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface SenderNameStatistics {
|
||||
name: string;
|
||||
first_timestamp: string;
|
||||
last_timestamp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SenderNameListResponse {
|
||||
sender_names: SenderNameStatistics[];
|
||||
}
|
||||
36
webapp/src/app/core/models/subscription.model.ts
Normal file
36
webapp/src/app/core/models/subscription.model.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface Subscription {
|
||||
subscription_id: string;
|
||||
subscriber_user_id: string;
|
||||
channel_owner_user_id: string;
|
||||
channel_id: string;
|
||||
channel_internal_name: string;
|
||||
timestamp_created: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionFilter {
|
||||
direction?: 'outgoing' | 'incoming' | 'both';
|
||||
confirmation?: 'all' | 'confirmed' | 'unconfirmed';
|
||||
external?: 'all' | 'true' | 'false';
|
||||
subscriber_user_id?: string;
|
||||
channel_owner_user_id?: string;
|
||||
next_page_token?: string;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
channel_id?: string;
|
||||
channel_owner_user_id?: string;
|
||||
channel_internal_name?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmSubscriptionRequest {
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionListResponse {
|
||||
subscriptions: Subscription[];
|
||||
next_page_token?: string;
|
||||
page_size: number;
|
||||
total_count: number;
|
||||
}
|
||||
32
webapp/src/app/core/models/user.model.ts
Normal file
32
webapp/src/app/core/models/user.model.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string | null;
|
||||
timestamp_created: string;
|
||||
timestamp_lastread: string | null;
|
||||
timestamp_lastsent: string | null;
|
||||
messages_sent: number;
|
||||
is_pro: boolean;
|
||||
quota_used: number;
|
||||
quota_used_day: string | null;
|
||||
}
|
||||
|
||||
export interface UserExtra {
|
||||
quota_remaining: number;
|
||||
quota_max: number;
|
||||
quota_used: number;
|
||||
default_channel: string;
|
||||
max_body_size: number;
|
||||
max_title_length: number;
|
||||
default_priority: number;
|
||||
max_channel_name_length: number;
|
||||
max_channel_description_length: number;
|
||||
max_sender_name_length: number;
|
||||
max_user_message_id_length: number;
|
||||
}
|
||||
|
||||
export interface UserWithExtra extends User, UserExtra {}
|
||||
|
||||
export interface UserPreview {
|
||||
user_id: string;
|
||||
username: string | null;
|
||||
}
|
||||
209
webapp/src/app/core/services/api.service.ts
Normal file
209
webapp/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
User,
|
||||
UserWithExtra,
|
||||
UserPreview,
|
||||
Message,
|
||||
MessageListParams,
|
||||
MessageListResponse,
|
||||
Channel,
|
||||
ChannelWithSubscription,
|
||||
ChannelSelector,
|
||||
ChannelListResponse,
|
||||
CreateChannelRequest,
|
||||
UpdateChannelRequest,
|
||||
Subscription,
|
||||
SubscriptionFilter,
|
||||
SubscriptionListResponse,
|
||||
CreateSubscriptionRequest,
|
||||
ConfirmSubscriptionRequest,
|
||||
KeyToken,
|
||||
KeyListResponse,
|
||||
CreateKeyRequest,
|
||||
UpdateKeyRequest,
|
||||
Client,
|
||||
ClientListResponse,
|
||||
SenderNameStatistics,
|
||||
SenderNameListResponse,
|
||||
} from '../models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
// User endpoints
|
||||
getUser(userId: string): Observable<UserWithExtra> {
|
||||
return this.http.get<UserWithExtra>(`${this.baseUrl}/users/${userId}`);
|
||||
}
|
||||
|
||||
getUserPreview(userId: string): Observable<UserPreview> {
|
||||
return this.http.get<UserPreview>(`${this.baseUrl}/preview/users/${userId}`);
|
||||
}
|
||||
|
||||
updateUser(userId: string, data: { username?: string; pro_token?: string }): Observable<User> {
|
||||
return this.http.patch<User>(`${this.baseUrl}/users/${userId}`, data);
|
||||
}
|
||||
|
||||
deleteUser(userId: string): Observable<User> {
|
||||
return this.http.delete<User>(`${this.baseUrl}/users/${userId}`);
|
||||
}
|
||||
|
||||
// Key endpoints
|
||||
getKeys(userId: string): Observable<KeyListResponse> {
|
||||
return this.http.get<KeyListResponse>(`${this.baseUrl}/users/${userId}/keys`);
|
||||
}
|
||||
|
||||
getCurrentKey(userId: string): Observable<KeyToken> {
|
||||
return this.http.get<KeyToken>(`${this.baseUrl}/users/${userId}/keys/current`);
|
||||
}
|
||||
|
||||
getKey(userId: string, keyId: string): Observable<KeyToken> {
|
||||
return this.http.get<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`);
|
||||
}
|
||||
|
||||
createKey(userId: string, data: CreateKeyRequest): Observable<KeyToken> {
|
||||
return this.http.post<KeyToken>(`${this.baseUrl}/users/${userId}/keys`, data);
|
||||
}
|
||||
|
||||
updateKey(userId: string, keyId: string, data: UpdateKeyRequest): Observable<KeyToken> {
|
||||
return this.http.patch<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`, data);
|
||||
}
|
||||
|
||||
deleteKey(userId: string, keyId: string): Observable<KeyToken> {
|
||||
return this.http.delete<KeyToken>(`${this.baseUrl}/users/${userId}/keys/${keyId}`);
|
||||
}
|
||||
|
||||
// Client endpoints
|
||||
getClients(userId: string): Observable<ClientListResponse> {
|
||||
return this.http.get<ClientListResponse>(`${this.baseUrl}/users/${userId}/clients`);
|
||||
}
|
||||
|
||||
getClient(userId: string, clientId: string): Observable<Client> {
|
||||
return this.http.get<Client>(`${this.baseUrl}/users/${userId}/clients/${clientId}`);
|
||||
}
|
||||
|
||||
deleteClient(userId: string, clientId: string): Observable<Client> {
|
||||
return this.http.delete<Client>(`${this.baseUrl}/users/${userId}/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// Channel endpoints
|
||||
getChannels(userId: string, selector?: ChannelSelector): Observable<ChannelListResponse> {
|
||||
let params = new HttpParams();
|
||||
if (selector) {
|
||||
params = params.set('selector', selector);
|
||||
}
|
||||
return this.http.get<ChannelListResponse>(`${this.baseUrl}/users/${userId}/channels`, { params });
|
||||
}
|
||||
|
||||
getChannel(userId: string, channelId: string): Observable<ChannelWithSubscription> {
|
||||
return this.http.get<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels/${channelId}`);
|
||||
}
|
||||
|
||||
createChannel(userId: string, data: CreateChannelRequest): Observable<ChannelWithSubscription> {
|
||||
return this.http.post<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels`, data);
|
||||
}
|
||||
|
||||
updateChannel(userId: string, channelId: string, data: UpdateChannelRequest): Observable<ChannelWithSubscription> {
|
||||
return this.http.patch<ChannelWithSubscription>(`${this.baseUrl}/users/${userId}/channels/${channelId}`, data);
|
||||
}
|
||||
|
||||
deleteChannel(userId: string, channelId: string): Observable<Channel> {
|
||||
return this.http.delete<Channel>(`${this.baseUrl}/users/${userId}/channels/${channelId}`);
|
||||
}
|
||||
|
||||
getChannelMessages(userId: string, channelId: string, params?: { page_size?: number; next_page_token?: string; trimmed?: boolean }): Observable<MessageListResponse> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.page_size) httpParams = httpParams.set('page_size', params.page_size);
|
||||
if (params?.next_page_token) httpParams = httpParams.set('next_page_token', params.next_page_token);
|
||||
if (params?.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed);
|
||||
return this.http.get<MessageListResponse>(`${this.baseUrl}/users/${userId}/channels/${channelId}/messages`, { params: httpParams });
|
||||
}
|
||||
|
||||
getChannelSubscriptions(userId: string, channelId: string): Observable<SubscriptionListResponse> {
|
||||
return this.http.get<SubscriptionListResponse>(`${this.baseUrl}/users/${userId}/channels/${channelId}/subscriptions`);
|
||||
}
|
||||
|
||||
// Message endpoints
|
||||
getMessages(params?: MessageListParams): Observable<MessageListResponse> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.after) httpParams = httpParams.set('after', params.after);
|
||||
if (params.before) httpParams = httpParams.set('before', params.before);
|
||||
if (params.channel_id) {
|
||||
for (const c of params.channel_id) {
|
||||
httpParams = httpParams.append('channel_id', c);
|
||||
}
|
||||
}
|
||||
if (params.priority) {
|
||||
for (const p of params.priority) {
|
||||
httpParams = httpParams.append('priority', p);
|
||||
}
|
||||
}
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sender) {
|
||||
for (const s of params.sender) {
|
||||
httpParams = httpParams.append('sender', s);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
webapp/src/app/core/services/channel-cache.service.ts
Normal file
88
webapp/src/app/core/services/channel-cache.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of, map, shareReplay, catchError } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ChannelWithSubscription } from '../models';
|
||||
|
||||
export interface ResolvedChannel {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
internalName: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ChannelCacheService {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
private channelsCache$: Observable<Map<string, ChannelWithSubscription>> | null = null;
|
||||
|
||||
getAllChannels(): Observable<ChannelWithSubscription[]> {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) {
|
||||
return of([]);
|
||||
}
|
||||
return this.apiService.getChannels(userId, 'owned').pipe(
|
||||
map(response => response.channels),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
resolveChannel(channelId: string): Observable<ResolvedChannel> {
|
||||
return this.getChannelsMap().pipe(
|
||||
map(channelsMap => {
|
||||
const channel = channelsMap.get(channelId);
|
||||
return {
|
||||
channelId,
|
||||
displayName: channel?.display_name || channel?.internal_name || channelId,
|
||||
internalName: channel?.internal_name || channelId
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
resolveChannels(channelIds: string[]): Observable<Map<string, ResolvedChannel>> {
|
||||
return this.getChannelsMap().pipe(
|
||||
map(channelsMap => {
|
||||
const resolved = new Map<string, ResolvedChannel>();
|
||||
for (const channelId of channelIds) {
|
||||
const channel = channelsMap.get(channelId);
|
||||
resolved.set(channelId, {
|
||||
channelId,
|
||||
displayName: channel?.display_name || channel?.internal_name || channelId,
|
||||
internalName: channel?.internal_name || channelId
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getChannelsMap(): Observable<Map<string, ChannelWithSubscription>> {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
if (!this.channelsCache$) {
|
||||
this.channelsCache$ = this.apiService.getChannels(userId, 'owned').pipe(
|
||||
map(response => {
|
||||
const map = new Map<string, ChannelWithSubscription>();
|
||||
for (const channel of response.channels) {
|
||||
map.set(channel.channel_id, channel);
|
||||
}
|
||||
return map;
|
||||
}),
|
||||
catchError(() => of(new Map())),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
return this.channelsCache$;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.channelsCache$ = null;
|
||||
}
|
||||
}
|
||||
4
webapp/src/app/core/services/index.ts
Normal file
4
webapp/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth.service';
|
||||
export * from './api.service';
|
||||
export * from './notification.service';
|
||||
export * from './user-cache.service';
|
||||
33
webapp/src/app/core/services/notification.service.ts
Normal file
33
webapp/src/app/core/services/notification.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NotificationService {
|
||||
private message = inject(NzMessageService);
|
||||
|
||||
success(content: string): void {
|
||||
this.message.success(content);
|
||||
}
|
||||
|
||||
error(content: string): void {
|
||||
this.message.error(content);
|
||||
}
|
||||
|
||||
warning(content: string): void {
|
||||
this.message.warning(content);
|
||||
}
|
||||
|
||||
info(content: string): void {
|
||||
this.message.info(content);
|
||||
}
|
||||
|
||||
loading(content: string): string {
|
||||
return this.message.loading(content, { nzDuration: 0 }).messageId;
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
this.message.remove(id);
|
||||
}
|
||||
}
|
||||
55
webapp/src/app/core/services/user-cache.service.ts
Normal file
55
webapp/src/app/core/services/user-cache.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, of, tap, catchError, map, shareReplay } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserPreview } from '../models';
|
||||
|
||||
export interface ResolvedUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UserCacheService {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
private cache = new Map<string, Observable<UserPreview | null>>();
|
||||
|
||||
resolveUser(userId: string): Observable<ResolvedUser> {
|
||||
const currentUserId = this.authService.getUserId();
|
||||
const isCurrentUser = userId === currentUserId;
|
||||
|
||||
return this.getUserPreview(userId).pipe(
|
||||
map(preview => {
|
||||
let displayName = preview?.username || userId;
|
||||
if (isCurrentUser) {
|
||||
displayName += ' (you)';
|
||||
}
|
||||
return {
|
||||
userId,
|
||||
displayName,
|
||||
isCurrentUser
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getUserPreview(userId: string): Observable<UserPreview | null> {
|
||||
if (!this.cache.has(userId)) {
|
||||
const request$ = this.apiService.getUserPreview(userId).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
);
|
||||
this.cache.set(userId, request$);
|
||||
}
|
||||
return this.cache.get(userId)!;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Account</h2>
|
||||
<button nz-button (click)="loadUser()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<nz-spin nzSimple nzSize="large"></nz-spin>
|
||||
</div>
|
||||
} @else if (user()) {
|
||||
<nz-card nzTitle="User Information">
|
||||
<nz-descriptions nzBordered [nzColumn]="2">
|
||||
<nz-descriptions-item nzTitle="User ID" [nzSpan]="2">
|
||||
<span class="mono">{{ user()!.user_id }}</span>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Username">
|
||||
{{ user()!.username || '(Not set)' }}
|
||||
<button nz-button nzSize="small" nzType="link" (click)="openEditModal()">
|
||||
<span nz-icon nzType="edit"></span>
|
||||
</button>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Account Type">
|
||||
@if (user()!.is_pro) {
|
||||
<nz-tag nzColor="gold">Pro</nz-tag>
|
||||
} @else {
|
||||
<nz-tag>Free</nz-tag>
|
||||
}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Messages Sent">
|
||||
{{ user()!.messages_sent }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Created">
|
||||
{{ user()!.timestamp_created | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Last Read">
|
||||
{{ user()!.timestamp_lastread | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Last Sent">
|
||||
{{ user()!.timestamp_lastsent | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
</nz-descriptions>
|
||||
</nz-card>
|
||||
|
||||
<nz-card nzTitle="Quota" class="mt-16">
|
||||
<div class="quota-info">
|
||||
<div class="quota-progress">
|
||||
<nz-progress
|
||||
[nzPercent]="getQuotaPercent()"
|
||||
[nzStatus]="getQuotaStatus()"
|
||||
nzType="circle"
|
||||
></nz-progress>
|
||||
</div>
|
||||
<div class="quota-details">
|
||||
<p><strong>{{ user()!.quota_used }}</strong> / {{ user()!.quota_max }} messages used today</p>
|
||||
<p class="quota-remaining">{{ user()!.quota_remaining }} remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nz-divider></nz-divider>
|
||||
|
||||
<nz-descriptions [nzColumn]="2" nzSize="small">
|
||||
<nz-descriptions-item nzTitle="Max Body Size">
|
||||
{{ user()!.max_body_size | number }} bytes
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Max Title Length">
|
||||
{{ user()!.max_title_length }} chars
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Default Channel">
|
||||
{{ user()!.default_channel }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Default Priority">
|
||||
{{ user()!.default_priority }}
|
||||
</nz-descriptions-item>
|
||||
</nz-descriptions>
|
||||
</nz-card>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Edit Username Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showEditModal"
|
||||
nzTitle="Edit Username"
|
||||
(nzOnCancel)="closeEditModal()"
|
||||
(nzOnOk)="saveUsername()"
|
||||
[nzOkLoading]="saving()"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<nz-form-item class="mb-0">
|
||||
<nz-form-label>Username</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Enter your username"
|
||||
[(ngModel)]="editUsername"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
@@ -0,0 +1,45 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.quota-details {
|
||||
p {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.quota-remaining {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.danger-section {
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { NzProgressModule } from 'ng-zorro-antd/progress';
|
||||
import { NzModalModule } from 'ng-zorro-antd/modal';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzDividerModule } from 'ng-zorro-antd/divider';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { UserWithExtra } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-info',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzCardModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzDescriptionsModule,
|
||||
NzTagModule,
|
||||
NzSpinModule,
|
||||
NzProgressModule,
|
||||
NzModalModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzDividerModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './account-info.component.html',
|
||||
styleUrl: './account-info.component.scss'
|
||||
})
|
||||
export class AccountInfoComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
|
||||
user = signal<UserWithExtra | null>(null);
|
||||
loading = signal(true);
|
||||
|
||||
// Edit username modal
|
||||
showEditModal = signal(false);
|
||||
editUsername = '';
|
||||
saving = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadUser();
|
||||
}
|
||||
|
||||
loadUser(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.apiService.getUser(userId).subscribe({
|
||||
next: (user) => {
|
||||
this.user.set(user);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getQuotaPercent(): number {
|
||||
const user = this.user();
|
||||
if (!user || user.quota_max === 0) return 0;
|
||||
return Math.round((user.quota_used / user.quota_max) * 100);
|
||||
}
|
||||
|
||||
getQuotaStatus(): 'success' | 'normal' | 'exception' {
|
||||
const percent = this.getQuotaPercent();
|
||||
if (percent >= 90) return 'exception';
|
||||
if (percent >= 70) return 'normal';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// Edit username
|
||||
openEditModal(): void {
|
||||
const user = this.user();
|
||||
this.editUsername = user?.username || '';
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
|
||||
closeEditModal(): void {
|
||||
this.showEditModal.set(false);
|
||||
}
|
||||
|
||||
saveUsername(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.saving.set(true);
|
||||
this.apiService.updateUser(userId, {
|
||||
username: this.editUsername || undefined
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Username updated');
|
||||
this.closeEditModal();
|
||||
this.saving.set(false);
|
||||
this.loadUser();
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
75
webapp/src/app/features/auth/login/login.component.html
Normal file
75
webapp/src/app/features/auth/login/login.component.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<div class="login-container">
|
||||
<nz-card class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/logo.png" alt="SimpleCloudNotifier" class="login-logo" />
|
||||
<h1>SimpleCloudNotifier</h1>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<nz-alert
|
||||
nzType="error"
|
||||
[nzMessage]="error()!"
|
||||
nzShowIcon
|
||||
class="mb-16"
|
||||
></nz-alert>
|
||||
}
|
||||
|
||||
<form nz-form nzLayout="horizontal" (ngSubmit)="login()">
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="7">User ID</nz-form-label>
|
||||
<nz-form-control [nzSpan]="17">
|
||||
<nz-input-group nzPrefixIcon="user">
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Enter your User ID"
|
||||
[(ngModel)]="userId"
|
||||
name="userId"
|
||||
[disabled]="loading()"
|
||||
/>
|
||||
</nz-input-group>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label [nzSpan]="7">Admin Key</nz-form-label>
|
||||
<nz-form-control [nzSpan]="17">
|
||||
<nz-input-group nzPrefixIcon="key" [nzSuffix]="keySuffix">
|
||||
<input
|
||||
[type]="showKey() ? 'text' : 'password'"
|
||||
nz-input
|
||||
placeholder="Enter your Admin Key"
|
||||
[(ngModel)]="adminKey"
|
||||
name="adminKey"
|
||||
[disabled]="loading()"
|
||||
/>
|
||||
</nz-input-group>
|
||||
<ng-template #keySuffix>
|
||||
<span
|
||||
nz-icon
|
||||
[nzType]="showKey() ? 'eye' : 'eye-invisible'"
|
||||
class="key-toggle"
|
||||
(click)="toggleShowKey()"
|
||||
></span>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item class="mb-0">
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
nzBlock
|
||||
type="submit"
|
||||
[nzLoading]="loading()"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>You need an admin key to access.</p>
|
||||
</div>
|
||||
</nz-card>
|
||||
</div>
|
||||
64
webapp/src/app/features/auth/login/login.component.scss
Normal file
64
webapp/src/app/features/auth/login/login.component.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.login-logo {
|
||||
width: 80px;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.key-toggle {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
nz-form-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
87
webapp/src/app/features/auth/login/login.component.ts
Normal file
87
webapp/src/app/features/auth/login/login.component.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzAlertModule } from 'ng-zorro-antd/alert';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { isAdminKey } from '../../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzButtonModule,
|
||||
NzCardModule,
|
||||
NzAlertModule,
|
||||
NzIconModule,
|
||||
NzSpinModule,
|
||||
],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss'
|
||||
})
|
||||
export class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
userId = '';
|
||||
adminKey = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
showKey = signal(false);
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.userId.trim() || !this.adminKey.trim()) {
|
||||
this.error.set('Please enter both User ID and Admin Key');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
// Temporarily set credentials to make the API call
|
||||
this.authService.login(this.userId.trim(), this.adminKey.trim());
|
||||
|
||||
this.apiService.getCurrentKey(this.userId.trim()).subscribe({
|
||||
next: (key) => {
|
||||
if (!isAdminKey(key)) {
|
||||
this.authService.logout();
|
||||
this.error.set('This key does not have admin permissions. Please use an admin key.');
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Login successful
|
||||
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages';
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.authService.logout();
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
this.error.set('Invalid User ID or Admin Key');
|
||||
} else if (err.status === 404) {
|
||||
this.error.set('User not found');
|
||||
} else {
|
||||
this.error.set('Failed to authenticate. Please try again.');
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleShowKey(): void {
|
||||
this.showKey.update(v => !v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<div class="page-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<nz-spin nzSimple nzSize="large"></nz-spin>
|
||||
</div>
|
||||
} @else if (channel()) {
|
||||
<div class="detail-header">
|
||||
<button nz-button (click)="goBack()">
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
Back to Channels
|
||||
</button>
|
||||
@if (isOwner()) {
|
||||
<div class="header-actions">
|
||||
<button nz-button (click)="openEditModal()">
|
||||
<span nz-icon nzType="edit"></span>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this channel? All messages and subscriptions will be lost."
|
||||
nzPopconfirmPlacement="bottomRight"
|
||||
(nzOnConfirm)="deleteChannel()"
|
||||
[nzLoading]="deleting()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<nz-card [nzTitle]="channel()!.display_name">
|
||||
<nz-descriptions nzBordered [nzColumn]="2">
|
||||
<nz-descriptions-item nzTitle="Channel ID" [nzSpan]="2">
|
||||
<span class="mono">{{ channel()!.channel_id }}</span>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Internal Name">
|
||||
<span class="mono">{{ channel()!.internal_name }}</span>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Status">
|
||||
<nz-tag [nzColor]="getSubscriptionStatus().color">
|
||||
{{ getSubscriptionStatus().label }}
|
||||
</nz-tag>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Owner" [nzSpan]="2">
|
||||
<span class="mono">{{ channel()!.owner_user_id }}</span>
|
||||
</nz-descriptions-item>
|
||||
@if (channel()!.description_name) {
|
||||
<nz-descriptions-item nzTitle="Description" [nzSpan]="2">
|
||||
{{ channel()!.description_name }}
|
||||
</nz-descriptions-item>
|
||||
}
|
||||
<nz-descriptions-item nzTitle="Messages Sent">
|
||||
{{ channel()!.messages_sent }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Last Sent">
|
||||
@if (channel()!.timestamp_lastsent) {
|
||||
{{ channel()!.timestamp_lastsent | relativeTime }}
|
||||
} @else {
|
||||
Never
|
||||
}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Created" [nzSpan]="2">
|
||||
{{ channel()!.timestamp_created }}
|
||||
</nz-descriptions-item>
|
||||
</nz-descriptions>
|
||||
</nz-card>
|
||||
|
||||
@if (isOwner()) {
|
||||
<nz-card nzTitle="Keys" class="mt-16">
|
||||
@if (channel()!.subscribe_key) {
|
||||
<div class="key-section">
|
||||
<label>Subscribe Key</label>
|
||||
<nz-input-group [nzSuffix]="subscribeKeySuffix">
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
[value]="channel()!.subscribe_key"
|
||||
readonly
|
||||
class="mono"
|
||||
/>
|
||||
</nz-input-group>
|
||||
<ng-template #subscribeKeySuffix>
|
||||
<span
|
||||
nz-icon
|
||||
nzType="copy"
|
||||
class="action-icon"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Copy"
|
||||
[appCopyToClipboard]="channel()!.subscribe_key!"
|
||||
></span>
|
||||
</ng-template>
|
||||
<div class="key-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Regenerate subscribe key? The existing key will no longer be valid."
|
||||
(nzOnConfirm)="regenerateSubscribeKey()"
|
||||
>
|
||||
Invalidate & Regenerate
|
||||
</button>
|
||||
</div>
|
||||
<div class="qr-section">
|
||||
<app-qr-code-display [data]="qrCodeData()"></app-qr-code-display>
|
||||
<p class="qr-hint">Scan this QR code with the SimpleCloudNotifier app to subscribe to this channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (channel()!.send_key) {
|
||||
<nz-divider></nz-divider>
|
||||
<div class="key-section">
|
||||
<label>Send Key</label>
|
||||
<nz-input-group [nzSuffix]="sendKeySuffix">
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
[value]="channel()!.send_key"
|
||||
readonly
|
||||
class="mono"
|
||||
/>
|
||||
</nz-input-group>
|
||||
<ng-template #sendKeySuffix>
|
||||
<span
|
||||
nz-icon
|
||||
nzType="copy"
|
||||
class="action-icon"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Copy"
|
||||
[appCopyToClipboard]="channel()!.send_key!"
|
||||
></span>
|
||||
</ng-template>
|
||||
<div class="key-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Regenerate send key?"
|
||||
(nzOnConfirm)="regenerateSendKey()"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</nz-card>
|
||||
|
||||
<nz-card nzTitle="Subscriptions" class="mt-16">
|
||||
<nz-table
|
||||
#subscriptionTable
|
||||
[nzData]="subscriptions()"
|
||||
[nzLoading]="loadingSubscriptions()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="small"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subscriber</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ sub.subscriber_user_id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
|
||||
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>{{ sub.timestamp_created | relativeTime }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
}
|
||||
} @else {
|
||||
<nz-card>
|
||||
<div class="not-found">
|
||||
<p>Channel not found</p>
|
||||
<button nz-button nzType="primary" (click)="goBack()">
|
||||
Back to Channels
|
||||
</button>
|
||||
</div>
|
||||
</nz-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showEditModal"
|
||||
nzTitle="Edit Channel"
|
||||
(nzOnCancel)="closeEditModal()"
|
||||
(nzOnOk)="saveChannel()"
|
||||
[nzOkLoading]="saving()"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<nz-form-item>
|
||||
<nz-form-label>Display Name</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
[(ngModel)]="editDisplayName"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="mb-0">
|
||||
<nz-form-label>Description</nz-form-label>
|
||||
<nz-form-control>
|
||||
<textarea
|
||||
nz-input
|
||||
rows="3"
|
||||
[(ngModel)]="editDescription"
|
||||
></textarea>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-section {
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
|
||||
p {
|
||||
color: #999;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
app-qr-code-display {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||
import { NzDividerModule } from 'ng-zorro-antd/divider';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzModalModule } from 'ng-zorro-antd/modal';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ChannelWithSubscription, Subscription } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
|
||||
import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzCardModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzDescriptionsModule,
|
||||
NzTagModule,
|
||||
NzSpinModule,
|
||||
NzPopconfirmModule,
|
||||
NzDividerModule,
|
||||
NzInputModule,
|
||||
NzModalModule,
|
||||
NzFormModule,
|
||||
NzTableModule,
|
||||
NzToolTipModule,
|
||||
NzEmptyModule,
|
||||
RelativeTimePipe,
|
||||
CopyToClipboardDirective,
|
||||
QrCodeDisplayComponent,
|
||||
],
|
||||
templateUrl: './channel-detail.component.html',
|
||||
styleUrl: './channel-detail.component.scss'
|
||||
})
|
||||
export class ChannelDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
|
||||
channel = signal<ChannelWithSubscription | null>(null);
|
||||
subscriptions = signal<Subscription[]>([]);
|
||||
loading = signal(true);
|
||||
loadingSubscriptions = signal(false);
|
||||
deleting = signal(false);
|
||||
|
||||
// Edit modal
|
||||
showEditModal = signal(false);
|
||||
editDisplayName = '';
|
||||
editDescription = '';
|
||||
saving = signal(false);
|
||||
|
||||
// QR code data (computed from channel)
|
||||
qrCodeData = computed(() => {
|
||||
const channel = this.channel();
|
||||
if (!channel || !channel.subscribe_key) return '';
|
||||
|
||||
return [
|
||||
'@scn.channel.subscribe',
|
||||
'v1',
|
||||
channel.display_name,
|
||||
channel.owner_user_id,
|
||||
channel.channel_id,
|
||||
channel.subscribe_key
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
const channelId = this.route.snapshot.paramMap.get('id');
|
||||
if (channelId) {
|
||||
this.loadChannel(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
loadChannel(channelId: string): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.apiService.getChannel(userId, channelId).subscribe({
|
||||
next: (channel) => {
|
||||
this.channel.set(channel);
|
||||
this.loading.set(false);
|
||||
if (this.isOwner()) {
|
||||
this.loadSubscriptions(channelId);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadSubscriptions(channelId: string): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loadingSubscriptions.set(true);
|
||||
this.apiService.getChannelSubscriptions(userId, channelId).subscribe({
|
||||
next: (response) => {
|
||||
this.subscriptions.set(response.subscriptions);
|
||||
this.loadingSubscriptions.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingSubscriptions.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/channels']);
|
||||
}
|
||||
|
||||
isOwner(): boolean {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
return channel?.owner_user_id === userId;
|
||||
}
|
||||
|
||||
// Edit methods
|
||||
openEditModal(): void {
|
||||
const channel = this.channel();
|
||||
if (!channel) return;
|
||||
|
||||
this.editDisplayName = channel.display_name;
|
||||
this.editDescription = channel.description_name || '';
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
|
||||
closeEditModal(): void {
|
||||
this.showEditModal.set(false);
|
||||
}
|
||||
|
||||
saveChannel(): void {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!channel || !userId) return;
|
||||
|
||||
this.saving.set(true);
|
||||
this.apiService.updateChannel(userId, channel.channel_id, {
|
||||
display_name: this.editDisplayName,
|
||||
description_name: this.editDescription || undefined
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.channel.set(updated);
|
||||
this.notification.success('Channel updated');
|
||||
this.closeEditModal();
|
||||
this.saving.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete channel
|
||||
deleteChannel(): void {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!channel || !userId) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
this.apiService.deleteChannel(userId, channel.channel_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Channel deleted');
|
||||
this.router.navigate(['/channels']);
|
||||
},
|
||||
error: () => {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate keys
|
||||
regenerateSubscribeKey(): void {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!channel || !userId) return;
|
||||
|
||||
this.apiService.updateChannel(userId, channel.channel_id, {
|
||||
subscribe_key: 'true'
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.channel.set(updated);
|
||||
this.notification.success('Subscribe key regenerated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
regenerateSendKey(): void {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!channel || !userId) return;
|
||||
|
||||
this.apiService.updateChannel(userId, channel.channel_id, {
|
||||
send_key: 'true'
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.channel.set(updated);
|
||||
this.notification.success('Send key regenerated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSubscriptionStatus(): { label: string; color: string } {
|
||||
const channel = this.channel();
|
||||
if (!channel) return { label: 'Unknown', color: 'default' };
|
||||
|
||||
if (this.isOwner()) {
|
||||
if (channel.subscription) {
|
||||
return { label: 'Owned & Subscribed', color: 'green' };
|
||||
}
|
||||
return { label: 'Owned', color: 'blue' };
|
||||
}
|
||||
|
||||
if (channel.subscription) {
|
||||
if (channel.subscription.confirmed) {
|
||||
return { label: 'Subscribed', color: 'green' };
|
||||
}
|
||||
return { label: 'Pending', color: 'orange' };
|
||||
}
|
||||
|
||||
return { label: 'Not Subscribed', color: 'default' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Channels</h2>
|
||||
<div class="header-actions">
|
||||
<button nz-button (click)="loadChannels()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#channelTable
|
||||
[nzData]="channels()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="20%">Name</th>
|
||||
<th nzWidth="15%">Internal Name</th>
|
||||
<th nzWidth="15%">Owner</th>
|
||||
<th nzWidth="15%">Status</th>
|
||||
<th nzWidth="15%">Messages</th>
|
||||
<th nzWidth="20%">Last Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (channel of channels(); track channel.channel_id) {
|
||||
<tr [class.clickable-row]="isOwned(channel)" (click)="isOwned(channel) && viewChannel(channel)">
|
||||
<td>
|
||||
<div class="channel-name">{{ channel.display_name }}</div>
|
||||
@if (channel.description_name) {
|
||||
<div class="channel-description">{{ channel.description_name }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ channel.internal_name }}</span>
|
||||
</td>
|
||||
<td>{{ getOwnerDisplayName(channel.owner_user_id) }}</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
|
||||
{{ getSubscriptionStatus(channel).label }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>{{ channel.messages_sent }}</td>
|
||||
<td>
|
||||
@if (channel.timestamp_lastsent) {
|
||||
<span nz-tooltip [nzTooltipTitle]="channel.timestamp_lastsent">
|
||||
{{ channel.timestamp_lastsent | relativeTime }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.channel-description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzBadgeModule } from 'ng-zorro-antd/badge';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { ChannelWithSubscription } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzBadgeModule,
|
||||
NzEmptyModule,
|
||||
NzCardModule,
|
||||
NzToolTipModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './channel-list.component.html',
|
||||
styleUrl: './channel-list.component.scss'
|
||||
})
|
||||
export class ChannelListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private userCacheService = inject(UserCacheService);
|
||||
private router = inject(Router);
|
||||
|
||||
channels = signal<ChannelWithSubscription[]>([]);
|
||||
ownerNames = signal<Map<string, ResolvedUser>>(new Map());
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadChannels();
|
||||
}
|
||||
|
||||
loadChannels(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.apiService.getChannels(userId, 'all_any').subscribe({
|
||||
next: (response) => {
|
||||
this.channels.set(response.channels);
|
||||
this.loading.set(false);
|
||||
this.resolveOwnerNames(response.channels);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveOwnerNames(channels: ChannelWithSubscription[]): void {
|
||||
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
|
||||
for (const ownerId of uniqueOwnerIds) {
|
||||
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
|
||||
this.ownerNames.update(map => new Map(map).set(ownerId, resolved));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getOwnerDisplayName(ownerId: string): string {
|
||||
const resolved = this.ownerNames().get(ownerId);
|
||||
return resolved?.displayName || ownerId;
|
||||
}
|
||||
|
||||
isOwned(channel: ChannelWithSubscription): boolean {
|
||||
return channel.owner_user_id === this.authService.getUserId();
|
||||
}
|
||||
|
||||
viewChannel(channel: ChannelWithSubscription): void {
|
||||
this.router.navigate(['/channels', channel.channel_id]);
|
||||
}
|
||||
|
||||
getSubscriptionStatus(channel: ChannelWithSubscription): { label: string; color: string } {
|
||||
const userId = this.authService.getUserId();
|
||||
|
||||
if (channel.owner_user_id === userId) {
|
||||
if (channel.subscription) {
|
||||
return { label: 'Owned & Subscribed', color: 'green' };
|
||||
}
|
||||
return { label: 'Owned', color: 'blue' };
|
||||
}
|
||||
|
||||
if (channel.subscription) {
|
||||
if (channel.subscription.confirmed) {
|
||||
return { label: 'Subscribed', color: 'green' };
|
||||
}
|
||||
return { label: 'Pending', color: 'orange' };
|
||||
}
|
||||
|
||||
return { label: 'Not Subscribed', color: 'default' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Clients</h2>
|
||||
<button nz-button (click)="loadClients()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#clientTable
|
||||
[nzData]="clients()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="5%"></th>
|
||||
<th nzWidth="20%">Name</th>
|
||||
<th nzWidth="15%">Type</th>
|
||||
<th nzWidth="25%">Agent</th>
|
||||
<th nzWidth="20%">Created</th>
|
||||
<th nzWidth="15%">Client ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (client of clients(); track client.client_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
nz-icon
|
||||
[nzType]="getClientIcon(client.type)"
|
||||
nzTheme="outline"
|
||||
class="client-icon"
|
||||
></span>
|
||||
</td>
|
||||
<td>{{ client.name || '-' }}</td>
|
||||
<td>
|
||||
<nz-tag>{{ getClientTypeLabel(client.type) }}</nz-tag>
|
||||
</td>
|
||||
<td>
|
||||
<div class="agent-info">
|
||||
<span>{{ client.agent_model }}</span>
|
||||
<span class="agent-version">v{{ client.agent_version }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span nz-tooltip [nzTooltipTitle]="client.timestamp_created">
|
||||
{{ client.timestamp_created | relativeTime }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono client-id">{{ client.client_id }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<nz-empty nzNotFoundContent="No clients registered"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.client-icon {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.agent-version {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.client-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-client-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzEmptyModule,
|
||||
NzCardModule,
|
||||
NzToolTipModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './client-list.component.html',
|
||||
styleUrl: './client-list.component.scss'
|
||||
})
|
||||
export class ClientListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
clients = signal<Client[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadClients();
|
||||
}
|
||||
|
||||
loadClients(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.apiService.getClients(userId).subscribe({
|
||||
next: (response) => {
|
||||
this.clients.set(response.clients);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getClientIcon(type: ClientType): string {
|
||||
return getClientTypeIcon(type);
|
||||
}
|
||||
|
||||
getClientTypeLabel(type: ClientType): string {
|
||||
switch (type) {
|
||||
case 'ANDROID': return 'Android';
|
||||
case 'IOS': return 'iOS';
|
||||
case 'MACOS': return 'macOS';
|
||||
case 'WINDOWS': return 'Windows';
|
||||
case 'LINUX': return 'Linux';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
326
webapp/src/app/features/keys/key-list/key-list.component.html
Normal file
326
webapp/src/app/features/keys/key-list/key-list.component.html
Normal file
@@ -0,0 +1,326 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Keys</h2>
|
||||
<div class="header-actions">
|
||||
<button nz-button (click)="loadKeys()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
<button nz-button nzType="primary" (click)="openCreateModal()">
|
||||
<span nz-icon nzType="plus"></span>
|
||||
Create Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
<nz-table
|
||||
#keyTable
|
||||
[nzData]="keys()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="25%">Name</th>
|
||||
<th nzWidth="25%">Permissions</th>
|
||||
<th nzWidth="15%">Messages Sent</th>
|
||||
<th nzWidth="20%">Last Used</th>
|
||||
<th nzWidth="15%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (key of keys(); track key.keytoken_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="key-name">
|
||||
{{ key.name }}
|
||||
@if (isCurrentKey(key)) {
|
||||
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
|
||||
}
|
||||
</div>
|
||||
<div class="key-id mono">{{ key.keytoken_id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="permissions">
|
||||
@for (perm of getPermissions(key); track perm) {
|
||||
<nz-tag
|
||||
[nzColor]="getPermissionColor(perm)"
|
||||
nz-tooltip
|
||||
[nzTooltipTitle]="getPermissionLabel(perm)"
|
||||
>
|
||||
{{ perm }}
|
||||
</nz-tag>
|
||||
}
|
||||
@if (key.all_channels) {
|
||||
<nz-tag nzColor="default" nz-tooltip nzTooltipTitle="Access to all channels">
|
||||
All Channels
|
||||
</nz-tag>
|
||||
} @else if (key.channels && key.channels.length > 0) {
|
||||
@for (channelId of key.channels; track channelId) {
|
||||
<nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId">
|
||||
{{ getChannelDisplayName(channelId) }}
|
||||
</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>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Edit key"
|
||||
(click)="openEditModal(key)"
|
||||
>
|
||||
<span nz-icon nzType="edit"></span>
|
||||
</button>
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<nz-empty nzNotFoundContent="No keys found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
</div>
|
||||
|
||||
<!-- Create Key Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showCreateModal"
|
||||
nzTitle="Create Key"
|
||||
(nzOnCancel)="closeCreateModal()"
|
||||
[nzFooter]="createModalFooter"
|
||||
nzWidth="500px"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
@if (createdKey()) {
|
||||
<!-- Show created key -->
|
||||
<nz-alert
|
||||
nzType="success"
|
||||
nzMessage="Key created successfully!"
|
||||
nzDescription="Make sure to copy the token now. You won't be able to see it again."
|
||||
nzShowIcon
|
||||
class="mb-16"
|
||||
></nz-alert>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label>Key Token</nz-form-label>
|
||||
<nz-form-control>
|
||||
<nz-input-group [nzSuffix]="copyButton">
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
[value]="createdKey()!.token"
|
||||
readonly
|
||||
class="mono"
|
||||
/>
|
||||
</nz-input-group>
|
||||
<ng-template #copyButton>
|
||||
<span
|
||||
nz-icon
|
||||
nzType="copy"
|
||||
class="copy-icon"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Copy"
|
||||
[appCopyToClipboard]="createdKey()!.token!"
|
||||
></span>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
} @else {
|
||||
<!-- Create form -->
|
||||
<nz-form-item>
|
||||
<nz-form-label>Name</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Enter a name for this key"
|
||||
[(ngModel)]="newKeyName"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<nz-form-label>Permissions</nz-form-label>
|
||||
<nz-form-control>
|
||||
<div class="permission-checkboxes">
|
||||
@for (opt of permissionOptions; track opt.value) {
|
||||
<label
|
||||
nz-checkbox
|
||||
[nzChecked]="isPermissionChecked(opt.value)"
|
||||
[nzDisabled]="opt.value !== 'A' && isPermissionChecked('A')"
|
||||
(nzCheckedChange)="onPermissionChange(opt.value, $event)"
|
||||
>
|
||||
<nz-tag [nzColor]="getPermissionColor(opt.value)">{{ opt.value }}</nz-tag>
|
||||
<span class="perm-label">{{ opt.label }}</span>
|
||||
<span class="perm-desc">- {{ opt.description }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<label nz-checkbox [(ngModel)]="newKeyAllChannels">
|
||||
Access to all channels
|
||||
</label>
|
||||
</nz-form-item>
|
||||
|
||||
@if (!newKeyAllChannels) {
|
||||
<nz-form-item class="mb-0">
|
||||
<nz-form-label>Channels</nz-form-label>
|
||||
<nz-form-control>
|
||||
<nz-select
|
||||
[(ngModel)]="newKeyChannels"
|
||||
nzMode="multiple"
|
||||
nzPlaceHolder="Select channels"
|
||||
nzShowSearch
|
||||
style="width: 100%"
|
||||
>
|
||||
@for (channel of availableChannels(); track channel.channel_id) {
|
||||
<nz-option
|
||||
[nzValue]="channel.channel_id"
|
||||
[nzLabel]="getChannelLabel(channel)"
|
||||
></nz-option>
|
||||
}
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</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>
|
||||
|
||||
<!-- Edit Key Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showEditModal"
|
||||
nzTitle="Edit Key"
|
||||
(nzOnCancel)="closeEditModal()"
|
||||
[nzFooter]="editModalFooter"
|
||||
nzWidth="500px"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<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)]="editKeyName"
|
||||
/>
|
||||
</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]="isEditPermissionChecked(opt.value)"
|
||||
[nzDisabled]="opt.value !== 'A' && isEditPermissionChecked('A')"
|
||||
(nzCheckedChange)="onEditPermissionChange(opt.value, $event)"
|
||||
>
|
||||
<nz-tag [nzColor]="getPermissionColor(opt.value)">{{ opt.value }}</nz-tag>
|
||||
<span class="perm-label">{{ opt.label }}</span>
|
||||
<span class="perm-desc">- {{ opt.description }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
<nz-form-item>
|
||||
<label nz-checkbox [(ngModel)]="editKeyAllChannels">
|
||||
Access to all channels
|
||||
</label>
|
||||
</nz-form-item>
|
||||
|
||||
@if (!editKeyAllChannels) {
|
||||
<nz-form-item class="mb-0">
|
||||
<nz-form-label>Channels</nz-form-label>
|
||||
<nz-form-control>
|
||||
<nz-select
|
||||
[(ngModel)]="editKeyChannels"
|
||||
nzMode="multiple"
|
||||
nzPlaceHolder="Select channels"
|
||||
nzShowSearch
|
||||
style="width: 100%"
|
||||
>
|
||||
@for (channel of availableChannels(); track channel.channel_id) {
|
||||
<nz-option
|
||||
[nzValue]="channel.channel_id"
|
||||
[nzLabel]="getChannelLabel(channel)"
|
||||
></nz-option>
|
||||
}
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
}
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
|
||||
<ng-template #editModalFooter>
|
||||
<button nz-button (click)="closeEditModal()">Cancel</button>
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
[nzLoading]="updating()"
|
||||
[disabled]="!editKeyName.trim() || editKeyPermissions.length === 0"
|
||||
(click)="updateKey()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,85 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.permission-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
nz-tag {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.perm-label {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.perm-desc {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
308
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal file
308
webapp/src/app/features/keys/key-list/key-list.component.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
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 { NzSelectModule } from 'ng-zorro-antd/select';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
||||
import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription } 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,
|
||||
NzSelectModule,
|
||||
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);
|
||||
private channelCacheService = inject(ChannelCacheService);
|
||||
|
||||
keys = signal<KeyToken[]>([]);
|
||||
currentKeyId = signal<string | null>(null);
|
||||
loading = signal(false);
|
||||
channelNames = signal<Map<string, ResolvedChannel>>(new Map());
|
||||
availableChannels = signal<ChannelWithSubscription[]>([]);
|
||||
|
||||
// Create modal
|
||||
showCreateModal = signal(false);
|
||||
newKeyName = '';
|
||||
newKeyPermissions: TokenPermission[] = ['CR'];
|
||||
newKeyAllChannels = true;
|
||||
newKeyChannels: string[] = [];
|
||||
creating = signal(false);
|
||||
createdKey = signal<KeyToken | null>(null);
|
||||
|
||||
// Edit modal
|
||||
showEditModal = signal(false);
|
||||
editingKey = signal<KeyToken | null>(null);
|
||||
editKeyName = '';
|
||||
editKeyPermissions: TokenPermission[] = [];
|
||||
editKeyAllChannels = true;
|
||||
editKeyChannels: string[] = [];
|
||||
updating = signal(false);
|
||||
|
||||
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();
|
||||
this.loadAvailableChannels();
|
||||
}
|
||||
|
||||
loadAvailableChannels(): void {
|
||||
this.channelCacheService.getAllChannels().subscribe(channels => {
|
||||
this.availableChannels.set(channels);
|
||||
});
|
||||
}
|
||||
|
||||
getChannelLabel(channel: ChannelWithSubscription): string {
|
||||
return channel.display_name || channel.internal_name;
|
||||
}
|
||||
|
||||
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);
|
||||
this.resolveChannelNames(response.keys);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveChannelNames(keys: KeyToken[]): void {
|
||||
const allChannelIds = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (!key.all_channels && key.channels) {
|
||||
for (const channelId of key.channels) {
|
||||
allChannelIds.add(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allChannelIds.size > 0) {
|
||||
this.channelCacheService.resolveChannels([...allChannelIds]).subscribe(resolved => {
|
||||
this.channelNames.set(resolved);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getChannelDisplayName(channelId: string): string {
|
||||
const resolved = this.channelNames().get(channelId);
|
||||
return resolved?.displayName || channelId;
|
||||
}
|
||||
|
||||
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.newKeyChannels = [];
|
||||
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,
|
||||
channels: this.newKeyAllChannels ? undefined : this.newKeyChannels
|
||||
}).subscribe({
|
||||
next: (key) => {
|
||||
this.createdKey.set(key);
|
||||
this.creating.set(false);
|
||||
this.loadKeys();
|
||||
},
|
||||
error: () => {
|
||||
this.creating.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPermissions(key: KeyToken): TokenPermission[] {
|
||||
return parsePermissions(key.permissions);
|
||||
}
|
||||
|
||||
getPermissionColor(perm: TokenPermission): string {
|
||||
switch (perm) {
|
||||
case 'A': return 'red';
|
||||
case 'CR': return 'blue';
|
||||
case 'CS': return 'green';
|
||||
case 'UR': return 'purple';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
getPermissionLabel(perm: TokenPermission): string {
|
||||
const option = this.permissionOptions.find(o => o.value === perm);
|
||||
return option?.label || perm;
|
||||
}
|
||||
|
||||
onPermissionChange(perm: TokenPermission, checked: boolean): void {
|
||||
if (checked) {
|
||||
if (perm === 'A') {
|
||||
// Admin selected - clear other permissions
|
||||
this.newKeyPermissions = ['A'];
|
||||
} else if (!this.newKeyPermissions.includes(perm)) {
|
||||
this.newKeyPermissions = [...this.newKeyPermissions, perm];
|
||||
}
|
||||
} else {
|
||||
this.newKeyPermissions = this.newKeyPermissions.filter(p => p !== perm);
|
||||
}
|
||||
}
|
||||
|
||||
isPermissionChecked(perm: TokenPermission): boolean {
|
||||
return this.newKeyPermissions.includes(perm);
|
||||
}
|
||||
|
||||
// Edit key modal
|
||||
openEditModal(key: KeyToken): void {
|
||||
this.editingKey.set(key);
|
||||
this.editKeyName = key.name;
|
||||
this.editKeyPermissions = parsePermissions(key.permissions);
|
||||
this.editKeyAllChannels = key.all_channels;
|
||||
this.editKeyChannels = key.channels ? [...key.channels] : [];
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
|
||||
closeEditModal(): void {
|
||||
this.showEditModal.set(false);
|
||||
this.editingKey.set(null);
|
||||
}
|
||||
|
||||
updateKey(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
const key = this.editingKey();
|
||||
if (!userId || !key || !this.editKeyName.trim() || this.editKeyPermissions.length === 0) return;
|
||||
|
||||
this.updating.set(true);
|
||||
this.apiService.updateKey(userId, key.keytoken_id, {
|
||||
name: this.editKeyName.trim(),
|
||||
permissions: this.editKeyPermissions.join(';'),
|
||||
all_channels: this.editKeyAllChannels,
|
||||
channels: this.editKeyAllChannels ? undefined : this.editKeyChannels
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Key updated');
|
||||
this.updating.set(false);
|
||||
this.closeEditModal();
|
||||
this.loadKeys();
|
||||
},
|
||||
error: () => {
|
||||
this.updating.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onEditPermissionChange(perm: TokenPermission, checked: boolean): void {
|
||||
if (checked) {
|
||||
if (perm === 'A') {
|
||||
this.editKeyPermissions = ['A'];
|
||||
} else if (!this.editKeyPermissions.includes(perm)) {
|
||||
this.editKeyPermissions = [...this.editKeyPermissions, perm];
|
||||
}
|
||||
} else {
|
||||
this.editKeyPermissions = this.editKeyPermissions.filter(p => p !== perm);
|
||||
}
|
||||
}
|
||||
|
||||
isEditPermissionChecked(perm: TokenPermission): boolean {
|
||||
return this.editKeyPermissions.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,153 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Messages</h2>
|
||||
<button nz-button nzType="default" (click)="loadMessages()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<nz-input-group nzSearch [nzAddOnAfter]="searchButton">
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="Search messages..."
|
||||
[(ngModel)]="searchText"
|
||||
(keyup.enter)="applyFilters()"
|
||||
/>
|
||||
</nz-input-group>
|
||||
<ng-template #searchButton>
|
||||
<button nz-button nzType="primary" nzSearch (click)="applyFilters()">
|
||||
<span nz-icon nzType="search"></span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
@if (hasActiveFilters()) {
|
||||
<div class="active-filters">
|
||||
@if (appliedSearchText) {
|
||||
<nz-tag nzMode="closeable" (nzOnClose)="clearSearch()">
|
||||
"{{ appliedSearchText }}"
|
||||
</nz-tag>
|
||||
}
|
||||
@for (channel of channelFilter; track channel) {
|
||||
<nz-tag nzMode="closeable" (nzOnClose)="removeChannelFilter(channel)">
|
||||
{{ getChannelDisplayName(channel) }}
|
||||
</nz-tag>
|
||||
}
|
||||
@for (sender of senderFilter; track sender) {
|
||||
<nz-tag nzMode="closeable" (nzOnClose)="removeSenderFilter(sender)">
|
||||
{{ sender }}
|
||||
</nz-tag>
|
||||
}
|
||||
@if (priorityFilter.length > 0) {
|
||||
<nz-tag nzMode="closeable" (nzOnClose)="clearPriorityFilter()">
|
||||
{{ getPriorityLabel(+priorityFilter[0]) }}
|
||||
</nz-tag>
|
||||
}
|
||||
@if (dateRange) {
|
||||
<nz-tag nzMode="closeable" (nzOnClose)="clearDateRange()">
|
||||
{{ getDateRangeDisplay() }}
|
||||
</nz-tag>
|
||||
}
|
||||
<a class="clear-all" (click)="clearAllFilters()">Clear all</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nz-table
|
||||
#messageTable
|
||||
nzBordered
|
||||
[nzData]="messages()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="40%">Title</th>
|
||||
<th
|
||||
nzWidth="15%"
|
||||
[nzFilters]="channelFilters()"
|
||||
[nzFilterMultiple]="true"
|
||||
(nzFilterChange)="onChannelFilterChange($event)"
|
||||
>Channel</th>
|
||||
<th
|
||||
nzWidth="15%"
|
||||
[nzFilters]="senderFilters()"
|
||||
[nzFilterMultiple]="true"
|
||||
(nzFilterChange)="onSenderFilterChange($event)"
|
||||
>Sender</th>
|
||||
<th
|
||||
nzWidth="10%"
|
||||
[nzFilters]="priorityFilters"
|
||||
[nzFilterMultiple]="false"
|
||||
(nzFilterChange)="onPriorityFilterChange($event)"
|
||||
>Priority</th>
|
||||
<th nzWidth="20%" nzCustomFilter>
|
||||
Time
|
||||
<nz-filter-trigger [(nzVisible)]="dateFilterVisible" [nzActive]="!!dateRange" [nzDropdownMenu]="dateMenu">
|
||||
<span nz-icon nzType="filter" nzTheme="fill"></span>
|
||||
</nz-filter-trigger>
|
||||
<nz-dropdown-menu #dateMenu="nzDropdownMenu">
|
||||
<div class="date-filter-dropdown" (click)="$event.stopPropagation()">
|
||||
<nz-range-picker
|
||||
[ngModel]="dateRange"
|
||||
(ngModelChange)="onDateRangeChange($event)"
|
||||
[nzAllowClear]="true"
|
||||
nzFormat="yyyy-MM-dd"
|
||||
[nzInline]="true"
|
||||
></nz-range-picker>
|
||||
</div>
|
||||
</nz-dropdown-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (message of messages(); track message.message_id) {
|
||||
<tr class="clickable-row" (click)="viewMessage(message)">
|
||||
<td>
|
||||
<div class="message-title">{{ message.title }}</div>
|
||||
@if (message.content && !message.trimmed) {
|
||||
<div class="message-preview">{{ message.content | slice:0:100 }}{{ message.content.length > 100 ? '...' : '' }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ message.channel_internal_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ message.sender_name || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||
{{ getPriorityLabel(message.priority) }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>
|
||||
<span nz-tooltip [nzTooltipTitle]="message.timestamp">
|
||||
{{ message.timestamp | relativeTime }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<nz-empty nzNotFoundContent="No messages found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<nz-pagination
|
||||
[nzPageIndex]="currentPage()"
|
||||
[nzPageSize]="pageSize"
|
||||
[nzTotal]="totalCount()"
|
||||
[nzDisabled]="loading()"
|
||||
(nzPageIndexChange)="goToPage($event)"
|
||||
></nz-pagination>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.date-filter-dropdown {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
nz-tag {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.clear-all {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
|
||||
.page-indicator {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||
import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
|
||||
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { Message, MessageListParams } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-message-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzInputModule,
|
||||
NzTagModule,
|
||||
NzIconModule,
|
||||
NzEmptyModule,
|
||||
NzSpinModule,
|
||||
NzToolTipModule,
|
||||
NzPaginationModule,
|
||||
NzDatePickerModule,
|
||||
NzDropDownModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './message-list.component.html',
|
||||
styleUrl: './message-list.component.scss'
|
||||
})
|
||||
export class MessageListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
messages = signal<Message[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
// Pagination
|
||||
currentPage = signal(1);
|
||||
pageSize = 50;
|
||||
totalCount = signal(0);
|
||||
|
||||
// Filters
|
||||
searchText = '';
|
||||
appliedSearchText = '';
|
||||
priorityFilter: string[] = [];
|
||||
channelFilter: string[] = [];
|
||||
senderFilter: string[] = [];
|
||||
dateRange: [Date, Date] | null = null;
|
||||
dateFilterVisible = false;
|
||||
|
||||
// Filter options
|
||||
priorityFilters: NzTableFilterList = [
|
||||
{ text: 'Low', value: '0' },
|
||||
{ text: 'Normal', value: '1' },
|
||||
{ text: 'High', value: '2' },
|
||||
];
|
||||
channelFilters = signal<NzTableFilterList>([]);
|
||||
senderFilters = signal<NzTableFilterList>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadChannels();
|
||||
this.loadSenders();
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
loadChannels(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.getChannels(userId, 'all_any').subscribe({
|
||||
next: (response) => {
|
||||
this.channelFilters.set(
|
||||
response.channels.map(ch => ({
|
||||
text: ch.display_name,
|
||||
value: ch.channel_id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadSenders(): void {
|
||||
this.apiService.getSenderNames().subscribe({
|
||||
next: (response) => {
|
||||
this.senderFilters.set(
|
||||
response.sender_names.map(s => ({
|
||||
text: s.name,
|
||||
value: s.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMessages(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
const params: MessageListParams = {
|
||||
page_size: this.pageSize,
|
||||
trimmed: true,
|
||||
};
|
||||
|
||||
if (this.appliedSearchText) {
|
||||
params.search = this.appliedSearchText;
|
||||
}
|
||||
if (this.priorityFilter.length > 0) {
|
||||
params.priority = this.priorityFilter.map(p => parseInt(p, 10));
|
||||
}
|
||||
if (this.channelFilter.length > 0) {
|
||||
params.channel_id = this.channelFilter;
|
||||
}
|
||||
if (this.senderFilter.length > 0) {
|
||||
params.sender = this.senderFilter;
|
||||
}
|
||||
if (this.dateRange) {
|
||||
params.after = this.dateRange[0].toISOString();
|
||||
params.before = this.dateRange[1].toISOString();
|
||||
}
|
||||
|
||||
// Use page-index based pagination: $1 = page 1, $2 = page 2, etc.
|
||||
const page = this.currentPage();
|
||||
if (page > 1) {
|
||||
params.next_page_token = `$${page}`;
|
||||
}
|
||||
|
||||
this.apiService.getMessages(params).subscribe({
|
||||
next: (response) => {
|
||||
this.messages.set(response.messages);
|
||||
this.totalCount.set(response.total_count);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.appliedSearchText = this.searchText;
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
onPriorityFilterChange(filters: string[] | null): void {
|
||||
this.priorityFilter = filters ?? [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
onChannelFilterChange(filters: string[] | null): void {
|
||||
this.channelFilter = filters ?? [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
onSenderFilterChange(filters: string[] | null): void {
|
||||
this.senderFilter = filters ?? [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchText = '';
|
||||
this.appliedSearchText = '';
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearChannelFilter(): void {
|
||||
this.channelFilter = [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
removeChannelFilter(channel: string): void {
|
||||
this.channelFilter = this.channelFilter.filter(c => c !== channel);
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearSenderFilter(): void {
|
||||
this.senderFilter = [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
removeSenderFilter(sender: string): void {
|
||||
this.senderFilter = this.senderFilter.filter(s => s !== sender);
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearPriorityFilter(): void {
|
||||
this.priorityFilter = [];
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
onDateRangeChange(dates: [Date, Date] | null): void {
|
||||
this.dateRange = dates;
|
||||
if (dates) {
|
||||
this.dateFilterVisible = false;
|
||||
}
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearDateRange(): void {
|
||||
this.dateRange = null;
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
clearAllFilters(): void {
|
||||
this.searchText = '';
|
||||
this.appliedSearchText = '';
|
||||
this.channelFilter = [];
|
||||
this.senderFilter = [];
|
||||
this.priorityFilter = [];
|
||||
this.dateRange = null;
|
||||
this.currentPage.set(1);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return !!this.appliedSearchText || this.channelFilter.length > 0 || this.senderFilter.length > 0 || this.priorityFilter.length > 0 || !!this.dateRange;
|
||||
}
|
||||
|
||||
getChannelDisplayName(channelId: string): string {
|
||||
const filters = this.channelFilters();
|
||||
const channel = filters.find(f => f.value === channelId);
|
||||
return channel?.text?.toString() ?? channelId;
|
||||
}
|
||||
|
||||
getDateRangeDisplay(): string {
|
||||
if (!this.dateRange) return '';
|
||||
const format = (d: Date) => d.toLocaleDateString();
|
||||
return `${format(this.dateRange[0])} - ${format(this.dateRange[1])}`;
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
viewMessage(message: Message): void {
|
||||
this.router.navigate(['/messages', message.message_id]);
|
||||
}
|
||||
|
||||
getPriorityLabel(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'Low';
|
||||
case 1: return 'Normal';
|
||||
case 2: return 'High';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getPriorityColor(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'default';
|
||||
case 1: return 'blue';
|
||||
case 2: return 'red';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Senders</h2>
|
||||
<button nz-button (click)="refresh()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
|
||||
<nz-tab nzTitle="My Senders">
|
||||
<nz-table
|
||||
#mySenderTable
|
||||
[nzData]="mySenders()"
|
||||
[nzLoading]="loadingMy()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="40%">Sender Name</th>
|
||||
<th nzWidth="20%">Message Count</th>
|
||||
<th nzWidth="40%">Last Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sender of mySenders(); track sender.name) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sender-name">{{ sender.name || '(No name)' }}</span>
|
||||
</td>
|
||||
<td>{{ sender.count }}</td>
|
||||
<td>
|
||||
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
|
||||
{{ sender.last_timestamp | relativeTime }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<nz-empty nzNotFoundContent="No senders found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-tab>
|
||||
|
||||
<nz-tab nzTitle="All Senders">
|
||||
<nz-table
|
||||
#allSenderTable
|
||||
[nzData]="allSenders()"
|
||||
[nzLoading]="loadingAll()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl2"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl2></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="40%">Sender Name</th>
|
||||
<th nzWidth="20%">Message Count</th>
|
||||
<th nzWidth="40%">Last Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sender of allSenders(); track sender.name) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sender-name">{{ sender.name || '(No name)' }}</span>
|
||||
</td>
|
||||
<td>{{ sender.count }}</td>
|
||||
<td>
|
||||
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
|
||||
{{ sender.last_timestamp | relativeTime }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<nz-empty nzNotFoundContent="No senders found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-tab>
|
||||
</nz-tabset>
|
||||
</nz-card>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzTabsModule } from 'ng-zorro-antd/tabs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { SenderNameStatistics } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sender-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzEmptyModule,
|
||||
NzCardModule,
|
||||
NzToolTipModule,
|
||||
NzTabsModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './sender-list.component.html',
|
||||
styleUrl: './sender-list.component.scss'
|
||||
})
|
||||
export class SenderListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
mySenders = signal<SenderNameStatistics[]>([]);
|
||||
allSenders = signal<SenderNameStatistics[]>([]);
|
||||
loadingMy = signal(false);
|
||||
loadingAll = signal(false);
|
||||
activeTab = signal(0);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMySenders();
|
||||
}
|
||||
|
||||
onTabChange(index: number): void {
|
||||
this.activeTab.set(index);
|
||||
if (index === 0 && this.mySenders().length === 0) {
|
||||
this.loadMySenders();
|
||||
} else if (index === 1 && this.allSenders().length === 0) {
|
||||
this.loadAllSenders();
|
||||
}
|
||||
}
|
||||
|
||||
loadMySenders(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loadingMy.set(true);
|
||||
this.apiService.getUserSenderNames(userId).subscribe({
|
||||
next: (response) => {
|
||||
this.mySenders.set(response.sender_names);
|
||||
this.loadingMy.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingMy.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadAllSenders(): void {
|
||||
this.loadingAll.set(true);
|
||||
this.apiService.getSenderNames().subscribe({
|
||||
next: (response) => {
|
||||
this.allSenders.set(response.sender_names);
|
||||
this.loadingAll.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingAll.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (this.activeTab() === 0) {
|
||||
this.loadMySenders();
|
||||
} else {
|
||||
this.loadAllSenders();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Subscriptions</h2>
|
||||
<div class="header-actions">
|
||||
<button nz-button (click)="loadSubscriptions()">
|
||||
<span nz-icon nzType="reload"></span>
|
||||
Refresh
|
||||
</button>
|
||||
<button nz-button nzType="primary" (click)="openCreateModal()">
|
||||
<span nz-icon nzType="plus"></span>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
|
||||
<nz-tab nzTitle="All"></nz-tab>
|
||||
<nz-tab nzTitle="Own"></nz-tab>
|
||||
<nz-tab nzTitle="Deactivated"></nz-tab>
|
||||
<nz-tab nzTitle="External"></nz-tab>
|
||||
<nz-tab nzTitle="Incoming"></nz-tab>
|
||||
</nz-tabset>
|
||||
|
||||
@if (getTabDescription()) {
|
||||
<nz-alert
|
||||
nzType="info"
|
||||
[nzMessage]="getTabDescription()!"
|
||||
nzShowIcon
|
||||
style="margin-bottom: 16px;"
|
||||
></nz-alert>
|
||||
}
|
||||
|
||||
<nz-table
|
||||
#subscriptionTable
|
||||
nzBordered
|
||||
[nzData]="subscriptions()"
|
||||
[nzLoading]="loading()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="middle"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th nzWidth="10%">Type</th>
|
||||
<th nzWidth="20%">Channel</th>
|
||||
<th nzWidth="20%">Subscriber</th>
|
||||
<th nzWidth="20%">Owner</th>
|
||||
<th nzWidth="10%">Status</th>
|
||||
<th nzWidth="12%">Created</th>
|
||||
<th nzWidth="8%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getTypeLabel(sub).color">
|
||||
{{ getTypeLabel(sub).label }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ sub.channel_internal_name }}</span>
|
||||
</td>
|
||||
<td>{{ getUserDisplayName(sub.subscriber_user_id) }}</td>
|
||||
<td>{{ getUserDisplayName(sub.channel_owner_user_id) }}</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getStatusInfo(sub).color">
|
||||
{{ getStatusInfo(sub).label }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>
|
||||
<span nz-tooltip [nzTooltipTitle]="sub.timestamp_created">
|
||||
{{ sub.timestamp_created | relativeTime }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (!sub.confirmed && isOwner(sub)) {
|
||||
<!-- Incoming unconfirmed: can accept or deny -->
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzType="primary"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Accept"
|
||||
(click)="acceptSubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="check"></span>
|
||||
</button>
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzDanger
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Deny"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Deny this subscription request?"
|
||||
(nzOnConfirm)="denySubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="close"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<!-- Confirmed or outgoing: can revoke -->
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzDanger
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Revoke"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Revoke this subscription?"
|
||||
(nzOnConfirm)="revokeSubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<nz-pagination
|
||||
[nzPageIndex]="currentPage()"
|
||||
[nzPageSize]="pageSize"
|
||||
[nzTotal]="totalCount()"
|
||||
[nzDisabled]="loading()"
|
||||
(nzPageIndexChange)="goToPage($event)"
|
||||
></nz-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Subscription Modal -->
|
||||
<nz-modal
|
||||
[(nzVisible)]="showCreateModal"
|
||||
nzTitle="Subscribe to Channel"
|
||||
(nzOnCancel)="closeCreateModal()"
|
||||
(nzOnOk)="createSubscription()"
|
||||
[nzOkLoading]="creating()"
|
||||
[nzOkDisabled]="!newChannelOwner.trim() || !newChannelName.trim()"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<p class="modal-hint">Enter the channel owner's User ID and the channel name to subscribe.</p>
|
||||
<nz-form-item>
|
||||
<nz-form-label>Channel Owner User ID</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="e.g., USR12345"
|
||||
[(ngModel)]="newChannelOwner"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="mb-0">
|
||||
<nz-form-label>Channel Name</nz-form-label>
|
||||
<nz-form-control>
|
||||
<input
|
||||
type="text"
|
||||
nz-input
|
||||
placeholder="e.g., main"
|
||||
[(ngModel)]="newChannelName"
|
||||
/>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
@@ -0,0 +1,38 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzTabsModule } from 'ng-zorro-antd/tabs';
|
||||
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||
import { NzModalModule } from 'ng-zorro-antd/modal';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzAlertModule } from 'ng-zorro-antd/alert';
|
||||
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { Subscription, SubscriptionFilter } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
|
||||
type SubscriptionTab = 'all' | 'own' | 'deactivated' | 'external' | 'incoming';
|
||||
|
||||
interface TabConfig {
|
||||
filter: SubscriptionFilter;
|
||||
}
|
||||
|
||||
const TAB_CONFIGS: Record<SubscriptionTab, TabConfig> = {
|
||||
all: { filter: {} },
|
||||
own: { filter: { direction: 'outgoing', confirmation: 'confirmed', external: 'false' } },
|
||||
deactivated: { filter: { direction: 'outgoing', confirmation: 'unconfirmed', external: 'false' } },
|
||||
external: { filter: { direction: 'outgoing', confirmation: 'all', external: 'true' } },
|
||||
incoming: { filter: { direction: 'incoming', confirmation: 'all', external: 'true' } },
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-subscription-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzEmptyModule,
|
||||
NzTabsModule,
|
||||
NzPopconfirmModule,
|
||||
NzModalModule,
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzToolTipModule,
|
||||
NzAlertModule,
|
||||
NzPaginationModule,
|
||||
RelativeTimePipe,
|
||||
],
|
||||
templateUrl: './subscription-list.component.html',
|
||||
styleUrl: './subscription-list.component.scss'
|
||||
})
|
||||
export class SubscriptionListComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private userCacheService = inject(UserCacheService);
|
||||
|
||||
subscriptions = signal<Subscription[]>([]);
|
||||
userNames = signal<Map<string, ResolvedUser>>(new Map());
|
||||
loading = signal(false);
|
||||
activeTab: SubscriptionTab = 'all';
|
||||
|
||||
// Pagination
|
||||
currentPage = signal(1);
|
||||
pageSize = 50;
|
||||
totalCount = signal(0);
|
||||
|
||||
// Create subscription modal
|
||||
showCreateModal = signal(false);
|
||||
newChannelOwner = '';
|
||||
newChannelName = '';
|
||||
creating = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
|
||||
loadSubscriptions(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
|
||||
const filter: SubscriptionFilter = {
|
||||
...TAB_CONFIGS[this.activeTab].filter,
|
||||
page_size: this.pageSize,
|
||||
};
|
||||
|
||||
// Use page-index based pagination: $1 = page 1, $2 = page 2, etc.
|
||||
const page = this.currentPage();
|
||||
if (page > 1) {
|
||||
filter.next_page_token = `$${page}`;
|
||||
}
|
||||
|
||||
this.apiService.getSubscriptions(userId, filter).subscribe({
|
||||
next: (response) => {
|
||||
this.subscriptions.set(response.subscriptions);
|
||||
this.totalCount.set(response.total_count);
|
||||
this.loading.set(false);
|
||||
this.resolveUserNames(response.subscriptions);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveUserNames(subscriptions: Subscription[]): void {
|
||||
const userIds = new Set<string>();
|
||||
for (const sub of subscriptions) {
|
||||
userIds.add(sub.subscriber_user_id);
|
||||
userIds.add(sub.channel_owner_user_id);
|
||||
}
|
||||
for (const id of userIds) {
|
||||
this.userCacheService.resolveUser(id).subscribe(resolved => {
|
||||
this.userNames.update(map => new Map(map).set(id, resolved));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getUserDisplayName(userId: string): string {
|
||||
const resolved = this.userNames().get(userId);
|
||||
return resolved?.displayName || userId;
|
||||
}
|
||||
|
||||
onTabChange(index: number): void {
|
||||
const tabs: SubscriptionTab[] = ['all', 'own', 'deactivated', 'external', 'incoming'];
|
||||
this.activeTab = tabs[index];
|
||||
this.currentPage.set(1);
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
|
||||
isOutgoing(sub: Subscription): boolean {
|
||||
const userId = this.authService.getUserId();
|
||||
return sub.subscriber_user_id === userId;
|
||||
}
|
||||
|
||||
isOwner(sub: Subscription): boolean {
|
||||
const userId = this.authService.getUserId();
|
||||
return sub.channel_owner_user_id === userId;
|
||||
}
|
||||
|
||||
// Actions
|
||||
acceptSubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: true }).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription accepted');
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
denySubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription denied');
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
revokeSubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription revoked');
|
||||
this.loadSubscriptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
openCreateModal(): void {
|
||||
this.newChannelOwner = '';
|
||||
this.newChannelName = '';
|
||||
this.showCreateModal.set(true);
|
||||
}
|
||||
|
||||
closeCreateModal(): void {
|
||||
this.showCreateModal.set(false);
|
||||
}
|
||||
|
||||
createSubscription(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId || !this.newChannelOwner.trim() || !this.newChannelName.trim()) return;
|
||||
|
||||
this.creating.set(true);
|
||||
this.apiService.createSubscription(userId, {
|
||||
channel_owner_user_id: this.newChannelOwner.trim(),
|
||||
channel_internal_name: this.newChannelName.trim()
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription request sent');
|
||||
this.closeCreateModal();
|
||||
this.creating.set(false);
|
||||
this.loadSubscriptions();
|
||||
},
|
||||
error: () => {
|
||||
this.creating.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStatusInfo(sub: Subscription): { label: string; color: string } {
|
||||
if (sub.confirmed) {
|
||||
return { label: 'Confirmed', color: 'green' };
|
||||
}
|
||||
return { label: 'Pending', color: 'orange' };
|
||||
}
|
||||
|
||||
getTypeLabel(sub: Subscription): { label: string; color: string } {
|
||||
const userId = this.authService.getUserId();
|
||||
if (sub.subscriber_user_id === sub.channel_owner_user_id) {
|
||||
return { label: 'Own', color: 'green' };
|
||||
}
|
||||
if (sub.subscriber_user_id === userId) {
|
||||
return { label: 'External', color: 'blue' };
|
||||
}
|
||||
return { label: 'Incoming', color: 'purple' };
|
||||
}
|
||||
|
||||
getTabDescription(): string | null {
|
||||
switch (this.activeTab) {
|
||||
case 'own':
|
||||
return 'Active subscriptions to your channels.';
|
||||
case 'deactivated':
|
||||
return 'Deactivated subscriptions to your channels. These can be reactivated by you.';
|
||||
case 'external':
|
||||
return 'Your subscriptions to channels owned by other users.';
|
||||
case 'incoming':
|
||||
return 'Subscription from other users to your channels.';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
webapp/src/app/layout/main-layout/main-layout.component.html
Normal file
70
webapp/src/app/layout/main-layout/main-layout.component.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<nz-layout class="app-layout">
|
||||
<nz-sider
|
||||
class="menu-sidebar"
|
||||
nzCollapsible
|
||||
nzBreakpoint="md"
|
||||
[nzCollapsed]="isCollapsed()"
|
||||
(nzCollapsedChange)="isCollapsed.set($event)"
|
||||
[nzWidth]="240"
|
||||
[nzCollapsedWidth]="80"
|
||||
>
|
||||
<div class="sidebar-logo">
|
||||
<img src="/logo.png" alt="SCN" class="sidebar-logo-img" />
|
||||
@if (!isCollapsed()) {
|
||||
<span>SimpleCloudNotifier</span>
|
||||
}
|
||||
</div>
|
||||
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed()">
|
||||
<li nz-menu-item nzMatchRouter routerLink="/messages">
|
||||
<span nz-icon nzType="mail"></span>
|
||||
<span>Messages</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/channels">
|
||||
<span nz-icon nzType="send"></span>
|
||||
<span>Channels</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/subscriptions">
|
||||
<span nz-icon nzType="link"></span>
|
||||
<span>Subscriptions</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/keys">
|
||||
<span nz-icon nzType="key"></span>
|
||||
<span>Keys</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/clients">
|
||||
<span nz-icon nzType="desktop"></span>
|
||||
<span>Clients</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/senders">
|
||||
<span nz-icon nzType="team"></span>
|
||||
<span>Senders</span>
|
||||
</li>
|
||||
<li nz-menu-item nzMatchRouter routerLink="/account">
|
||||
<span nz-icon nzType="user"></span>
|
||||
<span>Account</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nz-sider>
|
||||
<nz-layout>
|
||||
<nz-header class="app-header">
|
||||
<div class="header-left">
|
||||
<span
|
||||
class="header-trigger"
|
||||
(click)="toggleCollapsed()"
|
||||
>
|
||||
<span nz-icon [nzType]="isCollapsed() ? 'menu-unfold' : 'menu-fold'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-id mono">{{ userId }}</span>
|
||||
<button nz-button nzType="text" nzDanger (click)="logout()">
|
||||
<span nz-icon nzType="logout"></span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nz-header>
|
||||
<nz-content class="content-area">
|
||||
<router-outlet></router-outlet>
|
||||
</nz-content>
|
||||
</nz-layout>
|
||||
</nz-layout>
|
||||
95
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal file
95
webapp/src/app/layout/main-layout/main-layout.component.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.menu-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
background: #001529;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
.sidebar-logo-img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 16px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.user-id {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-area {
|
||||
margin-left: 240px;
|
||||
transition: margin-left 0.2s;
|
||||
min-height: calc(100vh - 64px);
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
:host-context(.ant-layout-sider-collapsed) + nz-layout .content-area,
|
||||
nz-layout:has(.ant-layout-sider-collapsed) .content-area {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
// Handle collapsed state with sibling selector
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.ant-layout-sider-collapsed ~ nz-layout .content-area {
|
||||
margin-left: 80px;
|
||||
}
|
||||
}
|
||||
42
webapp/src/app/layout/main-layout/main-layout.component.ts
Normal file
42
webapp/src/app/layout/main-layout/main-layout.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet, RouterLink, Router } from '@angular/router';
|
||||
import { NzLayoutModule } from 'ng-zorro-antd/layout';
|
||||
import { NzMenuModule } from 'ng-zorro-antd/menu';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-main-layout',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
NzLayoutModule,
|
||||
NzMenuModule,
|
||||
NzIconModule,
|
||||
NzButtonModule,
|
||||
NzDropDownModule,
|
||||
],
|
||||
templateUrl: './main-layout.component.html',
|
||||
styleUrl: './main-layout.component.scss'
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
isCollapsed = signal(false);
|
||||
userId = this.authService.getUserId();
|
||||
|
||||
toggleCollapsed(): void {
|
||||
this.isCollapsed.update(v => !v);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-qr-code-display',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NzSpinModule],
|
||||
template: `
|
||||
@if (loading()) {
|
||||
<div class="qr-loading">
|
||||
<nz-spin nzSimple></nz-spin>
|
||||
</div>
|
||||
} @else if (qrDataUrl()) {
|
||||
<div class="qr-code-container">
|
||||
<img [src]="qrDataUrl()" alt="QR Code" />
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px;
|
||||
}
|
||||
.qr-code-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
|
||||
img {
|
||||
max-width: 256px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class QrCodeDisplayComponent implements OnChanges {
|
||||
@Input() data = '';
|
||||
@Input() size = 256;
|
||||
|
||||
loading = signal(false);
|
||||
qrDataUrl = signal<string | null>(null);
|
||||
|
||||
async ngOnChanges(changes: SimpleChanges): Promise<void> {
|
||||
if (changes['data'] && this.data) {
|
||||
await this.generateQrCode();
|
||||
}
|
||||
}
|
||||
|
||||
private async generateQrCode(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const url = await QRCode.toDataURL(this.data, {
|
||||
width: this.size,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M'
|
||||
});
|
||||
this.qrDataUrl.set(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
this.qrDataUrl.set(null);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Directive, HostListener, Input, inject } from '@angular/core';
|
||||
import { NotificationService } from '../../core/services/notification.service';
|
||||
|
||||
@Directive({
|
||||
selector: '[appCopyToClipboard]',
|
||||
standalone: true
|
||||
})
|
||||
export class CopyToClipboardDirective {
|
||||
@Input('appCopyToClipboard') textToCopy = '';
|
||||
|
||||
private notification = inject(NotificationService);
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
async onClick(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.textToCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.textToCopy);
|
||||
this.notification.success('Copied to clipboard');
|
||||
} catch {
|
||||
this.notification.error('Failed to copy to clipboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
21
webapp/src/app/shared/pipes/relative-time.pipe.ts
Normal file
21
webapp/src/app/shared/pipes/relative-time.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
|
||||
@Pipe({
|
||||
name: 'relativeTime',
|
||||
standalone: true
|
||||
})
|
||||
export class RelativeTimePipe implements PipeTransform {
|
||||
transform(value: string | Date | null | undefined): string {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = typeof value === 'string' ? parseISO(value) : value;
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
webapp/src/assets/favicon.ico
Normal file
BIN
webapp/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
webapp/src/assets/logo.png
Normal file
BIN
webapp/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
4
webapp/src/environments/environment.prod.ts
Normal file
4
webapp/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: '/api/v2'
|
||||
};
|
||||
4
webapp/src/environments/environment.ts
Normal file
4
webapp/src/environments/environment.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'https://simplecloudnotifier.blackforestbytes.com/api/v2'
|
||||
};
|
||||
13
webapp/src/index.html
Normal file
13
webapp/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SimpleCloudNotifier</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user