Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9db56f6db6
|
|||
|
5e6060e537
|
|||
|
4b8ebf15d2
|
|||
|
d26f18f356
|
|||
|
6090319b5f
|
|||
|
3ed323e056
|
|||
|
f41ef30121
|
|||
|
01df2b49f6
|
|||
|
8306992533
|
|||
|
d983737239
|
|||
|
7c88281f03
|
|||
|
308d6bbba0
|
|||
|
85e6e4adfb
|
|||
|
c860ef9c30
|
|||
|
e7f613b5dc
|
|||
| d932410802 | |||
| 26d2854617 | |||
|
b521f74951
|
|||
|
acc23c0d10
|
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
name: Build Docker and Deploy
|
name: Build Docker and Deploy
|
||||||
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
|
run-name: "[cicd-server]: ${{ github.event.head_commit.message }}"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -30,6 +30,7 @@ jobs:
|
|||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- run: cd "${{ gitea.workspace }}/scnserver" && make clean
|
- 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 docker
|
||||||
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
|
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
|
||||||
|
|
||||||
@@ -60,21 +61,7 @@ jobs:
|
|||||||
run: go version
|
run: go version
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd "${{ gitea.workspace }}/scnserver" && make dgi && make swagger && SCN_TEST_LOGLEVEL=WARN make test
|
run: cd "${{ gitea.workspace }}/scnserver" && make generate && 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"
|
|
||||||
|
|
||||||
deploy_server:
|
deploy_server:
|
||||||
name: Deploy to 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ if [[ "$VERS_BY_TAG" != "$VERS_BY_SPEC" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "(!) Make sure you've updated version-number in pubspec.yaml (current = ${VERS}) !"
|
echo "(!) Make sure you've updated version-number in pubspec.yaml (current = ${VERS}) and created a tag (current = ${VERS_BY_TAG}) !"
|
||||||
echo 'Confirmed' && read -r
|
echo '> Press Enter to confirm...' && read -r
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
@@ -31,7 +31,10 @@ echo ""
|
|||||||
|
|
||||||
flutter build appbundle --release
|
flutter build appbundle --release
|
||||||
cp build/app/outputs/bundle/release/app-release.aab "_releases/v${VERS}.aab"
|
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" .
|
|
||||||
|
pushd "build/app/intermediates/merged_native_libs/release/out/lib" || exit 1
|
||||||
|
zip -r "../../../../../../../_releases/v${VERS}.symbols.zip" .
|
||||||
|
popd || exit 1
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--> copied AAB to _releases ( Version: ${VERS} )"
|
echo "--> copied AAB to _releases ( Version: ${VERS} )"
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ include:
|
|||||||
- package:lints/recommended.yaml
|
- package:lints/recommended.yaml
|
||||||
- package:flutter_lints/flutter.yaml
|
- package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
formatter:
|
||||||
|
page_width: 512
|
||||||
|
trailing_commas: preserve
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -166,20 +166,34 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
title: 'Sender',
|
title: 'Sender',
|
||||||
values: [message.senderName!],
|
values: [message.senderName!],
|
||||||
mainAction: () => {
|
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)
|
if (cfg.showExtendedAttributes)
|
||||||
UI.metaCard(
|
UI.metaCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: FontAwesomeIcons.solidGearCode,
|
icon: FontAwesomeIcons.solidGearCode,
|
||||||
title: 'KeyToken',
|
title: 'Used Key',
|
||||||
values: [message.usedKeyID, token?.name ?? '...'],
|
values: [message.usedKeyID, token?.name ?? '...'],
|
||||||
mainAction: () {
|
mainAction: () {
|
||||||
if (message.senderUserID == userAccUserID) {
|
if (message.senderUserID == userAccUserID) {
|
||||||
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
||||||
} else {
|
} 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(
|
UI.metaCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: FontAwesomeIcons.solidGearCode,
|
icon: FontAwesomeIcons.solidGearCode,
|
||||||
title: 'KeyToken',
|
title: 'Used Key',
|
||||||
values: [token.name],
|
values: [token.name],
|
||||||
mainAction: () {
|
mainAction: () {
|
||||||
if (message.senderUserID == userAccUserID) {
|
if (message.senderUserID == userAccUserID) {
|
||||||
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
|
||||||
} else {
|
} 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,
|
: null,
|
||||||
),
|
),
|
||||||
UI.metaCard(
|
UI.metaCard(context: context, icon: FontAwesomeIcons.solidTimer, title: 'Timestamp', values: [message.timestamp]),
|
||||||
context: context,
|
|
||||||
icon: FontAwesomeIcons.solidTimer,
|
|
||||||
title: 'Timestamp',
|
|
||||||
values: [message.timestamp],
|
|
||||||
),
|
|
||||||
if (cfg.showExtendedAttributes)
|
if (cfg.showExtendedAttributes)
|
||||||
UI.metaCard(
|
UI.metaCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: FontAwesomeIcons.solidUser,
|
icon: FontAwesomeIcons.solidUser,
|
||||||
title: 'User',
|
title: 'User',
|
||||||
values: [user?.userID ?? message.senderUserID, if (user?.username != null) user?.username ?? ''],
|
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)
|
if (!cfg.showExtendedAttributes)
|
||||||
UI.metaCard(
|
UI.metaCard(
|
||||||
@@ -228,14 +251,28 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
icon: FontAwesomeIcons.solidUser,
|
icon: FontAwesomeIcons.solidUser,
|
||||||
title: 'User',
|
title: 'User',
|
||||||
values: [user?.username ?? user?.userID ?? message.senderUserID],
|
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(
|
UI.metaCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: FontAwesomeIcons.solidBolt,
|
icon: FontAwesomeIcons.solidBolt,
|
||||||
title: 'Priority',
|
title: 'Priority',
|
||||||
values: [_prettyPrintPriority(message.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)
|
if (message.senderUserID == userAccUserID)
|
||||||
UI.button(
|
UI.button(
|
||||||
@@ -243,7 +280,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Toaster.info("Not Implemented", "... will be implemented in a later version"); // TODO
|
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,
|
thumbVisibility: false,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(controller: _controller, child: child),
|
||||||
controller: _controller,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(child: child);
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,12 +317,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
return [
|
return [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
UI.channelChip(
|
UI.channelChip(context: context, text: _resolveChannelName(channel, message), margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), fontSize: 16),
|
||||||
context: context,
|
|
||||||
text: _resolveChannelName(channel, message),
|
|
||||||
margin: const EdgeInsets.fromLTRB(0, 0, 4, 0),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
Expanded(child: SizedBox()),
|
Expanded(child: SizedBox()),
|
||||||
Text(dateFormat.format(DateTime.parse(message.timestamp)), style: const TextStyle(fontSize: 14)),
|
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,
|
borderColor: (message.priority == 2) ? Colors.red[900] : null,
|
||||||
)
|
)
|
||||||
: UI.box(
|
: UI.box(context: context, padding: const EdgeInsets.all(4), child: Text(message.content ?? ''), borderColor: (message.priority == 2) ? Colors.red[900] : null),
|
||||||
context: context,
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: Text(message.content ?? ''),
|
|
||||||
borderColor: (message.priority == 2) ? Colors.red[900] : null,
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,14 @@ class AppSettings extends ChangeNotifier {
|
|||||||
|
|
||||||
class AppNotificationSettings {
|
class AppNotificationSettings {
|
||||||
// Immutable
|
// Immutable
|
||||||
AppNotificationSettings({this.enableLights = false, this.enableVibration = true, this.playSound = true, this.sound = null, this.silent = false, this.timeoutAfter = null});
|
AppNotificationSettings({
|
||||||
|
this.enableLights = false,
|
||||||
|
this.enableVibration = true,
|
||||||
|
this.playSound = true,
|
||||||
|
this.sound = null,
|
||||||
|
this.silent = false,
|
||||||
|
this.timeoutAfter = null,
|
||||||
|
});
|
||||||
|
|
||||||
final bool enableLights;
|
final bool enableLights;
|
||||||
final bool enableVibration;
|
final bool enableVibration;
|
||||||
@@ -199,7 +206,14 @@ class AppNotificationSettings {
|
|||||||
final silent = prefs.getBool('${prefix}.silent') ?? def.silent;
|
final silent = prefs.getBool('${prefix}.silent') ?? def.silent;
|
||||||
final timeoutAfter = _decode(prefs.getString('${prefix}.timeoutAfter'), def.timeoutAfter);
|
final timeoutAfter = _decode(prefs.getString('${prefix}.timeoutAfter'), def.timeoutAfter);
|
||||||
|
|
||||||
return AppNotificationSettings(enableLights: enableLights, enableVibration: enableVibration, playSound: playSound, sound: sound, silent: silent, timeoutAfter: timeoutAfter);
|
return AppNotificationSettings(
|
||||||
|
enableLights: enableLights,
|
||||||
|
enableVibration: enableVibration,
|
||||||
|
playSound: playSound,
|
||||||
|
sound: sound,
|
||||||
|
silent: silent,
|
||||||
|
timeoutAfter: timeoutAfter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: simplecloudnotifier
|
|||||||
description: "Receive push messages"
|
description: "Receive push messages"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 2.1.0+509
|
version: 2.1.1+509
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.9.0 <4.0.0'
|
sdk: '>=3.9.0 <4.0.0'
|
||||||
|
|||||||
5
scnserver/.gitignore
vendored
5
scnserver/.gitignore
vendored
@@ -24,6 +24,11 @@ identifier.sqlite
|
|||||||
|
|
||||||
scn_send.sh
|
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
|
rm -rf /var/lib/apt/lists
|
||||||
|
|
||||||
COPY --from=builder /buildsrc/_build/scn_backend /app/server
|
COPY --from=builder /buildsrc/_build/scn_backend /app/server
|
||||||
|
COPY --from=builder /buildsrc/DOCKER_GIT_INFO /DOCKER_GIT_INFO
|
||||||
|
|
||||||
RUN mkdir /data
|
RUN mkdir /data
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ swagger-setup:
|
|||||||
swagger: swagger-setup
|
swagger: swagger-setup
|
||||||
".swaggobin/swag_$(SWAGGO_VERSION)" init -generalInfo ./api/router.go --propertyStrategy camelcase --output ./swagger/ --outputTypes "json,yaml"
|
".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
|
pygmentize: website/scn_send.html
|
||||||
|
|
||||||
website/scn_send.html: ../scn_send.sh
|
website/scn_send.html: ../scn_send.sh
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
//
|
//
|
||||||
// @Param uid path string true "UserID"
|
// @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 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 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
|
||||||
// @Failure 404 {object} ginresp.apiError "message not found"
|
// @Failure 404 {object} ginresp.apiError "message not found"
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||||
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||||
"blackforestbytes.com/simplecloudnotifier/logic"
|
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||||
"blackforestbytes.com/simplecloudnotifier/models"
|
"blackforestbytes.com/simplecloudnotifier/models"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/ginext"
|
"git.blackforestbytes.com/BlackForestBytes/goext/ginext"
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListUserSubscriptions swaggerdoc
|
// ListUserSubscriptions swaggerdoc
|
||||||
@@ -40,7 +41,8 @@ import (
|
|||||||
// @Tags API-v2
|
// @Tags API-v2
|
||||||
//
|
//
|
||||||
// @Param uid path string true "UserID"
|
// @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
|
// @Success 200 {object} handler.ListUserSubscriptions.response
|
||||||
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import (
|
|||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Mode string //@enum:type
|
type Mode string //@enum:type
|
||||||
@@ -13,12 +17,14 @@ type Mode string //@enum:type
|
|||||||
const (
|
const (
|
||||||
CTMStart = "START"
|
CTMStart = "START"
|
||||||
CTMNormal = "NORMAL"
|
CTMNormal = "NORMAL"
|
||||||
|
CTMPaginated = "PAGINATED"
|
||||||
CTMEnd = "END"
|
CTMEnd = "END"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CursorToken struct {
|
type CursorToken struct {
|
||||||
Mode Mode
|
Mode Mode
|
||||||
Timestamp int64
|
Timestamp *int64
|
||||||
|
Page *int
|
||||||
Id string
|
Id string
|
||||||
Direction string
|
Direction string
|
||||||
FilterHash string
|
FilterHash string
|
||||||
@@ -34,7 +40,8 @@ type cursorTokenSerialize struct {
|
|||||||
func Start() CursorToken {
|
func Start() CursorToken {
|
||||||
return CursorToken{
|
return CursorToken{
|
||||||
Mode: CTMStart,
|
Mode: CTMStart,
|
||||||
Timestamp: 0,
|
Timestamp: langext.Ptr[int64](0),
|
||||||
|
Page: nil,
|
||||||
Id: "",
|
Id: "",
|
||||||
Direction: "",
|
Direction: "",
|
||||||
FilterHash: "",
|
FilterHash: "",
|
||||||
@@ -44,7 +51,8 @@ func Start() CursorToken {
|
|||||||
func End() CursorToken {
|
func End() CursorToken {
|
||||||
return CursorToken{
|
return CursorToken{
|
||||||
Mode: CTMEnd,
|
Mode: CTMEnd,
|
||||||
Timestamp: 0,
|
Timestamp: nil,
|
||||||
|
Page: nil,
|
||||||
Id: "",
|
Id: "",
|
||||||
Direction: "",
|
Direction: "",
|
||||||
FilterHash: "",
|
FilterHash: "",
|
||||||
@@ -54,13 +62,22 @@ func End() CursorToken {
|
|||||||
func Normal(ts time.Time, id string, dir string, filter string) CursorToken {
|
func Normal(ts time.Time, id string, dir string, filter string) CursorToken {
|
||||||
return CursorToken{
|
return CursorToken{
|
||||||
Mode: CTMNormal,
|
Mode: CTMNormal,
|
||||||
Timestamp: ts.UnixMilli(),
|
Timestamp: langext.Ptr(ts.UnixMilli()),
|
||||||
|
Page: nil,
|
||||||
Id: id,
|
Id: id,
|
||||||
Direction: dir,
|
Direction: dir,
|
||||||
FilterHash: filter,
|
FilterHash: filter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Paginated(p int) CursorToken {
|
||||||
|
return CursorToken{
|
||||||
|
Mode: CTMPaginated,
|
||||||
|
Timestamp: nil,
|
||||||
|
Page: langext.Ptr(p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CursorToken) Token() string {
|
func (c *CursorToken) Token() string {
|
||||||
if c.Mode == CTMStart {
|
if c.Mode == CTMStart {
|
||||||
return "@start"
|
return "@start"
|
||||||
@@ -69,6 +86,10 @@ func (c *CursorToken) Token() string {
|
|||||||
return "@end"
|
return "@end"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Page != nil {
|
||||||
|
return fmt.Sprintf("$%d", *c.Page)
|
||||||
|
}
|
||||||
|
|
||||||
// We kinda manually implement omitempty for the CursorToken here
|
// We kinda manually implement omitempty for the CursorToken here
|
||||||
// because omitempty does not work for time.Time and otherwise we would always
|
// 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
|
// 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
|
sertok.Id = &c.Id
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Timestamp != 0 {
|
if c.Timestamp != nil && *c.Timestamp != 0 {
|
||||||
sertok.Timestamp = &c.Timestamp
|
sertok.Timestamp = langext.Ptr(*c.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Direction != "" {
|
if c.Direction != "" {
|
||||||
@@ -111,6 +132,14 @@ func Decode(tok string) (CursorToken, error) {
|
|||||||
return End(), nil
|
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_") {
|
if !strings.HasPrefix(tok, "tok_") {
|
||||||
return CursorToken{}, errors.New("could not decode token, missing prefix")
|
return CursorToken{}, errors.New("could not decode token, missing prefix")
|
||||||
}
|
}
|
||||||
@@ -129,7 +158,7 @@ func Decode(tok string) (CursorToken, error) {
|
|||||||
token := CursorToken{Mode: CTMNormal}
|
token := CursorToken{Mode: CTMNormal}
|
||||||
|
|
||||||
if tokenDeserialize.Timestamp != nil {
|
if tokenDeserialize.Timestamp != nil {
|
||||||
token.Timestamp = *tokenDeserialize.Timestamp
|
token.Timestamp = langext.Ptr(*tokenDeserialize.Timestamp)
|
||||||
}
|
}
|
||||||
if tokenDeserialize.Id != nil {
|
if tokenDeserialize.Id != nil {
|
||||||
token.Id = *tokenDeserialize.Id
|
token.Id = *tokenDeserialize.Id
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package primary
|
package primary
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"blackforestbytes.com/simplecloudnotifier/db"
|
"blackforestbytes.com/simplecloudnotifier/db"
|
||||||
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
|
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
|
||||||
"blackforestbytes.com/simplecloudnotifier/models"
|
"blackforestbytes.com/simplecloudnotifier/models"
|
||||||
"errors"
|
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (db *Database) GetMessageByUserMessageID(ctx db.TxContext, usrMsgId string) (*models.Message, error) {
|
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
|
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()
|
filterCond, filterJoin, prepParams, err := filter.SQL()
|
||||||
|
|
||||||
orderClause := ""
|
pageCond := "1=1"
|
||||||
|
|
||||||
|
limitCond := ""
|
||||||
if pageSize != nil {
|
if pageSize != nil {
|
||||||
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC LIMIT :lim"
|
limitCond = "LIMIT :lim"
|
||||||
prepParams["lim"] = *pageSize + 1
|
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
|
if inTok.Mode == ct.CTMNormal {
|
||||||
sqlQueryCount := "SELECT " + " COUNT(*) AS count FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
|
pageCond = "timestamp_real < :tokts OR (timestamp_real = :tokts AND message_id < :tokid )"
|
||||||
|
|
||||||
prepParams["tokts"] = inTok.Timestamp
|
prepParams["tokts"] = inTok.Timestamp
|
||||||
prepParams["tokid"] = inTok.Id
|
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 {
|
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
|
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
|
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
|
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()
|
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 {
|
if pageSize != nil {
|
||||||
orderClause = "ORDER BY timestamp_created DESC, request_id DESC LIMIT :lim"
|
orderClause = "ORDER BY timestamp_created DESC, request_id DESC LIMIT :lim"
|
||||||
prepParams["lim"] = *pageSize + 1
|
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"
|
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["tokts"] = inTok.Timestamp
|
||||||
prepParams["tokid"] = inTok.Id
|
prepParams["tokid"] = inTok.Id
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
module blackforestbytes.com/simplecloudnotifier
|
module blackforestbytes.com/simplecloudnotifier
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.575
|
git.blackforestbytes.com/BlackForestBytes/goext v0.0.614
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/glebarez/go-sqlite v1.22.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/go-sql-driver/mysql v1.8.1
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
@@ -18,20 +18,22 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // 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/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/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.5.0 // indirect
|
github.com/google/uuid v1.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/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/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
golang.org/x/arch v0.17.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.38.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.32.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
modernc.org/libc v1.37.6 // indirect
|
modernc.org/libc v1.37.6 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.7.2 // 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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.614 h1:LMjy2iHEPHRfLCXhguG9mQ+cgRypy0o2OAKztzZXmqk=
|
||||||
git.blackforestbytes.com/BlackForestBytes/goext v0.0.575/go.mod h1:Rj+bq1jLkgvXYe2sthg5UtXHf22nFvmTLeo+54fbYq8=
|
git.blackforestbytes.com/BlackForestBytes/goext v0.0.614/go.mod h1:LTkvxOvjXqkfxjxMOCD+qUlQ6Cu5uPRXQ0rZodFl7Rw=
|
||||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
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=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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/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 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
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.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.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.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.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.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.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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:3TdGTiHZCPqBdTvFbQZQN/TRZzKF3KWw2rFEyKz3YqA=
|
||||||
github.com/viney-shih/go-lock v1.1.2/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8=
|
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 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
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.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
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 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
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 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
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=
|
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.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
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-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.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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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/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-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-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.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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
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-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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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-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.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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
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-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.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/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=
|
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:12APklfJKuGszqZsrArW5QoQh03/W+qyCCjvnDuS6Tw=
|
||||||
gopkg.in/loremipsum.v1 v1.1.2/go.mod h1:TuRvzFuzuejXj+odBU6Tubp/EPUyGb9wmSvHenyP2Ts=
|
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/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 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
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)
|
||||||
|
}
|
||||||
|
|||||||
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" (click)="viewChannel(channel)">
|
||||||
|
<td>
|
||||||
|
<div class="channel-name">{{ channel.display_name }}</div>
|
||||||
|
@if (channel.description_name) {
|
||||||
|
<div class="channel-description">{{ channel.description_name }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono">{{ channel.internal_name }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ getOwnerDisplayName(channel.owner_user_id) }}</td>
|
||||||
|
<td>
|
||||||
|
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
|
||||||
|
{{ getSubscriptionStatus(channel).label }}
|
||||||
|
</nz-tag>
|
||||||
|
</td>
|
||||||
|
<td>{{ channel.messages_sent }}</td>
|
||||||
|
<td>
|
||||||
|
@if (channel.timestamp_lastsent) {
|
||||||
|
<span nz-tooltip [nzTooltipTitle]="channel.timestamp_lastsent">
|
||||||
|
{{ channel.timestamp_lastsent | relativeTime }}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</nz-table>
|
||||||
|
</nz-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
|
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
|
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||||
|
import { NzBadgeModule } from 'ng-zorro-antd/badge';
|
||||||
|
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||||
|
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||||
|
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||||
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||||
|
import { ChannelWithSubscription } from '../../../core/models';
|
||||||
|
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-channel-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NzTableModule,
|
||||||
|
NzButtonModule,
|
||||||
|
NzIconModule,
|
||||||
|
NzTagModule,
|
||||||
|
NzBadgeModule,
|
||||||
|
NzEmptyModule,
|
||||||
|
NzCardModule,
|
||||||
|
NzToolTipModule,
|
||||||
|
RelativeTimePipe,
|
||||||
|
],
|
||||||
|
templateUrl: './channel-list.component.html',
|
||||||
|
styleUrl: './channel-list.component.scss'
|
||||||
|
})
|
||||||
|
export class ChannelListComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private userCacheService = inject(UserCacheService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
channels = signal<ChannelWithSubscription[]>([]);
|
||||||
|
ownerNames = signal<Map<string, ResolvedUser>>(new Map());
|
||||||
|
loading = signal(false);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChannels(): void {
|
||||||
|
const userId = this.authService.getUserId();
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getChannels(userId, 'all_any').subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.channels.set(response.channels);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.resolveOwnerNames(response.channels);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveOwnerNames(channels: ChannelWithSubscription[]): void {
|
||||||
|
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
|
||||||
|
for (const ownerId of uniqueOwnerIds) {
|
||||||
|
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
|
||||||
|
this.ownerNames.update(map => new Map(map).set(ownerId, resolved));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwnerDisplayName(ownerId: string): string {
|
||||||
|
const resolved = this.ownerNames().get(ownerId);
|
||||||
|
return resolved?.displayName || ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewChannel(channel: ChannelWithSubscription): void {
|
||||||
|
this.router.navigate(['/channels', channel.channel_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriptionStatus(channel: ChannelWithSubscription): { label: string; color: string } {
|
||||||
|
const userId = this.authService.getUserId();
|
||||||
|
|
||||||
|
if (channel.owner_user_id === userId) {
|
||||||
|
if (channel.subscription) {
|
||||||
|
return { label: 'Owned & Subscribed', color: 'green' };
|
||||||
|
}
|
||||||
|
return { label: 'Owned', color: 'blue' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.subscription) {
|
||||||
|
if (channel.subscription.confirmed) {
|
||||||
|
return { label: 'Subscribed', color: 'green' };
|
||||||
|
}
|
||||||
|
return { label: 'Pending', color: 'orange' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: 'Not Subscribed', color: 'default' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<div class="page-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Clients</h2>
|
||||||
|
<button nz-button (click)="loadClients()">
|
||||||
|
<span nz-icon nzType="reload"></span>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nz-card>
|
||||||
|
<nz-table
|
||||||
|
#clientTable
|
||||||
|
[nzData]="clients()"
|
||||||
|
[nzLoading]="loading()"
|
||||||
|
[nzShowPagination]="false"
|
||||||
|
[nzNoResult]="noResultTpl"
|
||||||
|
nzSize="middle"
|
||||||
|
>
|
||||||
|
<ng-template #noResultTpl></ng-template>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th nzWidth="5%"></th>
|
||||||
|
<th nzWidth="20%">Name</th>
|
||||||
|
<th nzWidth="15%">Type</th>
|
||||||
|
<th nzWidth="25%">Agent</th>
|
||||||
|
<th nzWidth="20%">Created</th>
|
||||||
|
<th nzWidth="15%">Client ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (client of clients(); track client.client_id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
nz-icon
|
||||||
|
[nzType]="getClientIcon(client.type)"
|
||||||
|
nzTheme="outline"
|
||||||
|
class="client-icon"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<td>{{ client.name || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<nz-tag>{{ getClientTypeLabel(client.type) }}</nz-tag>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="agent-info">
|
||||||
|
<span>{{ client.agent_model }}</span>
|
||||||
|
<span class="agent-version">v{{ client.agent_version }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span nz-tooltip [nzTooltipTitle]="client.timestamp_created">
|
||||||
|
{{ client.timestamp_created | relativeTime }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono client-id">{{ client.client_id }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<nz-empty nzNotFoundContent="No clients registered"></nz-empty>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</nz-table>
|
||||||
|
</nz-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.agent-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
|
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
|
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||||
|
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||||
|
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||||
|
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||||
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
|
||||||
|
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-client-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NzTableModule,
|
||||||
|
NzButtonModule,
|
||||||
|
NzIconModule,
|
||||||
|
NzTagModule,
|
||||||
|
NzEmptyModule,
|
||||||
|
NzCardModule,
|
||||||
|
NzToolTipModule,
|
||||||
|
RelativeTimePipe,
|
||||||
|
],
|
||||||
|
templateUrl: './client-list.component.html',
|
||||||
|
styleUrl: './client-list.component.scss'
|
||||||
|
})
|
||||||
|
export class ClientListComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
|
clients = signal<Client[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadClients(): void {
|
||||||
|
const userId = this.authService.getUserId();
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getClients(userId).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.clients.set(response.clients);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientIcon(type: ClientType): string {
|
||||||
|
return getClientTypeIcon(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientTypeLabel(type: ClientType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ANDROID': return 'Android';
|
||||||
|
case 'IOS': return 'iOS';
|
||||||
|
case 'MACOS': return 'macOS';
|
||||||
|
case 'WINDOWS': return 'Windows';
|
||||||
|
case 'LINUX': return 'Linux';
|
||||||
|
default: return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
6
webapp/src/main.ts
Normal file
6
webapp/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
152
webapp/src/styles.scss
Normal file
152
webapp/src/styles.scss
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
@import "ng-zorro-antd/ng-zorro-antd.min.css";
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global utilities
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.mb-8 { margin-bottom: 8px; }
|
||||||
|
.mb-16 { margin-bottom: 16px; }
|
||||||
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.mt-0 { margin-top: 0; }
|
||||||
|
.mt-8 { margin-top: 8px; }
|
||||||
|
.mt-16 { margin-top: 16px; }
|
||||||
|
.mt-24 { margin-top: 24px; }
|
||||||
|
|
||||||
|
.mr-8 { margin-right: 8px; }
|
||||||
|
.mr-16 { margin-right: 16px; }
|
||||||
|
|
||||||
|
.ml-8 { margin-left: 8px; }
|
||||||
|
.ml-16 { margin-left: 16px; }
|
||||||
|
|
||||||
|
.p-16 { padding: 16px; }
|
||||||
|
.p-24 { padding: 24px; }
|
||||||
|
|
||||||
|
.w-100 { width: 100%; }
|
||||||
|
|
||||||
|
// Monospace for IDs and keys
|
||||||
|
.mono {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card styling
|
||||||
|
.content-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page content wrapper
|
||||||
|
.page-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter bar
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clickable row
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
.status-confirmed {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority colors
|
||||||
|
.priority-high {
|
||||||
|
color: #f5222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-normal {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-low {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state wrapper
|
||||||
|
.empty-wrapper {
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons group
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail page header
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR code display
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 256px;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nz-card {
|
||||||
|
border: 1px solid #CCC !important;
|
||||||
|
box-shadow: 0 0 6px #CCC;
|
||||||
|
|
||||||
|
> .ant-card-head {
|
||||||
|
border-bottom: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> .ant-card-body {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
webapp/tsconfig.app.json
Normal file
15
webapp/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
webapp/tsconfig.json
Normal file
27
webapp/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
15
webapp/tsconfig.spec.json
Normal file
15
webapp/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user