From 8aea1f624cabc61a6d263b18cfd24037cdee2364 Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:26:56 +0300 Subject: [PATCH 1/9] feat(chats): align EditChat form + add per-channel Remove buttons Fix two rough edges in the unified EditChat form shipped in #1262: - EnableMessages/MessagesDelay inputs used col-4 while every sibling row used col-6, causing visual misalignment. Aligned to col-6. - Only the header "Remove chat" link existed (deletes the whole record). Added per-channel Remove buttons (Telegram/Slack/Mattermost) in the footer next to the existing Send-test buttons. Each clears only its channel; the Chat row, folder bindings, and other channels stay intact. Required backend additions: ChatUpdate gains ClearTelegramBinding / ClearSlackWebhook / ClearMattermostWebhook flags (ApplyUpdate's ?? coalescing prevents null submission from clearing a saved webhook today). Chat.TelegramType and Chat.AuthorizationTime move from init to internal set so ApplyUpdate can null them on clear; ToEntity() already gated the Telegram block on TelegramType.HasValue, so the cleared fields drop on round-trip. Three new NotificationsController endpoints (RemoveTelegramBinding / ClearSlackWebhook / ClearMattermostWebhook) route through ConcurrentStorage.TryUpdate so the Updated event fires and _telegramChatIds index is rekeyed. Closes #1271 Co-Authored-By: Claude Opus 4.7 --- .../Controllers/NotificationsController.cs | 29 +++++++ .../HSMServer/Notifications/Chats/Chat.cs | 21 ++++- .../Notifications/Chats/ChatUpdate.cs | 12 +++ .../Views/Notifications/EditChat.cshtml | 54 +++++++++++- .../products/modify_folder_chat.spec.ts | 54 ++++++++++++ .../Notifications/ChatsManagerTests.cs | 82 +++++++++++++++++++ 6 files changed, 247 insertions(+), 5 deletions(-) diff --git a/src/server/HSMServer/Controllers/NotificationsController.cs b/src/server/HSMServer/Controllers/NotificationsController.cs index 3adf5d046..efcead4fb 100644 --- a/src/server/HSMServer/Controllers/NotificationsController.cs +++ b/src/server/HSMServer/Controllers/NotificationsController.cs @@ -119,6 +119,35 @@ public async Task SendTestSlackMessage([FromQuery] Guid id) return Ok(); } + [HttpPost] + [TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)] + public async Task RemoveTelegramBinding(Guid id) => + await ClearChannel(id, clearTelegram: true); + + [HttpPost] + [TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)] + public async Task ClearSlackWebhook(Guid id) => + await ClearChannel(id, clearSlack: true); + + [HttpPost] + [TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)] + public async Task ClearMattermostWebhook(Guid id) => + await ClearChannel(id, clearMattermost: true); + + + private async Task ClearChannel(Guid id, bool clearTelegram = false, bool clearSlack = false, bool clearMattermost = false) + { + var update = new ChatUpdate + { + Id = id, + ClearTelegramBinding = clearTelegram, + ClearSlackWebhook = clearSlack, + ClearMattermostWebhook = clearMattermost, + }; + + return await ChatsManager.TryUpdate(update) ? Ok() : NotFound(); + } + private ChatFoldersViewModel BuildChatFolders(Chat chat) { diff --git a/src/server/HSMServer/Notifications/Chats/Chat.cs b/src/server/HSMServer/Notifications/Chats/Chat.cs index d5f7d4d3d..1fc1116e2 100644 --- a/src/server/HSMServer/Notifications/Chats/Chat.cs +++ b/src/server/HSMServer/Notifications/Chats/Chat.cs @@ -29,9 +29,13 @@ public sealed class Chat : BaseServerModel // Telegram (optional) public ChatId? TelegramChatId { get; private set; } - public ConnectedChatType? TelegramType { get; init; } + // init would block the ClearTelegramBinding path — ApplyUpdate needs to null these out so + // ToEntity() drops them on the next round-trip. internal set keeps existing object + // initializers in ChatsManager.TryConnect and ChatsManagerTests working; outside this + // assembly the surface is effectively read-only. + public ConnectedChatType? TelegramType { get; internal set; } - public DateTime? AuthorizationTime { get; init; } + public DateTime? AuthorizationTime { get; internal set; } // Slack (optional) @@ -98,6 +102,19 @@ protected override void ApplyUpdate(ChatUpdate update) if (update.TelegramChatId.HasValue) TelegramChatId = new ChatId(update.TelegramChatId.Value); + + if (update.ClearTelegramBinding is true) + { + TelegramChatId = null; + TelegramType = null; + AuthorizationTime = null; + } + + if (update.ClearSlackWebhook is true) + SlackWebhookUrl = null; + + if (update.ClearMattermostWebhook is true) + MattermostWebhookUrl = null; } public override ChatEntity ToEntity() diff --git a/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs b/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs index e7ef27c25..347fc26a7 100644 --- a/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs +++ b/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs @@ -20,5 +20,17 @@ public record ChatUpdate : BaseUpdateRequest // Mattermost (optional) public string MattermostWebhookUrl { get; init; } + + + // Negative-clear flags. Chat.ApplyUpdate uses `?? current` for SlackWebhookUrl / + // MattermostWebhookUrl, so a null update value means "don't change" and submitting an + // empty string through EditChat cannot clear a saved webhook. Telegram binding is even + // stricter — TelegramType / AuthorizationTime are private-set and only mutable here. + // These flags route around both issues by explicitly signalling "set this channel to null". + public bool? ClearTelegramBinding { get; init; } + + public bool? ClearSlackWebhook { get; init; } + + public bool? ClearMattermostWebhook { get; init; } } } diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index 1409c53d7..b4430b0ee 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -31,7 +31,7 @@ - Remove + Remove chat @@ -70,7 +70,7 @@ -
+
@@ -80,7 +80,7 @@ -
+
sec @@ -140,10 +140,16 @@ @if (isTelegramBound) { Send test Telegram message + Remove Telegram } @if (Model.HasSlack) { Send test Slack message + Remove Slack + } + @if (Model.HasMattermost) + { + Remove Mattermost }
@@ -173,6 +179,24 @@ ); } + // Per-channel Remove buttons. Each clears only its channel; the Chat row and the other + // channels stay intact. Form reloads to reflect the post-clear state. + function removeChannel(channelName, actionUrl, confirmText) { + showConfirmationModal( + `Removing ${channelName} from chat '@Model.Name'`, + confirmText, + function () { + $.ajax({ + type: 'POST', + url: actionUrl + `?id=@Model.Id`, + cache: false, + async: true, + success: function () { window.location.reload(); } + }); + } + ); + } + $(function () { $("#sendTestTelegram").off("click").on("click", function () { var chatId = $(this).data("chat-id"); @@ -187,5 +211,29 @@ .done(function () { showToast("Test Slack message sent."); }) .fail(function () { showToast("Failed to send test Slack message."); }); }); + + $("#removeTelegram").off("click").on("click", function () { + removeChannel( + "Telegram binding", + "@Url.Action(nameof(NotificationsController.RemoveTelegramBinding), ViewConstants.NotificationsController)", + `Telegram binding will be removed from chat '@Model.Name'. The chat itself, its folder bindings, and other channels stay intact.` + ); + }); + + $("#removeSlack").off("click").on("click", function () { + removeChannel( + "Slack webhook", + "@Url.Action(nameof(NotificationsController.ClearSlackWebhook), ViewConstants.NotificationsController)", + `Slack webhook will be removed from chat '@Model.Name'. The chat itself and other channels stay intact.` + ); + }); + + $("#removeMattermost").off("click").on("click", function () { + removeChannel( + "Mattermost webhook", + "@Url.Action(nameof(NotificationsController.ClearMattermostWebhook), ViewConstants.NotificationsController)", + `Mattermost webhook will be removed from chat '@Model.Name'. The chat itself and other channels stay intact.` + ); + }); }); diff --git a/src/tests/Autotests/products/modify_folder_chat.spec.ts b/src/tests/Autotests/products/modify_folder_chat.spec.ts index 84fc792de..0f802e333 100644 --- a/src/tests/Autotests/products/modify_folder_chat.spec.ts +++ b/src/tests/Autotests/products/modify_folder_chat.spec.ts @@ -5,6 +5,7 @@ import { uniqueName, cleanup } from '../fixtures.ts'; const folderName = uniqueName('Fldr'); const slackChatName = uniqueName('SlackChat'); +const slackRemoveChatName = uniqueName('SlackRm'); // XSS payload used as chat Name. Cleanup by text still works because Razor default-encodes // @chat.Name into the Configuration/_Chats.cshtml row's first td, so the literal payload text // appears in the DOM. The onerror handler would set window.__xss=1 if it ever executed. @@ -17,6 +18,7 @@ test.afterEach(async ({ browser }) => { try { await login(page, testConfig.admin_user, testConfig.admin_user_password, testConfig.apiUrl); await cleanup.chat(page, slackChatName); + await cleanup.chat(page, slackRemoveChatName); await cleanup.chat(page, xssChatName); await cleanup.folder(page, folderName); } finally { @@ -135,3 +137,55 @@ test('Folder Chats picker renders chat.Name as inert text (XSS lock-down)', asyn await page.getByRole('link', { name: 'Logout' }).click(); await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); }); + + +// Covers issue #1271: per-channel Remove buttons on the EditChat form. The Slack "Remove" button +// must clear only the Slack webhook and leave the Chat row intact. Pre-#1271 the only destructive +// action was the header-level "Remove chat" link, which deletes the whole record. +test('EditChat: per-channel Remove clears Slack webhook without deleting the chat', async ({ page }) => { + const { apiUrl, admin_user, admin_user_password } = testConfig; + + // --- Login --- + await login(page, admin_user, admin_user_password, apiUrl); + + // --- Create a Slack chat via the unified Chats tab --- + await page.getByRole('link', { name: 'Configuration' }).click(); + await page.getByRole('tab', { name: 'Chats' }).click(); + await page.getByRole('link', { name: 'Add new chat' }).click(); + await page.locator('#Name').fill(slackRemoveChatName); + await page.locator('#SlackWebhookUrl').fill('https://hooks.slack.com/services/remove-test'); + await page.getByRole('button', { name: 'Save' }).click(); + + // AddChat POST redirects to Configuration. Reopen the Chats tab and click into EditChat via + // the row's action dropdown (matches the row pattern in _Chats.cshtml:53-86). + await page.getByRole('tab', { name: 'Chats' }).click(); + const chatRow = page.getByRole('row').filter({ hasText: slackRemoveChatName }); + await expect(chatRow).toBeVisible(); + await chatRow.locator('#actionButton').click(); + await page.getByRole('link', { name: 'View/Edit' }).click(); + + // EditChat should show the populated webhook and a "Remove Slack" button alongside the + // existing "Send test Slack message" button. + await expect(page.locator('#SlackWebhookUrl')).toHaveValue('https://hooks.slack.com/services/remove-test'); + await expect(page.locator('#removeSlack')).toBeVisible(); + + // Click Remove Slack → confirmation modal → OK. The AJAX POST hits ClearSlackWebhook and the + // page reloads (EditChat.cshtml removeChannel success handler). + await page.locator('#removeSlack').click(); + await page.getByRole('button', { name: 'OK' }).click(); + + // After reload, the webhook field is empty and the per-channel Remove button is gone + // (HasSlack is now false). The chat still exists — only the webhook was cleared. + await expect(page.locator('#SlackWebhookUrl')).toHaveValue(''); + await expect(page.locator('#removeSlack')).toHaveCount(0); + await expect(page.getByRole('heading', { name: /Edit chat/ })).toBeVisible(); + + // The Chats list must still contain the row — channel clear ≠ chat delete (acceptance #4). + await page.getByRole('link', { name: 'Configuration' }).click(); + await page.getByRole('tab', { name: 'Chats' }).click(); + await expect(page.getByRole('row').filter({ hasText: slackRemoveChatName })).toBeVisible(); + + // --- Logout --- + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); +}); diff --git a/src/tests/HSMServer.Core.Tests/Notifications/ChatsManagerTests.cs b/src/tests/HSMServer.Core.Tests/Notifications/ChatsManagerTests.cs index 44687ece9..dfe48c146 100644 --- a/src/tests/HSMServer.Core.Tests/Notifications/ChatsManagerTests.cs +++ b/src/tests/HSMServer.Core.Tests/Notifications/ChatsManagerTests.cs @@ -144,6 +144,88 @@ public void Chat_BuiltFromPublicCtor_PersistsTelegramFieldsThroughToEntity() Assert.Equal(ConnectedChatType.TelegramPrivate, reloaded.TelegramType); } + // ApplyUpdate uses `?? current` for SlackWebhookUrl, so a null update value means "don't + // change" — submitting an empty webhook through EditChat cannot clear a saved value. The + // ClearSlackWebhook flag routes around that. Pinned because the EditChat "Remove Slack" + // button depends on it (issue #1271). + [Fact] + public async Task TryUpdate_ClearSlackWebhook_FlagSetsFieldToNull() + { + var manager = BuildManager(); + var chat = BuildSlackOnlyChat(); + await manager.TryAdd(chat); + await manager.TryUpdate(new ChatUpdate + { + Id = chat.Id, + SlackWebhookUrl = "https://hooks.slack.com/services/test", + }); + + var cleared = await manager.TryUpdate(new ChatUpdate { Id = chat.Id, ClearSlackWebhook = true }); + + Assert.True(cleared); + Assert.Null(manager[chat.Id].SlackWebhookUrl); + } + + [Fact] + public async Task TryUpdate_ClearMattermostWebhook_FlagSetsFieldToNull() + { + var manager = BuildManager(); + var chat = BuildSlackOnlyChat(); + await manager.TryAdd(chat); + await manager.TryUpdate(new ChatUpdate + { + Id = chat.Id, + MattermostWebhookUrl = "https://mattermost.example/hooks/test", + }); + + var cleared = await manager.TryUpdate(new ChatUpdate { Id = chat.Id, ClearMattermostWebhook = true }); + + Assert.True(cleared); + Assert.Null(manager[chat.Id].MattermostWebhookUrl); + } + + // ClearTelegramBinding must null all three Telegram fields and drop the chat from the + // _telegramChatIds index — otherwise GetChatByChatId would still return the chat and the + // next bot invite for the same Telegram chat would collide with the ghost entry. + [Fact] + public async Task TryUpdate_ClearTelegramBinding_FlagClearsFieldsAndRekeysIndex() + { + const long knownChatId = 42L; + var manager = BuildManager(); + var chat = BuildTelegramChat(knownChatId); + await manager.TryAdd(chat); + + Assert.NotNull(manager.GetChatByChatId(new Telegram.Bot.Types.ChatId(knownChatId))); + + var cleared = await manager.TryUpdate(new ChatUpdate { Id = chat.Id, ClearTelegramBinding = true }); + + Assert.True(cleared); + Assert.Null(manager[chat.Id].TelegramChatId); + Assert.Null(manager[chat.Id].TelegramType); + Assert.Null(manager[chat.Id].AuthorizationTime); + Assert.Null(manager.GetChatByChatId(new Telegram.Bot.Types.ChatId(knownChatId))); + } + + // ToEntity() gates the Telegram block on `TelegramType.HasValue` — so after ClearTelegramBinding + // the entity round-trip must omit TelegramType / TelegramChatId / AuthorizationTime entirely. + // Without this, reloading the chat from storage would resurrect the cleared binding. + [Fact] + public async Task Chat_AfterClearTelegramBinding_ToEntityOmitsTelegramFields() + { + const long knownChatId = 99L; + var manager = BuildManager(); + var chat = BuildTelegramChat(knownChatId); + await manager.TryAdd(chat); + + await manager.TryUpdate(new ChatUpdate { Id = chat.Id, ClearTelegramBinding = true }); + + var entity = manager[chat.Id].ToEntity(); + + Assert.Null(entity.TelegramType); + Assert.Null(entity.TelegramChatId); + Assert.Null(entity.AuthorizationTime); + } + private static ChatsManager BuildManager() { From c6c46ca0ffdc9ef9d5d96d75b47ec5800238b6f1 Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:30:18 +0300 Subject: [PATCH 2/9] refactor(chats): sync stale comment + drop unused data-chat-id (review cycle 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatUpdate.cs comment referenced "private-set" but TelegramType / AuthorizationTime are internal-set after this PR — sync the explanation. - Remove anchors had data-chat-id="@Model.Id" that the JS removeChannel() helper never reads (URL is built inline via Razor @Model.Id). Drop the attribute so the markup doesn't promise a contract the code doesn't honor; sendTest* anchors keep data-chat-id because Telegram Send-test actually needs Model.TelegramChatId (different value). Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Notifications/Chats/ChatUpdate.cs | 2 +- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs b/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs index 347fc26a7..1b438f32a 100644 --- a/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs +++ b/src/server/HSMServer/Notifications/Chats/ChatUpdate.cs @@ -25,7 +25,7 @@ public record ChatUpdate : BaseUpdateRequest // Negative-clear flags. Chat.ApplyUpdate uses `?? current` for SlackWebhookUrl / // MattermostWebhookUrl, so a null update value means "don't change" and submitting an // empty string through EditChat cannot clear a saved webhook. Telegram binding is even - // stricter — TelegramType / AuthorizationTime are private-set and only mutable here. + // stricter — TelegramType / AuthorizationTime are internal-set and only mutable here. // These flags route around both issues by explicitly signalling "set this channel to null". public bool? ClearTelegramBinding { get; init; } diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index b4430b0ee..59a315f71 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -140,16 +140,16 @@ @if (isTelegramBound) { Send test Telegram message - Remove Telegram + Remove Telegram } @if (Model.HasSlack) { Send test Slack message - Remove Slack + Remove Slack } @if (Model.HasMattermost) { - Remove Mattermost + Remove Mattermost }
From 30090d269e143f6dc7e51e9c9c8f77b11f9d3e54 Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:31:16 +0300 Subject: [PATCH 3/9] fix(chats): surface AJAX errors on per-channel Remove (review cycle 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeChannel previously had only a success handler. If ChatsManager.TryUpdate returned false (chat deleted between page render and click) the controller responds NotFound and the AJAX call failed silently — user saw a stale form with no toast. Add error: handler matching the success: option pattern already used by removeChat, mirroring sendTest*'s .fail() toast contract. Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index 59a315f71..37e56fccb 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -191,7 +191,8 @@ url: actionUrl + `?id=@Model.Id`, cache: false, async: true, - success: function () { window.location.reload(); } + success: function () { window.location.reload(); }, + error: function () { showToast(`Failed to remove ${channelName.toLowerCase()} from chat.`); } }); } ); From 93f869d68bf7a7e5b2b2931d8a8eb47a9553df8e Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:32:08 +0300 Subject: [PATCH 4/9] test(autotests): wait for reload before asserting post-clear state (review cycle 3) The Remove-Slack flow ends with window.location.reload(). The previous assertion block ran immediately after the OK click, relying solely on Playwright's auto-waiting to settle the toHaveValue('') check. On a slow local run or under CI load the assertion can race the still-rendering previous DOM (which still has the populated webhook value), causing a flake. Add waitForLoadState('domcontentloaded') between the click and the assertions so the reload is guaranteed to have landed. Co-Authored-By: Claude Opus 4.7 --- src/tests/Autotests/products/modify_folder_chat.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tests/Autotests/products/modify_folder_chat.spec.ts b/src/tests/Autotests/products/modify_folder_chat.spec.ts index 0f802e333..740e849b3 100644 --- a/src/tests/Autotests/products/modify_folder_chat.spec.ts +++ b/src/tests/Autotests/products/modify_folder_chat.spec.ts @@ -170,9 +170,12 @@ test('EditChat: per-channel Remove clears Slack webhook without deleting the cha await expect(page.locator('#removeSlack')).toBeVisible(); // Click Remove Slack → confirmation modal → OK. The AJAX POST hits ClearSlackWebhook and the - // page reloads (EditChat.cshtml removeChannel success handler). + // page reloads (EditChat.cshtml removeChannel success handler). Wait for the reload to land + // before asserting — otherwise the auto-waiting toHaveValue('') can race against the + // still-rendering previous DOM and flake. await page.locator('#removeSlack').click(); await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForLoadState('domcontentloaded'); // After reload, the webhook field is empty and the per-channel Remove button is gone // (HasSlack is now false). The chat still exists — only the webhook was cleared. From fc640113a627ceb6196e8d0840aff238a1ed6c9f Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:45:01 +0300 Subject: [PATCH 5/9] fix(chats): align EnableMessages/MessagesDelay inputs (real cause of #1271 alignment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix (col-4 → col-6) equalised the column width but left the inputs visually shifted ~12px left of Name/Description/SlackWebhookUrl/MattermostWebhookUrl: - EnableMessages: the col-6 div also carried `form-check form-switch`, and Bootstrap's .form-switch rule overrides the .col-6 padding-left to 2.5em. The .form-check-input margin-left: -2.5em cancels the padding shift, so the checkbox visually sits at col-6 outer-left instead of col-6 outer-left + 0.75rem (the default col padding every other input gets). - MessagesDelay: the col-6 div had `p-0`, removing the default 0.75rem col padding. The input-group hugged the left edge of the column. Fix: wrap both .form-check and .input-group in a plain inner
, so the col-6 keeps its 0.75rem padding (matching every sibling row) and the form-switch / input-group styles stay scoped to the inner element. Net visual result: EnableMessages switch and MessagesDelay input left edges line up with Name / Description / Slack / Mattermost inputs. Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index 37e56fccb..a832a3698 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -70,8 +70,10 @@
-
- +
+
+ +
@@ -80,7 +82,7 @@
-
+
sec From 78a2cdfb37e5867d1d853d4d1a2bc5c262a79f80 Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:53:20 +0300 Subject: [PATCH 6/9] refactor(chats): group channel UI under Telegram/Slack/Mattermost tabs in EditChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-channel Remove buttons in PR #1272. The form was linear: 7 stacked rows (Name / Description / EnableMessages / MessagesDelay / Telegram status / Slack webhook / Mattermost webhook), with channel-specific Send-test + Remove buttons clustered in a footer group. As the three channels became first-class, the linear layout scattered each channel's input, status, and actions across different visual groups. Now three Bootstrap nav-tabs (Telegram / Slack / Mattermost) sit between MessagesDelay and Connected Folders. Each tab pane owns its channel's full UI — status badge or setup-help link + Send-test + Remove for Telegram, webhook input + Send-test + Remove for Slack, webhook input + Remove for Mattermost. Footer is reduced to Save only. Active-tab rule (in Razor at top of the view): - isTelegramBound → telegram - HasSlack || isNewChat → slack (new chats can't fill Telegram from the form) - HasMattermost → mattermost - fallback → telegram (shows the bot-invite setup help) This makes the Slack-create Playwright path work without a manual tab click on both AddChat GET (isNewChat → slack) and on EditChat reopen for a Slack-only chat (HasSlack → slack). Pattern follows Configuration/Index.cshtml:17-66 and Folders/EditFolder.cshtml (button + data-bs-toggle="tab" + data-bs-target, not anchor-based). JS handlers from cycle 2 of PR #1272 are unchanged — the sendTest* / remove* anchor IDs are preserved verbatim inside their respective tab panes. Backend untouched: NotificationsController.RemoveTelegramBinding / ClearSlackWebhook / ClearMattermostWebhook endpoints and ChatUpdate Clear* flags from PR #1272 are reused as-is. Co-Authored-By: Claude Opus 4.7 --- .../Views/Notifications/EditChat.cshtml | 154 ++++++++++++------ 1 file changed, 100 insertions(+), 54 deletions(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index a832a3698..e3ced36e7 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -16,6 +16,14 @@ var icons = Model.ChatBrandIcons(); var isNewChat = Model.Id == Guid.Empty; var formAction = isNewChat ? nameof(NotificationsController.AddChat) : nameof(NotificationsController.EditChat); + + // First configured channel wins; for a brand-new chat we land on Slack because Telegram can + // only be added through the bot-invite flow (Show setup help), so Slack is the only fillable + // field. An existing chat with no channels configured falls back to Telegram (shows the help). + var defaultTab = isTelegramBound ? "telegram" + : (Model.HasSlack || isNewChat) ? "slack" + : Model.HasMattermost ? "mattermost" + : "telegram"; } @@ -90,69 +98,107 @@
- @if (isTelegramBound) - { -
- -
- - - @Model.TelegramType?.GetDisplayName() - - ChatId: @Model.TelegramChatId - Connected: @Model.AuthorizationTime?.ToLocalTime().ToString("g") -
-
- } - else - { - @await Html.PartialAsync("_NewChatHelpModal.cshtml", Model.Id) -
- - -
- } + -
-
- -
-
- +
+
+ @if (isTelegramBound) + { +
+ +
+ + + @Model.TelegramType?.GetDisplayName() + + ChatId: @Model.TelegramChatId + Connected: @Model.AuthorizationTime?.ToLocalTime().ToString("g") +
+
+ + } + else + { + @await Html.PartialAsync("_NewChatHelpModal.cshtml", Model.Id) +
+ + +
+ }
-
-
-
- +
+
+
+ +
+
+ +
+
+ @if (Model.HasSlack) + { + + }
-
- - Mattermost delivery is not yet implemented. + +
+
+
+ +
+
+ + Mattermost delivery is not yet implemented. +
+
+ @if (Model.HasMattermost) + { + + }
-
- @if (isTelegramBound) - { - Send test Telegram message - Remove Telegram - } - @if (Model.HasSlack) - { - Send test Slack message - Remove Slack - } - @if (Model.HasMattermost) - { - Remove Mattermost - } +
From 0cf5fe559cd79d66379548044aa035dd0fcf6917 Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 17:59:01 +0300 Subject: [PATCH 7/9] refactor(chats): use fab prefix consistently for tab icons (review cycle 1) Slack/Mattermost tab icons used fa-brands while the sibling _NewChatHelpModal and the rest of the form use fab (the FontAwesome 6 short alias). Normalize to fab so all three brand icons in EditChat match the surrounding markup. Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index e3ced36e7..30fc89b13 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -110,14 +110,14 @@ From aabafe6a64e7863bd623e8f8193d6a90349ccb4b Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 18:00:32 +0300 Subject: [PATCH 8/9] refactor(chats): drop unused id on EditChat tab-content (review cycle 2) channelTabsContent was carried over from early planning but is never referenced from JS, CSS, or another view. Matches the canonical pattern in Configuration/Index.cshtml:40 which leaves tab-content without an id. Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index 30fc89b13..5d0e5b0f1 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -122,7 +122,7 @@ -
+
@if (isTelegramBound) From 3f172b051f51b1705138262d8282aeaa891a0feb Mon Sep 17 00:00:00 2001 From: Siarhei Hanich Date: Fri, 17 Jul 2026 18:04:06 +0300 Subject: [PATCH 9/9] refactor(chats): land on Slack tab after clearing Slack webhook (review cycle 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fallback chain returned "telegram" when an existing chat had no configured channels, which is exactly the state right after ClearSlackWebhook or ClearTelegramBinding. The user just acted on Slack/Telegram and lands on the *other* channel's tab — confusing. Restructure the defaultTab ternary so the Telegram tab only opens for Telegram-bound chats, Mattermost only wins when it's the sole configured channel, and Slack is the fallback in all other cases (new chat, post-clear, or chat with neither channel set). Co-Authored-By: Claude Opus 4.7 --- src/server/HSMServer/Views/Notifications/EditChat.cshtml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/server/HSMServer/Views/Notifications/EditChat.cshtml b/src/server/HSMServer/Views/Notifications/EditChat.cshtml index 5d0e5b0f1..f347d18fe 100644 --- a/src/server/HSMServer/Views/Notifications/EditChat.cshtml +++ b/src/server/HSMServer/Views/Notifications/EditChat.cshtml @@ -19,11 +19,12 @@ // First configured channel wins; for a brand-new chat we land on Slack because Telegram can // only be added through the bot-invite flow (Show setup help), so Slack is the only fillable - // field. An existing chat with no channels configured falls back to Telegram (shows the help). + // field. An existing chat with no channels configured (e.g. right after ClearSlackWebhook) + // also falls back to Slack — landing on Telegram would surprise the user, who just acted on + // the Slack webhook. Mattermost only wins if it's the sole configured channel. var defaultTab = isTelegramBound ? "telegram" - : (Model.HasSlack || isNewChat) ? "slack" - : Model.HasMattermost ? "mattermost" - : "telegram"; + : Model.HasMattermost && !Model.HasSlack ? "mattermost" + : "slack"; }