From 3f43130f14624b7c62f7532061eca0b5b9200ba4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:47:43 -0500 Subject: [PATCH 1/4] feat(permissions): slack.* permissions, body masking, token-free API/ESB/Quick Search lists Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackIntegrations.java | 96 ++++++++++++++ .../qbits/slack/metadata/SlackTables.java | 11 +- .../permissions/SlackPermissionChecker.java | 120 ++++++++++++++++++ .../slack/permissions/SlackPermissions.java | 45 +++++++ .../qbits/slack/security/SlackSecurity.java | 33 ++++- .../qbits/slack/support/SlackStore.java | 5 + 6 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/slack/SlackIntegrations.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissions.java diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackIntegrations.java b/src/main/java/com/kingsrook/qbits/slack/SlackIntegrations.java new file mode 100644 index 0000000..61fbb56 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/SlackIntegrations.java @@ -0,0 +1,96 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack; + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + +/******************************************************************************* + ** Opt-in host ESB, API and Quick Search wiring, with explicit safe projections. + ** Hosts must retain the table permissions and record locks in each integration. + *******************************************************************************/ +public final class SlackIntegrations +{ + /******************************************************************************* + ** Quick Search metadata contains no message body or credentials. + *******************************************************************************/ + public record SearchableTable(String tableName, Map fieldWeights, String basepullTimestamp, String recordLabelFormat, List recordLabelFields) + { + } + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackIntegrations() + { + } + + /******************************************************************************* + ** Main tables publish insert, update and delete events with safe fields. + *******************************************************************************/ + public static List esbTableNames() + { + return (SlackTables.MAIN); + } + + /******************************************************************************* + ** Main tables exposed under normal viewer permissions. + *******************************************************************************/ + public static List apiTableNames() + { + return (SlackTables.MAIN); + } + + /******************************************************************************* + ** Explicitly exclude both token columns, even if a host unhides them. + *******************************************************************************/ + public static List apiFieldNames(QInstance instance, String table) + { + if(!SlackTables.MAIN.contains(table)) + { + return (List.of()); + } + return (instance.getTable(table).getFields().keySet().stream() + .filter(name -> !SlackTables.BOT_TOKEN.equals(name) && !SlackTables.USER_TOKEN.equals(name)) + .filter(name -> !name.toLowerCase(java.util.Locale.ROOT).contains("token")) + .filter(name -> !instance.getTable(table).getField(name).getIsHidden()).toList()); + } + + /******************************************************************************* + ** ESB must not broadcast credentials or permission-protected message bodies. + *******************************************************************************/ + public static List esbFieldNames(QInstance instance, String table) + { + return (apiFieldNames(instance, table).stream().filter(name -> !List.of("text", "blocksJson", "variablesSnapshot").contains(name)).toList()); + } + + /******************************************************************************* + ** Safe, viewer-scoped search on messages and connections. + *******************************************************************************/ + public static List quickSearchTables() + { + return (List.of( + new SearchableTable(SlackTables.MESSAGE, Map.of("id", 10, "status", 4, "channelLabel", 8), "modifyDate", "%s", List.of("id")), + new SearchableTable(SlackTables.CONNECTION, Map.of("slackUserName", 10, "status", 4), "modifyDate", "%s", List.of("slackUserName")))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java index 590f99c..4f8cde5 100644 --- a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java @@ -71,6 +71,8 @@ public final class SlackTables public static final String MESSAGE = "slack_message"; public static final String ATTEMPT = "slack_message_attempt"; + public static final List MAIN = List.of(MESSAGE, CONNECTION, WORKSPACE, CHANNEL, USER_GROUP, TEMPLATE); + public static final List ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT); ///////////////////////////////////////////////////////////////////////////// @@ -105,7 +107,8 @@ public final class SlackTables *******************************************************************************/ public static QPermissionRules permissionRules() { - return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack")); + return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack") + .withCustomPermissionChecker(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.permissions.SlackPermissionChecker.class))); } @@ -448,9 +451,9 @@ public final class SlackTables .withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_message_status").withDefaultValue("QUEUED").withMaxLength(500)) .withField(field("category", QFieldType.STRING).withMaxLength(500)) .withField(field("templateName", QFieldType.STRING).withMaxLength(500)) - .withField(field("text", QFieldType.TEXT).withIsSensitive(true)) - .withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true)) - .withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true)) + .withField(field("text", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock())) + .withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock())) + .withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock())) .withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500)) .withField(field("parentMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE)) .withField(field("threadTs", QFieldType.STRING).withMaxLength(500)) diff --git a/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java new file mode 100644 index 0000000..0eb2b03 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java @@ -0,0 +1,120 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.permissions; + +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Table reads retain owner and tenant locks; mutations use explicit actions. + *******************************************************************************/ +public class SlackPermissionChecker implements CustomPermissionChecker +{ + private static final ThreadLocal WORKSPACE_LOOKUP = ThreadLocal.withInitial(() -> false); + + /******************************************************************************* + ** Internal workspace resolution retains viewer locks without granting access + ** to the administrative workspace table in the UI or host API. + *******************************************************************************/ + public static T workspaceLookup(com.kingsrook.qbits.slack.support.SlackStore.Work work) throws com.kingsrook.qqq.backend.core.exceptions.QException + { + boolean previous = WORKSPACE_LOOKUP.get(); + try + { + WORKSPACE_LOOKUP.set(true); + return (work.run()); + } + finally + { + if(previous) + { + WORKSPACE_LOOKUP.set(true); + } + else + { + WORKSPACE_LOOKUP.remove(); + } + } + } + + /******************************************************************************* + ** Authorize metadata and refresh the permission-derived body field key. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession) + { + return; + } + SlackSecurity.applyBodyPermission(QContext.getQSession()); + String needed = SlackPermissions.VIEW; + if(metadata instanceof QTableMetaData workspace && SlackTables.WORKSPACE.equals(workspace.getName()) && !WORKSPACE_LOOKUP.get()) + { + needed = SlackPermissions.ADMIN; + } + if(metadata instanceof QTableMetaData table && (input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput)) + { + if(SlackTables.SYSTEM_WRITTEN.contains(table.getName()) || SlackTables.CONNECTION.equals(table.getName()) || SlackTables.WORKSPACE.equals(table.getName()) && input instanceof InsertInput) + { + throw (new QPermissionDeniedException("Use a Slack record action to change this record.")); + } + needed = SlackPermissions.ADMIN; + } + if(metadata instanceof QProcessMetaData process) + { + needed = switch(process.getName()) + { + case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> SlackPermissions.CONNECT; + case SlackProcesses.INSTALL -> SlackPermissions.INSTALL; + case SlackProcesses.TEST_MESSAGE -> SlackPermissions.SEND; + default -> SlackPermissions.ADMIN; + }; + if(SlackProcesses.DISCONNECT.equals(process.getName()) && has(SlackPermissions.ADMIN)) + { + return; + } + } + if(!has(needed)) + { + throw (new QPermissionDeniedException("This needs the " + needed + " permission.")); + } + } + + /******************************************************************************* + ** Permissions are independent; an administrator still needs a body grant. + *******************************************************************************/ + public static boolean has(String permission) + { + return (QContext.getQSession() != null && QContext.getQSession().hasPermission(permission)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissions.java b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissions.java new file mode 100644 index 0000000..ff3ad88 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissions.java @@ -0,0 +1,45 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.permissions; + +import java.util.List; + +/******************************************************************************* + ** Explicit Slack permission names; body and raw blocks are separate grants. + *******************************************************************************/ +public final class SlackPermissions +{ + public static final String VIEW = "slack.view"; + public static final String CONNECT = "slack.connect"; + public static final String INSTALL = "slack.workspace.install"; + public static final String ADMIN = "slack.admin"; + public static final String SEND = "slack.send"; + public static final String BLOCKS = "slack.send.blocks"; + public static final String BODY = "slack.message.body"; + public static final List ALL = List.of(VIEW, CONNECT, INSTALL, ADMIN, SEND, BLOCKS, BODY); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackPermissions() + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java index c580a2a..31d6431 100644 --- a/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackSecurity.java @@ -49,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSession; *******************************************************************************/ public final class SlackSecurity { + public static final String KEY_BODY_ACCESS = "slack_body_access"; public static final String KEY_USER = "slack_user"; public static final String KEY_USER_ALL_ACCESS = "slack_user_all_access"; public static final String KEY_TOKEN_ACCESS = "slack_token_access"; @@ -77,7 +78,8 @@ public final class SlackSecurity { return (List.of( new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS), - new QSecurityKeyType().withName(KEY_TOKEN_ACCESS))); + new QSecurityKeyType().withName(KEY_TOKEN_ACCESS), + new QSecurityKeyType().withName(KEY_BODY_ACCESS))); } @@ -132,6 +134,7 @@ public final class SlackSecurity return; } + applyBodyPermission(session); if(session.getSecurityKeyValues() != null) { session.getSecurityKeyValues().remove(KEY_USER); @@ -150,6 +153,34 @@ public final class SlackSecurity + /******************************************************************************* + ** RC.6 field locks use security keys; derive this key from the body grant. + *******************************************************************************/ + public static void applyBodyPermission(QSession session) + { + if(session == null) + { + return; + } + if(session.getSecurityKeyValues() != null) + { + session.getSecurityKeyValues().remove(KEY_BODY_ACCESS); + } + if(session.hasPermission("slack.message.body")) + { + session.withSecurityKeyValue(KEY_BODY_ACCESS, true); + } + } + + /******************************************************************************* + ** Hide body values and prevent filtering, sorting or exporting without access. + *******************************************************************************/ + public static FieldSecurityLock bodyFieldLock() + { + return (new FieldSecurityLock().withSecurityKeyType(KEY_BODY_ACCESS) + .withDefaultBehavior(FieldSecurityLock.Behavior.DENY).withOverrideValues(List.of(true))); + } + /******************************************************************************* ** The value the session holds for the owner key, or null. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java b/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java index 456f2e6..2b6fec4 100644 --- a/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java +++ b/src/main/java/com/kingsrook/qbits/slack/support/SlackStore.java @@ -125,6 +125,11 @@ public final class SlackStore *******************************************************************************/ public static List queryAsCurrent(String table, QQueryFilter filter) throws QException { + if(com.kingsrook.qbits.slack.metadata.SlackTables.WORKSPACE.equals(table)) + { + return (com.kingsrook.qbits.slack.permissions.SlackPermissionChecker.workspaceLookup( + () -> new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords())); + } return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()); } -- 2.54.0 From 6f81857cbe9af60f08086836bd515e227bc54e52 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:47:43 -0500 Subject: [PATCH 2/4] feat(preferences): Slack preferences section adapter and Connections record actions Registers only when core user preferences exist: STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8). Connect, Disconnect, Send me a test message and Install for my organization also work today as record actions on Connections. Co-Authored-By: Claude Opus 5.5 --- .../slack/preferences/SlackPreferences.java | 110 ++++++++++++++++++ .../preferences/SlackPreferencesAdapter.java | 66 +++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java new file mode 100644 index 0000000..6e21583 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java @@ -0,0 +1,110 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.preferences; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Core-independent Slack preference section and the services behind its actions. + *******************************************************************************/ +public class SlackPreferences +{ + /******************************************************************************* + ** Read-only content and process references, shared with Connections today. + *******************************************************************************/ + public record Section(String name, String label, boolean readOnly, List connections, List actions) + { + } + + /******************************************************************************* + ** Explicit owner filter keeps an admin's preference list personal too. + *******************************************************************************/ + public Section section() throws QException + { + List connections = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION) + .withFilter(SlackStore.equal("userId", owner())).withFieldNamesToInclude(Set.of("id", "workspaceId", "status", "connectedAt"))).getRecords(); + List actions = new ArrayList<>(); + if(SlackPermissionChecker.has(SlackPermissions.CONNECT)) + { + actions.add(SlackProcesses.CONNECT); + actions.add(SlackProcesses.DISCONNECT); + } + if(SlackPermissionChecker.has(SlackPermissions.SEND)) + { + actions.add(SlackProcesses.TEST_MESSAGE); + } + if(SlackPermissionChecker.has(SlackPermissions.INSTALL) && SlackPermissionChecker.has(SlackPermissions.CONNECT)) + { + actions.add(SlackProcesses.INSTALL); + } + return (new Section("slack", "Slack", true, connections, List.copyOf(actions))); + } + + /******************************************************************************* + ** Start signed, single-use OAuth for the current user only. + *******************************************************************************/ + public static String connect(boolean install) + { + return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user")); + } + + /******************************************************************************* + ** Queue a DM through the service after reloading the selected owner connection. + *******************************************************************************/ + public static SlackSendResult testMessage(Integer connectionId) throws QException + { + QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, connectionId); + if(!owner().equals(connection.getValueString("userId"))) + { + throw (new QException("Choose your own Slack connection for a test message.")); + } + return (SlackService.enqueue(new SlackSendRequest().withOwnerUserId(owner()).withTo("me") + .withWorkspaceId(connection.getValueInteger("workspaceId")).withText("Your Slack connection is working. This is a test message from QQQ."))); + } + + /******************************************************************************* + ** Preference actions always belong to a signed-in human user. + *******************************************************************************/ + private static String owner() + { + if(QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getIdReference() == null) + { + throw (new IllegalStateException("Sign in to manage Slack preferences.")); + } + return (QContext.getQSession().getUser().getIdReference()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java new file mode 100644 index 0000000..472f5be --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java @@ -0,0 +1,66 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.preferences; + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; + +/******************************************************************************* + ** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8) + ** RC.6 has no preferences SPI. Keep its anticipated registration boundary here, + ** and fail explicitly on an incompatible future SPI rather than silently losing + ** the section. Connections actions remain available on RC.6. + *******************************************************************************/ +public final class SlackPreferencesAdapter +{ + public static final String CORE_TYPE = "com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackPreferencesAdapter() + { + } + + /******************************************************************************* + ** No linkage to missing core classes in the RC.6 artifact. + *******************************************************************************/ + public static boolean register(QInstance instance) throws QException + { + try + { + Class type = Class.forName(CORE_TYPE); + Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance); + type.getMethod("withSection", String.class, String.class, QCodeReference.class) + .invoke(metadata, "slack", "Slack", new QCodeReference(SlackPreferences.class)); + return (true); + } + catch(ClassNotFoundException e) + { + return (false); + } + catch(ReflectiveOperationException e) + { + throw (new QException("Update SlackPreferencesAdapter for the installed core preferences SPI.", e)); + } + } +} -- 2.54.0 From 0bb2b463f0421e97e21e6cb2b95f9d4f5d7876cc Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:47:44 -0500 Subject: [PATCH 3/4] feat(app): Slack app, viewer-scoped home dashboard and demo server Nav lists only the six main tables; the home shows the user's connections and deliveries and, for slack.admin, workspace readiness, outbox counts and recent failures, all through owner and tenant locks. Test-scope demo server with the fake Slack server and seeded data for screenshots. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 6 + .../qbits/slack/SlackQBitProducer.java | 3 + .../kingsrook/qbits/slack/app/SlackApp.java | 86 +++++ .../kingsrook/qbits/slack/app/SlackHome.java | 126 +++++++ .../qbits/slack/app/SlackHomeView.java | 144 ++++++++ .../qbits/slack/processes/SlackProcesses.java | 46 ++- .../qbits/slack/readiness/SlackReadiness.java | 13 + .../kingsrook/qbits/slack/SlackAppTest.java | 312 ++++++++++++++++++ .../kingsrook/qbits/slack/SlackH2Support.java | 20 ++ .../qbits/slack/SlackProviderTest.java | 2 +- .../qbits/slack/SlackSecurityTest.java | 6 +- .../qbits/slack/demo/SlackDemoServer.java | 172 ++++++++++ 12 files changed, 930 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/demo/SlackDemoServer.java diff --git a/pom.xml b/pom.xml index 5ce5333..965ca3a 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,12 @@ + + com.kingsrook.qqq + qqq-frontend-next + 1.0.0-RC.19 + test + com.kingsrook.qqq qqq-middleware-javalin diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index ec27632..dfa382e 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -147,6 +147,9 @@ public class SlackQBitProducer implements QBitMetaDataProducer output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName())); output.addSelfToInstance(qInstance); + qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables())); + qInstance.addWidget(com.kingsrook.qbits.slack.app.SlackApp.widget()); + com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance); com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance); try { diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java new file mode 100644 index 0000000..7e19eea --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java @@ -0,0 +1,86 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.app; + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** One full-width home and the six main tables; processes remain record actions. + *******************************************************************************/ +public final class SlackApp +{ + public static final String HOME_WIDGET = "slackHome"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackApp() + { + } + + /******************************************************************************* + ** Navigation in owner-requested order. + *******************************************************************************/ + public static QAppMetaData app(Map tables) + { + QAppMetaData app = new QAppMetaData().withName("slack").withLabel("Slack").withIcon(new QIcon("chat")) + .withPermissionRules(SlackTables.permissionRules()).withWidgets(List.of(HOME_WIDGET)); + SlackTables.MAIN.forEach(name -> app.withChild(tables.get(name))); + return (app); + } + + /******************************************************************************* + ** The only dashboard widget occupies all twelve grid columns. + *******************************************************************************/ + public static QWidgetMetaData widget() + { + return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("My Slack").withType(WidgetType.HTML.getType()) + .withGridColumns(12).withIsCard(false).withShowReloadButton(true) + .withCodeReference(new QCodeReference(HomeRenderer.class)).withPermissionRules(SlackTables.permissionRules())); + } + + /******************************************************************************* + ** Render with the request's current context. + *******************************************************************************/ + public static class HomeRenderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** Viewer-scoped dashboard. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + return (new RenderWidgetOutput(SlackHome.overview())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java new file mode 100644 index 0000000..4c35b8b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java @@ -0,0 +1,126 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.app; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Exact counts and bounded recent lists, always under the viewer's record locks. + *******************************************************************************/ +public final class SlackHome +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackHome() + { + } + + /******************************************************************************* + ** Personal and optional tenant administration data, with no body projections. + *******************************************************************************/ + public static RawHTML overview() throws QException + { + String owner = QContext.getQSession().getUser().getIdReference(); + boolean admin = SlackPermissionChecker.has(SlackPermissions.ADMIN); + Map personal = new LinkedHashMap<>(); + for(var status : SlackEnums.MessageStatus.values()) + { + personal.put(status.getPossibleValueLabel(), count(status.name(), owner, "createDate")); + } + Map outbox = new LinkedHashMap<>(); + if(admin) + { + outbox.put("Queued", count("QUEUED", null, null)); + outbox.put("Retrying", count("RETRY_WAIT", null, null)); + outbox.put("Sent (24 h)", count("SENT", null, "sentDate")); + outbox.put("Dead letters (24 h)", count("DEAD_LETTER", null, "deadLetterDate")); + outbox.put("FAILED_AUTH (24 h)", count("FAILED_AUTH", null, "modifyDate")); + } + List connections = rows(SlackTables.CONNECTION, SlackStore.equal("userId", owner), Set.of("id", "workspaceId", "status", "connectedAt")); + QQueryFilter workspaceFilter = new QQueryFilter(); + if(!admin) + { + workspaceFilter.withCriteria("id", QCriteriaOperator.IN, connections.stream().map(row -> row.getValueInteger("workspaceId")).toList()); + } + List workspaces = !admin && connections.isEmpty() ? List.of() : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, workspaceFilter); + QQueryFilter failures = new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("DEAD_LETTER", "FAILED_AUTH", "RETRY_WAIT")); + return (new RawHTML("My Slack", SlackHomeView.render(personal, connections, messages(SlackStore.equal("ownerUserId", owner)), + workspaces, admin, outbox, admin ? messages(failures) : List.of()))); + } + + /******************************************************************************* + ** Count in core, with an inclusive lower and upper bound for the last day. + *******************************************************************************/ + public static int count(String status, String owner, String dateField) throws QException + { + QQueryFilter filter = SlackStore.equal("status", status); + if(owner != null) + { + filter.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, owner); + } + if(dateField != null) + { + Instant now = SlackRuntime.clock().instant(); + filter.withCriteria(dateField, QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400)); + filter.withCriteria(dateField, QCriteriaOperator.LESS_THAN_OR_EQUALS, now); + } + return (new CountAction().execute(new CountInput(SlackTables.MESSAGE).withFilter(filter)).getCount()); + } + + /******************************************************************************* + ** Last ten messages, stable ordering even for equal timestamps. + *******************************************************************************/ + private static List messages(QQueryFilter filter) throws QException + { + filter.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10); + return (rows(SlackTables.MESSAGE, filter, Set.of("id", "status", "channelLabel", "createDate", "lastError"))); + } + + /******************************************************************************* + ** Never switch to a system context for dashboard queries. + *******************************************************************************/ + private static List rows(String table, QQueryFilter filter, Set fields) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(fields)).getRecords()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java new file mode 100644 index 0000000..533b9c7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java @@ -0,0 +1,144 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.app; + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.readiness.SlackReadiness; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Theme-neutral stacked sections; every record-sourced value is HTML escaped. + *******************************************************************************/ +public final class SlackHomeView +{ + private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;box-sizing:border-box;"; + private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackHomeView() + { + } + + /******************************************************************************* + ** Render personal data first, then tenant administration if permitted. + *******************************************************************************/ + public static String render(Map personal, List connections, List messages, + List workspaces, boolean admin, Map outbox, List failures) + { + StringBuilder html = new StringBuilder("

My Slack

"); + section(html, "My connections"); + if(connections.isEmpty()) + { + html.append("

No connections yet. Use Connect Slack on Connections.

"); + } + for(QRecord row : connections) + { + String workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(row.getValueInteger("workspaceId"))) + .map(w -> w.getValueString("teamName")).findFirst().orElse("Workspace " + row.getValueInteger("workspaceId")); + html.append("

").append(link(SlackTables.CONNECTION, row, workspace)).append(" — ") + .append(esc(row.getValueString("status"))).append(" · Connected: ").append(esc(row.getValueString("connectedAt"))).append("

"); + } + html.append(""); + counts(html, "My deliveries (24 h)", personal); + messages(html, "My recent deliveries", messages); + if(admin) + { + section(html, "Workspace readiness"); + if(workspaces.isEmpty()) + { + html.append("

No workspaces installed.

"); + } + for(QRecord workspace : workspaces) + { + html.append("

").append(link(SlackTables.WORKSPACE, workspace, workspace.getValueString("teamName"))).append("

"); + for(var check : SlackReadiness.workspaceSnapshot(workspace.getValueInteger("id")).checks()) + { + html.append("

").append(esc(check.name())).append(": ").append(esc(check.status())) + .append(" — ").append(esc(check.summary())).append("
Fix: ") + .append(check.name().equals("registeredChannels") ? "Check registered channels, unarchive them and invite the bot." : "Install or reconnect this workspace with the required scopes.") + .append(" Run Check readiness to refresh.

"); + } + } + html.append(""); + counts(html, "Outbox", outbox); + messages(html, "Recent failures", failures); + } + return (html.append("
").toString()); + } + + /******************************************************************************* + ** Counts wrap naturally without fixed-width columns. + *******************************************************************************/ + private static void counts(StringBuilder html, String title, Map counts) + { + section(html, title); + html.append("
"); + counts.forEach((label, count) -> html.append("
").append(count).append(" ").append(esc(label)).append("
")); + html.append("
"); + } + + /******************************************************************************* + ** Linked rows contain delivery metadata, never message bodies. + *******************************************************************************/ + private static void messages(StringBuilder html, String title, List messages) + { + section(html, title); + if(messages.isEmpty()) + { + html.append("

No deliveries.

"); + } + for(QRecord row : messages) + { + html.append("

").append(link(SlackTables.MESSAGE, row, "Message " + row.getValueInteger("id"))) + .append(" — ").append(esc(row.getValueString("status"))).append(" · ").append(esc(row.getValueString("channelLabel"))) + .append("
").append(esc(row.getValueString("createDate"))).append("

"); + } + html.append(""); + } + + /******************************************************************************* + ** A full-width section. + *******************************************************************************/ + private static void section(StringBuilder html, String title) + { + html.append("

").append(esc(title)).append("

"); + } + + /******************************************************************************* + ** Only integer record ids and internal table names enter URLs. + *******************************************************************************/ + private static String link(String table, QRecord row, String label) + { + return ("" + esc(label) + ""); + } + + /******************************************************************************* + ** Escape text and attribute delimiters; absent dates remain explicit. + *******************************************************************************/ + private static String esc(String value) + { + return (value == null ? "Not recorded" : value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java index 68d8931..0e4eb83 100644 --- a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java +++ b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java @@ -57,6 +57,9 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith *******************************************************************************/ public final class SlackProcesses { + public static final String CONNECT = "slack_connect"; + public static final String INSTALL = "slack_install"; + public static final String TEST_MESSAGE = "slack_test_message"; public static final String DISCONNECT = "slack_disconnect"; public static final String UNINSTALL = "slack_uninstall"; public static final String READINESS = "slack_check_readiness"; @@ -96,12 +99,40 @@ public final class SlackProcesses readiness.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) .withRepeatSeconds(config.getReadinessIntervalMinutes() * 60).withInitialDelaySeconds(1)); } - return (List.of(dispatch, readiness, action(DISCONNECT, "Disconnect", SlackTables.CONNECTION), + return (List.of(dispatch, readiness, connectAction(CONNECT, "Connect Slack"), connectAction(INSTALL, "Install for my organization"), + action(TEST_MESSAGE, "Send me a test message", SlackTables.CONNECTION).withMinInputRecords(1).withMaxInputRecords(1), action(DISCONNECT, "Disconnect", SlackTables.CONNECTION), action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE))); } + /******************************************************************************* + ** OAuth starts without requiring an existing connection or selected record. + *******************************************************************************/ + private static QProcessMetaData connectAction(String name, String label) + { + return (new QProcessMetaData().withName(name).withLabel(label).withTableName(SlackTables.CONNECTION) + .withMinInputRecords(0).withPermissionRules(SlackTables.permissionRules()) + .withStep(new QBackendStepMetaData().withName("authorize").withCode(new QCodeReference(ConnectStep.class))) + .withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Connect Slack") + .withViewField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("authorizeUrl", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withLabel("Open this Slack authorization URL")))); + } + + /******************************************************************************* + ** Return the authorize URL through the standard process UI API. + *******************************************************************************/ + public static class ConnectStep implements BackendStep + { + /******************************************************************************* + ** No remote mutation happens until the user approves at Slack. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) + { + output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName()))); + } + } + /******************************************************************************* ** The scheduler's entry point. *******************************************************************************/ @@ -164,7 +195,9 @@ public final class SlackProcesses var session = com.kingsrook.qqq.backend.core.context.QContext.getQSession(); for(QRecord row : input.getRecords()) { - boolean permitted = session.hasPermission("slack.admin") || DISCONNECT.equals(input.getProcessName()) + boolean permitted = TEST_MESSAGE.equals(input.getProcessName()) + ? session.hasPermission("slack.send") && session.getUser().getIdReference().equals(row.getValueString("userId")) + : session.hasPermission("slack.admin") || DISCONNECT.equals(input.getProcessName()) && session.hasPermission("slack.connect") && session.getUser().getIdReference().equals(row.getValueString("userId")); lines.add(new ProcessSummaryLine(permitted ? Status.OK : Status.INFO, 1, permitted ? "Record will be processed" : "Record not permitted")); if(permitted) @@ -201,6 +234,15 @@ public final class SlackProcesses { switch(input.getProcessName()) { + case TEST_MESSAGE -> + { + var result = com.kingsrook.qbits.slack.preferences.SlackPreferences.testMessage(row.getValueInteger("id")); + if(result.status() != com.kingsrook.qbits.slack.outbox.SlackSendResult.Status.QUEUED) + { + throw (new QException("Slack test message could not be queued: " + String.join("; ", result.reasons()))); + } + output.addValue("messageId", result.messageId()); + } case DISCONNECT -> SlackConnect.disconnect(row.getValueInteger("id")); case UNINSTALL -> SlackConnect.uninstall(row.getValueInteger("id")); case READINESS -> diff --git a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java index f173016..952034d 100644 --- a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java +++ b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java @@ -49,6 +49,18 @@ public final class SlackReadiness { private static final Map STORED = Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map> WORKSPACES = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>()); + + /******************************************************************************* + ** Last actual checks, scoped to this instance; never invent a successful check. + *******************************************************************************/ + public static SlackApiReadiness workspaceSnapshot(Integer id) + { + return (WORKSPACES.getOrDefault(QContext.getQInstance(), Map.of()).getOrDefault(id, + new SlackApiReadiness(false, List.of(new SlackApiReadinessCheck("workspace:" + id, "UNKNOWN", "Checks have not run since startup"), + new SlackApiReadinessCheck("registeredChannels", "UNKNOWN", "Checks have not run since startup"))))); + } + /******************************************************************************* ** Utility class. *******************************************************************************/ @@ -146,6 +158,7 @@ public final class SlackReadiness good && !optionalScopes ? "Workspace ready; optional scopes are missing" : "Workspace identity and required scopes")); checks.add(check("registeredChannels", channels, "Enabled channels and bot membership")); SlackApiReadiness result = result(checks); + WORKSPACES.computeIfAbsent(QContext.getQInstance(), key -> new java.util.concurrent.ConcurrentHashMap<>()).put(id, result); SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("readinessStatus", result.ready() ? "OK" : "FAIL") .withValue("readinessCheckedAt", SlackRuntime.clock().instant()).withValue("readinessSummary", result.ready() ? "Workspace ready" : "Workspace needs attention"), null); return (result); diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java new file mode 100644 index 0000000..12687db --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java @@ -0,0 +1,312 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.slack.app.SlackApp; +import com.kingsrook.qbits.slack.app.SlackHome; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.preferences.SlackPreferences; +import com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Home, integrations, preferences and record actions with real locked H2 data. + *******************************************************************************/ +class SlackAppTest +{ + /******************************************************************************* + ** Main navigation contains tables only and exactly one full-width dashboard. + *******************************************************************************/ + @Test + void navigation() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + var app = env.instance().getApp("slack"); + assertThat(app.getLabel()).isEqualTo("Slack"); + assertThat(app.getChildren()).extracting(QAppChildMetaData::getName).containsExactlyElementsOf(SlackTables.MAIN); + assertThat(app.getWidgets()).containsExactly(SlackApp.HOME_WIDGET); + assertThat(env.instance().getWidget(SlackApp.HOME_WIDGET).getGridColumns()).isEqualTo(12); + assertThat(env.instance().getProcess(SlackProcesses.CONNECT).getTableName()).isEqualTo(SlackTables.CONNECTION); + assertThat(env.instance().getProcess(SlackProcesses.TEST_MESSAGE).getTableName()).isEqualTo(SlackTables.CONNECTION); + } + } + + /******************************************************************************* + ** Every main API/ESB table has an explicit projection with no token columns. + *******************************************************************************/ + @Test + void integrationFields() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + assertThat(SlackIntegrations.apiTableNames()).containsExactlyElementsOf(SlackTables.MAIN); + assertThat(SlackIntegrations.esbTableNames()).containsExactlyElementsOf(SlackTables.MAIN); + env.instance().getTable(SlackTables.WORKSPACE).getField(SlackTables.BOT_TOKEN).setIsHidden(false); + env.instance().getTable(SlackTables.CONNECTION).getField(SlackTables.USER_TOKEN).setIsHidden(false); + for(String table : SlackTables.MAIN) + { + assertThat(SlackIntegrations.apiFieldNames(env.instance(), table)).contains("id").noneMatch(f -> f.toLowerCase().contains("token")); + assertThat(SlackIntegrations.esbFieldNames(env.instance(), table)).doesNotContain("text", "blocksJson", "variablesSnapshot"); + } + assertThat(SlackIntegrations.apiFieldNames(env.instance(), SlackTables.ATTEMPT)).isEmpty(); + assertThat(SlackIntegrations.quickSearchTables()).extracting(SlackIntegrations.SearchableTable::tableName) + .containsExactly(SlackTables.MESSAGE, SlackTables.CONNECTION); + } + } + + /******************************************************************************* + ** A personal dashboard cannot see another owner even within the same tenant. + *******************************************************************************/ + @Test + void personalCountsAndLastTen() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(true, c -> + { + })) + { + env.tenant("alice", 1); + env.tenant("bob", 1); + int workspace = env.workspace("T1", 1, "xoxb-demo", "BOT"); + env.connection("alice", workspace, "UA", null); + env.connection("bob", workspace, "UB", null); + int hidden = message(env, workspace, "bob", "SENT", env.clock().instant()); + List ids = new ArrayList<>(); + for(int i = 0; i < 12; i++) + { + ids.add(message(env, workspace, "alice", "SENT", env.clock().instant().minusSeconds(60 - i))); + } + env.asOwner("alice"); + assertThat(SlackHome.count("SENT", "alice", "createDate")).isEqualTo(12); + String html = SlackHome.overview().getHtml(); + assertThat(html).contains("My Slack", "Team T1", "My deliveries (24 h)", "12") + .doesNotContain("Workspace readiness", "/app/slack_message/" + hidden + "\"", "/app/slack_message/" + ids.getFirst() + "\"", "body-canary"); + assertThat(html.split("href=\"/app/slack_message/", -1).length - 1).isEqualTo(10); + assertThat(new SlackApp.HomeRenderer().render(null)).isNotNull(); + } + } + + /******************************************************************************* + ** Tenant admin totals include other owners in their tenant, never tenant two. + *******************************************************************************/ + @Test + void adminTenantCounts() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(true, c -> + { + })) + { + env.tenant("alice", 1); + int one = env.workspace("T1", 1, "xoxb-one", "B1"); + int two = env.workspace("T2", 2, "xoxb-two", "B2"); + message(env, one, "alice", "FAILED_AUTH", env.clock().instant()); + message(env, one, "bob", "FAILED_AUTH", env.clock().instant()); + int hidden = message(env, two, "carol", "FAILED_AUTH", env.clock().instant()); + var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN); + SlackSecurity.applyToSession(session, "alice", true); + assertThat(SlackHome.count("FAILED_AUTH", null, "modifyDate")).isEqualTo(2); + assertThat(SlackHome.count("FAILED_AUTH", "alice", "createDate")).isEqualTo(1); + String html = SlackHome.overview().getHtml(); + assertThat(html).contains("Workspace readiness", "Team T1", "UNKNOWN", "Fix:", "Outbox", "Recent failures") + .doesNotContain("Team T2", "/app/slack_message/" + hidden + "\""); + } + } + + /******************************************************************************* + ** Last-day counts exclude future and old data and include the exact boundary. + *******************************************************************************/ + @Test + void timeBounds() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + int workspace = env.workspace("T", null, "xoxb-demo", "B"); + Instant now = env.clock().instant(); + for(long seconds : List.of(-86401L, -86400L, 0L, 1L)) + { + message(env, workspace, "alice", "SENT", now.plusSeconds(seconds)); + } + env.asOwner("alice"); + assertThat(SlackHome.count("SENT", "alice", "sentDate")).isEqualTo(2); + } + } + + /******************************************************************************* + ** Body locks protect get, query and filter inference, including administrators. + *******************************************************************************/ + @Test + void bodyPermission() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + int workspace = env.workspace("T", null, "xoxb-demo", "B"); + int id = message(env, workspace, "alice", "QUEUED", env.clock().instant()); + var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN); + QRecord row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord(); + assertThat(row.getValues()).doesNotContainKeys("text", "blocksJson", "variablesSnapshot"); + assertThat(new QueryAction().execute(new QueryInput(SlackTables.MESSAGE).withShouldFetchHeavyFields(true)).getRecords().getFirst().getValues()) + .doesNotContainKeys("text", "blocksJson", "variablesSnapshot"); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.MESSAGE).withFilter(SlackStore.equal("text", "body-canary")))) + .hasMessageContaining("Permission denied"); + session.withPermissions(SlackPermissions.BODY); + row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord(); + assertThat(row.getValueString("text")).isEqualTo("body-canary"); + assertThat(row.getValueString("blocksJson")).isEqualTo("[]"); + assertThat(row.getValueString("variablesSnapshot")).isEqualTo("{}"); + session.setPermissions(Set.of(SlackPermissions.VIEW)); + row = new GetAction().execute(new GetInput(SlackTables.MESSAGE).withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord(); + assertThat(row.getValues()).doesNotContainKey("text"); + } + } + + /******************************************************************************* + ** RC.6 skips registration; personal section exposes only permitted actions. + *******************************************************************************/ + @Test + void preferencesAndConnect() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("http://localhost:8099") + .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret"))) + { + assertThat(SlackPreferencesAdapter.register(env.instance())).isFalse(); + int workspace = env.workspace("T", null, "xoxb-demo", "B"); + env.connection("alice", workspace, "UA", null); + env.connection("bob", workspace, "UB", null); + var session = env.asOwner("alice").withPermissions(SlackPermissions.CONNECT); + var section = new SlackPreferences().section(); + assertThat(section.name()).isEqualTo("slack"); + assertThat(section.readOnly()).isTrue(); + assertThat(section.connections()).hasSize(1); + assertThat(section.actions()).contains(SlackProcesses.CONNECT, SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE).doesNotContain(SlackProcesses.INSTALL); + var output = new RunBackendStepOutput(); + new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), output); + assertThat(output.getValueString("authorizeUrl")).startsWith("https://slack.com/oauth/v2/authorize?").contains("state="); + assertThatThrownBy(() -> SlackPreferences.connect(true)).hasMessageContaining("not permitted"); + session.withPermissions(SlackPermissions.INSTALL); + assertThat(new SlackPreferences().section().actions()).contains(SlackProcesses.INSTALL); + new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.INSTALL), output); + assertThat(output.getValueString("authorizeUrl")).contains("scope="); + } + } + + /******************************************************************************* + ** Record action queues one self DM, never posts inline or sends as another owner. + *******************************************************************************/ + @Test + void testMessageAction() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + int workspace = env.workspace("T", null, "xoxb-demo", "B"); + int own = env.connection("alice", workspace, "UA", null); + int other = env.connection("bob", workspace, "UB", null); + var session = env.asOwner("alice").withPermissions(SlackPermissions.ADMIN); + SlackSecurity.applyToSession(session, "alice", true); + var input = new RunBackendStepInput().withProcessName(SlackProcesses.TEST_MESSAGE); + input.setRecords(List.of(new QRecord().withValue("id", own).withValue("userId", "alice"))); + var reviewed = new RunBackendStepOutput().withRecords(new ArrayList<>()); + var transform = new SlackProcesses.ActionTransform(); + transform.runOnePage(input, reviewed); + assertThat(reviewed.getRecords()).hasSize(1); + var output = new RunBackendStepOutput().withRecords(new ArrayList<>()); + new SlackProcesses.ActionLoad().runOnePage(input, output); + QRecord message = SlackStore.get(SlackTables.MESSAGE, output.getValueInteger("messageId")); + assertThat(message.getValueString("status")).isEqualTo("QUEUED"); + assertThat(message.getValueString("ownerUserId")).isEqualTo("alice"); + assertThat(message.getValueString("targetRef")).isEqualTo("me"); + assertThat(env.slack().requests()).isEmpty(); + assertThatThrownBy(() -> SlackPreferences.testMessage(other)).hasMessageContaining("own Slack connection"); + } + } + + /******************************************************************************* + ** Metadata permission rules reject writes and do not accept the old access grant. + *******************************************************************************/ + @Test + void explicitPermissions() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + var session = env.asOwner("alice"); + var checker = new SlackPermissionChecker(); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE))) + .hasMessageContaining("slack.admin"); + assertThat(SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty(); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE))) + .hasMessageContaining("slack.admin"); + assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(SlackTables.CONNECTION), env.instance().getTable(SlackTables.CONNECTION))) + .hasMessageContaining("record action"); + assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(SlackTables.TEMPLATE), env.instance().getTable(SlackTables.TEMPLATE))) + .hasMessageContaining("slack.admin"); + session.withPermissions(SlackPermissions.ADMIN); + checker.checkPermissionsThrowing(new UpdateInput(SlackTables.TEMPLATE), env.instance().getTable(SlackTables.TEMPLATE)); + session.setPermissions(Set.of("slack.hasAccess")); + assertThatThrownBy(() -> SlackHome.overview()).hasMessageContaining("slack.view"); + } + } + + /******************************************************************************* + ** Seed metadata and canary bodies as the system, retaining caller timestamps. + *******************************************************************************/ + private int message(SlackTestEnv env, int workspace, String owner, String status, Instant at) throws Exception + { + env.asSystem(); + int id = SlackH2Support.insert(SlackTables.MESSAGE, new QRecord().withValue("workspaceId", workspace).withValue("ownerUserId", owner) + .withValue("status", status).withValue("createDate", at).withValue("modifyDate", at).withValue("sentDate", at) + .withValue("deadLetterDate", at).withValue("text", "body-canary").withValue("blocksJson", "[]").withValue("variablesSnapshot", "{}")) + .getValueInteger("id"); + SlackH2Support.messageDates(id, at); + return (id); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java index 0fd7970..60b6f3d 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackH2Support.java @@ -170,6 +170,10 @@ public final class SlackH2Support { instance.setEnforcePermissionsInCoreActions(true); QSession session = new QSession().withPermissions("slack.hasAccess", "slack.view"); + if(allUsers) + { + session.withPermissions("slack.admin"); + } SlackSecurity.applyToSession(session, userId, allUsers); if(tenantId != null) { @@ -196,6 +200,22 @@ public final class SlackH2Support + /******************************************************************************* + ** Deterministic fixture dates after core applies its current-time behaviors. + *******************************************************************************/ + public static void messageDates(Integer id, java.time.Instant at) throws Exception + { + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms"); + try(Connection connection = new ConnectionManager().getConnection(backend); + var statement = connection.prepareStatement("UPDATE slack_message SET create_date = ?, modify_date = ? WHERE id = ?")) + { + statement.setTimestamp(1, java.sql.Timestamp.from(at)); + statement.setTimestamp(2, java.sql.Timestamp.from(at)); + statement.setInt(3, id); + statement.executeUpdate(); + } + } + /******************************************************************************* ** Query a table as the current context. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java index 0c752a1..c0b9f8b 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java @@ -103,7 +103,7 @@ class SlackProviderTest @Test void testSendMessageActionQueuesRowForSessionUser() throws Exception { - env.asOwner("userA"); + env.asOwner("userA").withPermissions("slack.message.body"); SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me"))); assertThat(output).isNotNull(); diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java index 470a1fb..77c5190 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackSecurityTest.java @@ -191,8 +191,8 @@ class SlackSecurityTest // the system user's ordinary query does not return them either (hidden fields are omitted by default) assertThat(SlackH2Support.query(SlackTables.WORKSPACE).get(0).getValues()).doesNotContainKey("botToken"); - // a user session: values, fields and metadata - SlackH2Support.asUser(instance, "userA", null, false); + // even an administrator cannot access token values, fields or metadata + SlackH2Support.asUser(instance, "userA", null, false).withPermissions("slack.admin"); for(QRecord record : SlackH2Support.query(SlackTables.WORKSPACE)) { assertThat(record.getValues()).doesNotContainKey("botToken"); @@ -247,7 +247,7 @@ class SlackSecurityTest assertThat(cipher.decrypt(row.getValueString("botToken"), binding)).isEqualTo("xoxb-FAKE-ROUNDTRIP"); assertThatThrownBy(() -> cipher.decrypt(row.getValueString("botToken"), "slack_workspace:" + (workspaceId + 1) + ":1")).isInstanceOf(QException.class); - SlackH2Support.asUser(instance, "userA", null, false); + SlackH2Support.asUser(instance, "userA", null, false).withPermissions("slack.admin"); QRecord asUser = SlackH2Support.query(SlackTables.WORKSPACE).stream().filter(r -> r.getValueInteger("id") == workspaceId).findFirst().orElseThrow(); assertThat(asUser.getValues()).doesNotContainKey("botToken"); assertThat(asUser.getValueString("botTokenFingerprint")).hasSize(8); diff --git a/src/test/java/com/kingsrook/qbits/slack/demo/SlackDemoServer.java b/src/test/java/com/kingsrook/qbits/slack/demo/SlackDemoServer.java new file mode 100644 index 0000000..e530206 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/demo/SlackDemoServer.java @@ -0,0 +1,172 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.demo; + +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import com.kingsrook.qbits.slack.FakeSlackServer; +import com.kingsrook.qbits.slack.SlackH2Support; +import com.kingsrook.qbits.slack.SlackTestEnv; +import com.kingsrook.qbits.slack.metadata.SlackEnums; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.readiness.SlackReadiness; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.template.SlackTemplates; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.FullyAnonymousAuthenticationModule; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; + +/******************************************************************************* + ** Local screenshot server: in-memory H2, fake Slack and Next UI. No live tokens. + ** Start using exec:java with test classpath and this class, optionally a port. + *******************************************************************************/ +public class SlackDemoServer extends AbstractQQQApplication +{ + private final QInstance instance; + + /******************************************************************************* + ** Reuse the seeded instance on every server lifecycle request. + *******************************************************************************/ + public SlackDemoServer(QInstance instance) + { + this.instance = instance; + } + + /******************************************************************************* + ** The demo's already validated instance. + *******************************************************************************/ + @Override + public QInstance defineQInstance() + { + return (instance); + } + + /******************************************************************************* + ** Anonymous access is exclusively for this local screenshot demo. + *******************************************************************************/ + public static class DemoAuthenticationModule extends FullyAnonymousAuthenticationModule + { + /******************************************************************************* + ** A named tenant administrator, with normal owner and tenant locks. + *******************************************************************************/ + @Override + public QSession createSession(QInstance instance, Map context) + { + QSession session = super.createSession(instance, context).withPermissions(SlackPermissions.ALL) + .withUser(new QUser().withIdReference("alex").withFullName("Alex Morgan")) + .withSecurityKeyValue(SlackH2Support.TENANT_KEY, 1); + SlackSecurity.applyToSession(session, "alex", true); + return (session); + } + } + + /******************************************************************************* + ** Realistic fixture states are stable for screenshots; transport stays local. + *******************************************************************************/ + public static void seed(SlackTestEnv env) throws Exception + { + SlackRuntime.setClock(Clock.systemUTC()); + env.tenant("alex", 1); + env.tenant("jordan", 1); + int operations = env.workspace("T_OPERATIONS", 1, "xoxb-demo-operations", "UBOTOPS"); + int community = env.workspace("T_COMMUNITY", 1, "xoxb-demo-community", "UBOTCOMM"); + env.set(SlackTables.WORKSPACE, operations, "teamName", "QRun Operations"); + env.set(SlackTables.WORKSPACE, community, "teamName", "Community Projects"); + int alex = env.connection("alex", operations, "UALEX", "xoxp-demo-alex"); + int second = env.connection("alex", community, "UALEXCOMM", "xoxp-demo-alex-community"); + env.connection("jordan", operations, "UJORDAN", "xoxp-demo-jordan"); + env.set(SlackTables.CONNECTION, alex, "connectedAt", Instant.now().minusSeconds(86400 * 12)); + env.set(SlackTables.CONNECTION, second, "connectedAt", Instant.now().minusSeconds(86400 * 3)); + env.channel(operations, "operations", "COPS", "operations"); + env.channel(community, "project-updates", "CPROJECTS", "project-updates"); + env.userGroup(operations, "on-call", "SONCALL", "COPS"); + env.slack().members("COPS", "UBOTOPS", "UALEX", "UJORDAN"); + env.slack().members("CPROJECTS", "UBOTCOMM", "UALEXCOMM"); + SlackTemplates.seed(); + int sequence = 0; + for(var status : SlackEnums.MessageStatus.values()) + { + for(int i = 0; i < 3; i++) + { + Instant at = Instant.now().minusSeconds(++sequence * 900L); + QRecord message = SlackH2Support.insert(SlackTables.MESSAGE, new QRecord() + .withValue("ownerUserId", i == 2 ? "jordan" : "alex").withValue("workspaceId", operations) + .withValue("connectionId", i == 2 ? null : alex).withValue("postAs", "BOT").withValue("operation", "POST") + .withValue("targetType", "CHANNEL").withValue("targetRef", "channel:" + operations + "/COPS") + .withValue("slackChannelId", "COPS").withValue("channelLabel", "#operations").withValue("status", status.name()) + .withValue("text", "Project update " + sequence + ": the weekly operations report is ready.") + .withValue("createDate", at).withValue("modifyDate", at).withValue("attemptCount", 1) + .withValue("sentDate", status == SlackEnums.MessageStatus.SENT ? at : null) + .withValue("deadLetterDate", status == SlackEnums.MessageStatus.DEAD_LETTER ? at : null) + .withValue("lastError", status == SlackEnums.MessageStatus.FAILED_AUTH ? "Reconnect Slack to restore delivery." : null)); + SlackH2Support.messageDates(message.getValueInteger("id"), at); + SlackH2Support.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", message.getValueInteger("id")) + .withValue("attemptNumber", 1).withValue("outcome", status == SlackEnums.MessageStatus.SENT ? "SENT" : "TRANSIENT_FAILURE") + .withValue("startDate", at).withValue("endDate", at.plusSeconds(1))); + } + } + env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T_OPERATIONS\"}", + Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read,chat:write.customize")), FakeSlackServer.Reply.error("token_revoked")); + env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"is_archived\":false,\"is_member\":true}}"), + FakeSlackServer.Reply.error("not_in_channel")); + SlackReadiness.workspace(operations); + SlackReadiness.workspace(community); + } + + /******************************************************************************* + ** Start the same Javalin/Next wiring as the email screenshot application. + *******************************************************************************/ + public static void main(String[] args) throws Exception + { + int port = args.length == 0 ? 8099 : Integer.parseInt(args[0]); + SlackTestEnv env = SlackTestEnv.create(true, config -> config.withPublicBaseUrl("http://localhost:" + port) + .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? Base64.getEncoder().encodeToString(new byte[32]) : "local-demo-only")); + Runtime.getRuntime().addShutdownHook(new Thread(env::close)); + seed(env); + QInstance instance = env.instance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS) + .withCustomizer(new QCodeReference(SlackTestEnv.TestAuthCustomizer.class))); + instance.setHomeAppName("slack"); + instance.getProcesses().values().forEach(process -> process.setSchedule(null)); + var field = QAuthenticationModuleDispatcher.class.getDeclaredField("authenticationTypeToModuleClassNameMap"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map modules = (Map) field.get(null); + modules.put(QAuthenticationType.FULLY_ANONYMOUS.getName(), DemoAuthenticationModule.class.getName()); + QContext.clear(); + new QApplicationJavalinServer(new SlackDemoServer(instance)).withPort(port).withServeFrontendNext(true).start(); + System.out.println("Slack demo: http://localhost:" + port + "/app/slack"); + } +} -- 2.54.0 From cbac87b0d8c2becd8b4bb80f5eca696d2cfd7c36 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:10:37 -0500 Subject: [PATCH 4/4] fix(home): typed tables and statistic tiles instead of text blocks Connections, recent deliveries, readiness and failures as linked tables; non-zero status tiles linking to filtered Messages; local dates and readable status labels; Fix hints only when not ready; one title. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitProducer.java | 2 +- .../kingsrook/qbits/slack/app/SlackApp.java | 33 +++- .../kingsrook/qbits/slack/app/SlackHome.java | 127 ++++++++++--- .../qbits/slack/app/SlackHomeView.java | 178 +++++++++++------- .../permissions/SlackPermissionChecker.java | 5 + .../kingsrook/qbits/slack/SlackAppTest.java | 114 +++++++++-- 6 files changed, 345 insertions(+), 114 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index dfa382e..b3ed239 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -148,7 +148,7 @@ public class SlackQBitProducer implements QBitMetaDataProducer output.addSelfToInstance(qInstance); qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables())); - qInstance.addWidget(com.kingsrook.qbits.slack.app.SlackApp.widget()); + com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget); com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance); com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance); try diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java index 7e19eea..ffb468a 100644 --- a/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackApp.java @@ -35,11 +35,17 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* - ** One full-width home and the six main tables; processes remain record actions. + ** Full-width typed home widgets; processes remain record actions. *******************************************************************************/ public final class SlackApp { public static final String HOME_WIDGET = "slackHome"; + public static final String DELIVERIES = "slackHomeDeliveries"; + public static final String RECENT = "slackHomeRecent"; + public static final String READINESS = "slackHomeReadiness"; + public static final String OUTBOX = "slackHomeOutbox"; + public static final String FAILURES = "slackHomeFailures"; + public static final List ADMIN_WIDGETS = List.of(READINESS, OUTBOX, FAILURES); /******************************************************************************* ** Utility class. @@ -54,18 +60,31 @@ public final class SlackApp public static QAppMetaData app(Map tables) { QAppMetaData app = new QAppMetaData().withName("slack").withLabel("Slack").withIcon(new QIcon("chat")) - .withPermissionRules(SlackTables.permissionRules()).withWidgets(List.of(HOME_WIDGET)); + .withPermissionRules(SlackTables.permissionRules()).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList()); SlackTables.MAIN.forEach(name -> app.withChild(tables.get(name))); return (app); } /******************************************************************************* - ** The only dashboard widget occupies all twelve grid columns. + ** Each section occupies all twelve grid columns, including on small screens. *******************************************************************************/ - public static QWidgetMetaData widget() + public static List widgets() { - return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("My Slack").withType(WidgetType.HTML.getType()) - .withGridColumns(12).withIsCard(false).withShowReloadButton(true) + return (List.of(widget(HOME_WIDGET, "My connections", WidgetType.TABLE), + widget(DELIVERIES, "My deliveries (24 h)", WidgetType.MULTI_STATISTICS), + widget(RECENT, "My recent deliveries", WidgetType.TABLE), + widget(READINESS, "Workspace readiness", WidgetType.TABLE), + widget(OUTBOX, "Outbox", WidgetType.MULTI_STATISTICS), + widget(FAILURES, "Recent failures", WidgetType.TABLE))); + } + + /******************************************************************************* + ** Labels belong to widget metadata, never duplicated inside their payloads. + *******************************************************************************/ + private static QWidgetMetaData widget(String name, String label, WidgetType type) + { + return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()) + .withGridColumns(12).withIsCard(true).withShowReloadButton(true) .withCodeReference(new QCodeReference(HomeRenderer.class)).withPermissionRules(SlackTables.permissionRules())); } @@ -80,7 +99,7 @@ public final class SlackApp @Override public RenderWidgetOutput render(RenderWidgetInput input) throws QException { - return (new RenderWidgetOutput(SlackHome.overview())); + return (new RenderWidgetOutput(SlackHome.render(input.getWidgetMetaData().getName()))); } } } diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java index 4c35b8b..25b390a 100644 --- a/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackHome.java @@ -21,9 +21,8 @@ package com.kingsrook.qbits.slack.app; import java.time.Instant; -import java.util.LinkedHashMap; +import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Set; import com.kingsrook.qbits.slack.metadata.SlackEnums; import com.kingsrook.qbits.slack.metadata.SlackTables; @@ -35,12 +34,16 @@ import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData; import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* @@ -56,42 +59,96 @@ public final class SlackHome } /******************************************************************************* - ** Personal and optional tenant administration data, with no body projections. + ** Typed sections fetch only their data, with permissions checked on direct calls. *******************************************************************************/ - public static RawHTML overview() throws QException + public static QWidgetData render(String name) throws QException { + String permission = SlackApp.ADMIN_WIDGETS.contains(name) ? SlackPermissions.ADMIN : SlackPermissions.VIEW; + if(!SlackPermissionChecker.has(permission)) + { + throw (new QPermissionDeniedException("This needs the " + permission + " permission.")); + } String owner = QContext.getQSession().getUser().getIdReference(); - boolean admin = SlackPermissionChecker.has(SlackPermissions.ADMIN); - Map personal = new LinkedHashMap<>(); + return (switch(name) + { + case SlackApp.HOME_WIDGET -> connections(owner); + case SlackApp.DELIVERIES -> deliveries(owner); + case SlackApp.RECENT -> SlackHomeView.messages(messages(SlackStore.equal("ownerUserId", owner)), SlackStore.equal("ownerUserId", owner)); + case SlackApp.READINESS -> SlackHomeView.readiness(SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter())); + case SlackApp.OUTBOX -> outbox(); + case SlackApp.FAILURES -> SlackHomeView.messages(messages(failures()), failures()); + default -> throw new QException("Unknown Slack home widget."); + }); + } + + /******************************************************************************* + ** Resolve workspace labels without granting access to the workspace admin UI. + *******************************************************************************/ + private static QWidgetData connections(String owner) throws QException + { + List connections = rows(SlackTables.CONNECTION, SlackStore.equal("userId", owner), Set.of("id", "workspaceId", "status", "connectedAt")); + QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, + connections.stream().map(row -> row.getValueInteger("workspaceId")).toList()); + List workspaces = connections.isEmpty() ? List.of() : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, filter); + return (SlackHomeView.connections(connections, workspaces)); + } + + /******************************************************************************* + ** Omit empty personal statuses, but always show Sent. + *******************************************************************************/ + private static MultiStatisticsData deliveries(String owner) throws QException + { + List groups = new ArrayList<>(); for(var status : SlackEnums.MessageStatus.values()) { - personal.put(status.getPossibleValueLabel(), count(status.name(), owner, "createDate")); + QQueryFilter filter = countFilter(status.name(), owner, "createDate"); + int total = count(filter); + if(total > 0 || status == SlackEnums.MessageStatus.SENT) + { + groups.add(statistic(SlackHomeView.messageStatus(status.name()), total, filter)); + } } - Map outbox = new LinkedHashMap<>(); - if(admin) + return (new MultiStatisticsData(null, groups)); + } + + /******************************************************************************* + ** Backlog counts are current; terminal counts cover their event time in 24 h. + *******************************************************************************/ + private static MultiStatisticsData outbox() throws QException + { + List groups = new ArrayList<>(); + String[] statuses = {"QUEUED", "RETRY_WAIT", "SENT", "DEAD_LETTER", "FAILED_AUTH"}; + String[] labels = {"Queued", "Retrying", "Sent in 24 h", "Dead letters in 24 h", "Not authorized in 24 h"}; + String[] dates = {null, null, "sentDate", "deadLetterDate", "modifyDate"}; + for(int i = 0; i < statuses.length; i++) { - outbox.put("Queued", count("QUEUED", null, null)); - outbox.put("Retrying", count("RETRY_WAIT", null, null)); - outbox.put("Sent (24 h)", count("SENT", null, "sentDate")); - outbox.put("Dead letters (24 h)", count("DEAD_LETTER", null, "deadLetterDate")); - outbox.put("FAILED_AUTH (24 h)", count("FAILED_AUTH", null, "modifyDate")); + QQueryFilter filter = countFilter(statuses[i], null, dates[i]); + groups.add(statistic(labels[i], count(filter), filter)); } - List connections = rows(SlackTables.CONNECTION, SlackStore.equal("userId", owner), Set.of("id", "workspaceId", "status", "connectedAt")); - QQueryFilter workspaceFilter = new QQueryFilter(); - if(!admin) - { - workspaceFilter.withCriteria("id", QCriteriaOperator.IN, connections.stream().map(row -> row.getValueInteger("workspaceId")).toList()); - } - List workspaces = !admin && connections.isEmpty() ? List.of() : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, workspaceFilter); - QQueryFilter failures = new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("DEAD_LETTER", "FAILED_AUTH", "RETRY_WAIT")); - return (new RawHTML("My Slack", SlackHomeView.render(personal, connections, messages(SlackStore.equal("ownerUserId", owner)), - workspaces, admin, outbox, admin ? messages(failures) : List.of()))); + return (new MultiStatisticsData(null, groups)); + } + + /******************************************************************************* + ** Each tile links to exactly the same filter as its count. + *******************************************************************************/ + private static StatisticsGroupData statistic(String label, int total, QQueryFilter filter) + { + return (new StatisticsGroupData().withHeader(label) + .withStatisticList(List.of(new Statistic("Messages", total, SlackHomeView.listUrl(filter))))); } /******************************************************************************* ** Count in core, with an inclusive lower and upper bound for the last day. *******************************************************************************/ public static int count(String status, String owner, String dateField) throws QException + { + return (count(countFilter(status, owner, dateField))); + } + + /******************************************************************************* + ** One shared filter for counts and their drill-down links. + *******************************************************************************/ + private static QQueryFilter countFilter(String status, String owner, String dateField) { QQueryFilter filter = SlackStore.equal("status", status); if(owner != null) @@ -104,16 +161,32 @@ public final class SlackHome filter.withCriteria(dateField, QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400)); filter.withCriteria(dateField, QCriteriaOperator.LESS_THAN_OR_EQUALS, now); } + return (filter); + } + + /******************************************************************************* + ** Count under the current viewer's owner and tenant locks. + *******************************************************************************/ + private static int count(QQueryFilter filter) throws QException + { return (new CountAction().execute(new CountInput(SlackTables.MESSAGE).withFilter(filter)).getCount()); } /******************************************************************************* - ** Last ten messages, stable ordering even for equal timestamps. + ** Recoverable and terminal failures share the same list and drill-down filter. + *******************************************************************************/ + private static QQueryFilter failures() + { + return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("DEAD_LETTER", "FAILED_AUTH", "RETRY_WAIT"))); + } + + /******************************************************************************* + ** Last ten messages, stable ordering even for equal timestamps, without bodies. *******************************************************************************/ private static List messages(QQueryFilter filter) throws QException { filter.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10); - return (rows(SlackTables.MESSAGE, filter, Set.of("id", "status", "channelLabel", "createDate", "lastError"))); + return (rows(SlackTables.MESSAGE, filter, Set.of("id", "status", "channelLabel", "createDate"))); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java index 533b9c7..4c4df30 100644 --- a/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java @@ -20,20 +20,28 @@ package com.kingsrook.qbits.slack.app; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; +import com.kingsrook.qbits.slack.metadata.SlackEnums; import com.kingsrook.qbits.slack.metadata.SlackTables; import com.kingsrook.qbits.slack.readiness.SlackReadiness; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* - ** Theme-neutral stacked sections; every record-sourced value is HTML escaped. + ** Typed tables inherit responsive layout and themes from the QQQ dashboard. *******************************************************************************/ public final class SlackHomeView { - private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;box-sizing:border-box;"; - private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;"; - /******************************************************************************* ** Utility class. *******************************************************************************/ @@ -42,88 +50,124 @@ public final class SlackHomeView } /******************************************************************************* - ** Render personal data first, then tenant administration if permitted. + ** Workspace names link to the viewer's connection, never an admin-only record. *******************************************************************************/ - public static String render(Map personal, List connections, List messages, - List workspaces, boolean admin, Map outbox, List failures) + public static TableData connections(List connections, List workspaces) { - StringBuilder html = new StringBuilder("

My Slack

"); - section(html, "My connections"); - if(connections.isEmpty()) - { - html.append("

No connections yet. Use Connect Slack on Connections.

"); - } + List> rows = new ArrayList<>(); for(QRecord row : connections) { String workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(row.getValueInteger("workspaceId"))) .map(w -> w.getValueString("teamName")).findFirst().orElse("Workspace " + row.getValueInteger("workspaceId")); - html.append("

").append(link(SlackTables.CONNECTION, row, workspace)).append(" — ") - .append(esc(row.getValueString("status"))).append(" · Connected: ").append(esc(row.getValueString("connectedAt"))).append("

"); + rows.add(Map.of("workspace", link(SlackTables.CONNECTION, row, workspace), + "status", label(row.getValueString("status")), "connected", date(row.getValueInstant("connectedAt")))); } - html.append("
"); - counts(html, "My deliveries (24 h)", personal); - messages(html, "My recent deliveries", messages); - if(admin) - { - section(html, "Workspace readiness"); - if(workspaces.isEmpty()) - { - html.append("

No workspaces installed.

"); - } - for(QRecord workspace : workspaces) - { - html.append("

").append(link(SlackTables.WORKSPACE, workspace, workspace.getValueString("teamName"))).append("

"); - for(var check : SlackReadiness.workspaceSnapshot(workspace.getValueInteger("id")).checks()) - { - html.append("

").append(esc(check.name())).append(": ").append(esc(check.status())) - .append(" — ").append(esc(check.summary())).append("
Fix: ") - .append(check.name().equals("registeredChannels") ? "Check registered channels, unarchive them and invite the bot." : "Install or reconnect this workspace with the required scopes.") - .append(" Run Check readiness to refresh.

"); - } - } - html.append(""); - counts(html, "Outbox", outbox); - messages(html, "Recent failures", failures); - } - return (html.append("").toString()); - } - - /******************************************************************************* - ** Counts wrap naturally without fixed-width columns. - *******************************************************************************/ - private static void counts(StringBuilder html, String title, Map counts) - { - section(html, title); - html.append("
"); - counts.forEach((label, count) -> html.append("
").append(count).append(" ").append(esc(label)).append("
")); - html.append("
"); + return (table(List.of(column("Workspace", "workspace", "html"), column("Status", "status", "text"), column("Connected", "connected", "text")), + rows, "No connections yet. Use Connect Slack on Connections.")); } /******************************************************************************* ** Linked rows contain delivery metadata, never message bodies. *******************************************************************************/ - private static void messages(StringBuilder html, String title, List messages) + public static TableData messages(List messages, QQueryFilter filter) { - section(html, title); - if(messages.isEmpty()) - { - html.append("

No deliveries.

"); - } + List> rows = new ArrayList<>(); for(QRecord row : messages) { - html.append("

").append(link(SlackTables.MESSAGE, row, "Message " + row.getValueInteger("id"))) - .append(" — ").append(esc(row.getValueString("status"))).append(" · ").append(esc(row.getValueString("channelLabel"))) - .append("
").append(esc(row.getValueString("createDate"))).append("

"); + rows.add(Map.of("message", link(SlackTables.MESSAGE, row, "Message " + row.getValueInteger("id")), + "status", messageStatus(row.getValueString("status")), "where", row.getValueString("channelLabel") == null ? "Not recorded" : row.getValueString("channelLabel"), + "when", date(row.getValueInstant("createDate")))); } - html.append(""); + return (table(List.of(column("Message", "message", "html"), column("Status", "status", "text"), column("Where", "where", "text"), column("When", "when", "text")), + rows, "No deliveries.").withLinkText("View all").withLinkURL(listUrl(filter))); } /******************************************************************************* - ** A full-width section. + ** One row per workspace check; successful checks have no remediation hint. *******************************************************************************/ - private static void section(StringBuilder html, String title) + public static TableData readiness(List workspaces) { - html.append("

").append(esc(title)).append("

"); + List> rows = new ArrayList<>(); + for(QRecord workspace : workspaces) + { + for(var check : SlackReadiness.workspaceSnapshot(workspace.getValueInteger("id")).checks()) + { + boolean channels = "registeredChannels".equals(check.name()); + String status = switch(check.status()) + { + case "READY" -> "READY"; + case "WARN" -> "DEGRADED"; + default -> "NOT READY"; + }; + String fix = "READY".equals(status) ? "" : "UNKNOWN".equals(check.status()) ? "Run Check readiness to refresh." + : channels ? "Check registered channels, unarchive them and invite the bot. Run Check readiness to refresh." + : "Install or reconnect this workspace with the required scopes. Run Check readiness to refresh."; + rows.add(Map.of("workspace", link(SlackTables.WORKSPACE, workspace, workspace.getValueString("teamName")), + "check", channels ? "Registered channels" : "Bot token and scopes", "status", status, "fix", fix)); + } + } + return (table(List.of(column("Workspace", "workspace", "html"), column("Check", "check", "text"), column("Status", "status", "text"), column("Fix", "fix", "text")), + rows, "No workspaces installed.")); + } + + /******************************************************************************* + ** Human delivery labels clarify exceptional states instead of exposing enums. + *******************************************************************************/ + public static String messageStatus(String status) + { + if(status == null) + { + return ("Not recorded"); + } + return (switch(SlackEnums.MessageStatus.valueOf(status)) + { + case WAITING_PARENT -> "Waiting for parent"; + case FAILED_AUTH -> "Failed: not authorized"; + case RETRY_WAIT -> "Retrying"; + default -> label(status); + }); + } + + /******************************************************************************* + ** Plain enum display label for connection and remaining delivery states. + *******************************************************************************/ + private static String label(String value) + { + return (value == null ? "Not recorded" : value.charAt(0) + value.substring(1).toLowerCase(Locale.ROOT).replace('_', ' ')); + } + + /******************************************************************************* + ** Use the viewer's timezone, falling back to the application's configured zone. + *******************************************************************************/ + private static String date(Instant value) + { + return (value == null ? "Not recorded" : DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm a z", Locale.ENGLISH) + .withZone(ValueUtils.getSessionOrInstanceZoneId()).format(value)); + } + + /******************************************************************************* + ** A Messages list filtered to the count or recent list's actual scope. + *******************************************************************************/ + public static String listUrl(QQueryFilter filter) + { + return ("/app/" + SlackTables.MESSAGE + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8)); + } + + /******************************************************************************* + ** Only record links use HTML; ordinary text is escaped by the table renderer. + *******************************************************************************/ + private static TableData.Column column(String header, String accessor, String type) + { + return (new TableData.Column(type, header, accessor, null, "left").withVerticalAlign("top")); + } + + /******************************************************************************* + ** Full-width table with an honest empty state and no unnecessary page selector. + *******************************************************************************/ + private static TableData table(List columns, List> rows, String empty) + { + return (new TableData(null, columns, rows).withRowsPerPage(Math.max(10, rows.size())) + .withHidePaginationDropdown(true).withNoRowsFoundHTML(empty)); } /******************************************************************************* @@ -131,11 +175,11 @@ public final class SlackHomeView *******************************************************************************/ private static String link(String table, QRecord row, String label) { - return ("" + esc(label) + ""); + return ("" + esc(label) + ""); } /******************************************************************************* - ** Escape text and attribute delimiters; absent dates remain explicit. + ** Escape record-sourced labels in HTML cells. *******************************************************************************/ private static String esc(String value) { diff --git a/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java index 0eb2b03..251d1ad 100644 --- a/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java +++ b/src/main/java/com/kingsrook/qbits/slack/permissions/SlackPermissionChecker.java @@ -78,6 +78,11 @@ public class SlackPermissionChecker implements CustomPermissionChecker } SlackSecurity.applyBodyPermission(QContext.getQSession()); String needed = SlackPermissions.VIEW; + if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData widget + && com.kingsrook.qbits.slack.app.SlackApp.ADMIN_WIDGETS.contains(widget.getName())) + { + needed = SlackPermissions.ADMIN; + } if(metadata instanceof QTableMetaData workspace && SlackTables.WORKSPACE.equals(workspace.getName()) && !WORKSPACE_LOOKUP.get()) { needed = SlackPermissions.ADMIN; diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java index 12687db..8b08a85 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java @@ -20,6 +20,8 @@ package com.kingsrook.qbits.slack; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -42,8 +44,12 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -54,7 +60,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class SlackAppTest { /******************************************************************************* - ** Main navigation contains tables only and exactly one full-width dashboard. + ** Main navigation contains tables only, with full-width typed dashboard sections. *******************************************************************************/ @Test void navigation() throws Exception @@ -66,8 +72,13 @@ class SlackAppTest var app = env.instance().getApp("slack"); assertThat(app.getLabel()).isEqualTo("Slack"); assertThat(app.getChildren()).extracting(QAppChildMetaData::getName).containsExactlyElementsOf(SlackTables.MAIN); - assertThat(app.getWidgets()).containsExactly(SlackApp.HOME_WIDGET); + assertThat(app.getWidgets()).containsExactly(SlackApp.HOME_WIDGET, SlackApp.DELIVERIES, SlackApp.RECENT, + SlackApp.READINESS, SlackApp.OUTBOX, SlackApp.FAILURES); + assertThat(SlackApp.widgets()).allMatch(widget -> widget.getGridColumns() == 12); + assertThat(SlackApp.widgets()).extracting(widget -> widget.getType()) + .containsExactly("table", "multiStatistics", "table", "table", "multiStatistics", "table"); assertThat(env.instance().getWidget(SlackApp.HOME_WIDGET).getGridColumns()).isEqualTo(12); + assertThat(env.instance().getWidget(SlackApp.HOME_WIDGET).getType()).isEqualTo("table"); assertThat(env.instance().getProcess(SlackProcesses.CONNECT).getTableName()).isEqualTo(SlackTables.CONNECTION); assertThat(env.instance().getProcess(SlackProcesses.TEST_MESSAGE).getTableName()).isEqualTo(SlackTables.CONNECTION); } @@ -111,7 +122,8 @@ class SlackAppTest env.tenant("alice", 1); env.tenant("bob", 1); int workspace = env.workspace("T1", 1, "xoxb-demo", "BOT"); - env.connection("alice", workspace, "UA", null); + int connection = env.connection("alice", workspace, "UA", null); + env.set(SlackTables.CONNECTION, connection, "connectedAt", Instant.parse("2026-10-02T17:49:54.182409Z")); env.connection("bob", workspace, "UB", null); int hidden = message(env, workspace, "bob", "SENT", env.clock().instant()); List ids = new ArrayList<>(); @@ -121,11 +133,34 @@ class SlackAppTest } env.asOwner("alice"); assertThat(SlackHome.count("SENT", "alice", "createDate")).isEqualTo(12); - String html = SlackHome.overview().getHtml(); - assertThat(html).contains("My Slack", "Team T1", "My deliveries (24 h)", "12") - .doesNotContain("Workspace readiness", "/app/slack_message/" + hidden + "\"", "/app/slack_message/" + ids.getFirst() + "\"", "body-canary"); - assertThat(html.split("href=\"/app/slack_message/", -1).length - 1).isEqualTo(10); - assertThat(new SlackApp.HomeRenderer().render(null)).isNotNull(); + QContext.getQSession().withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago"); + TableData connections = (TableData) SlackHome.render(SlackApp.HOME_WIDGET); + assertThat(connections.getRows()).hasSize(1); + assertThat(connections.getRows().getFirst().get("workspace").toString()).contains("Team T1", "/app/slack_connection/"); + assertThat(connections.getRows().getFirst().get("status")).isEqualTo("Active"); + assertThat(connections.getRows().getFirst().get("connected")).isEqualTo("Oct 2, 2026, 12:49 PM CDT"); + MultiStatisticsData deliveries = (MultiStatisticsData) SlackHome.render(SlackApp.DELIVERIES); + assertThat(deliveries.getStatisticsGroupData()).hasSize(1); + var sent = deliveries.getStatisticsGroupData().getFirst(); + assertThat(sent.getHeader()).isEqualTo("Sent"); + assertThat(sent.getStatisticList().getFirst().getValue()).isEqualTo(12); + assertThat(URLDecoder.decode(sent.getStatisticList().getFirst().getUrl(), StandardCharsets.UTF_8)) + .contains("ownerUserId", "alice", "SENT", "createDate", "GREATER_THAN_OR_EQUALS", "LESS_THAN_OR_EQUALS"); + TableData recent = (TableData) SlackHome.render(SlackApp.RECENT); + assertThat(recent.getRows()).hasSize(10); + assertThat(recent.getRows().toString()).doesNotContain("/app/slack_message/" + hidden + "\"", "/app/slack_message/" + ids.getFirst() + "\"", "body-canary"); + assertThat(recent.getRows()).allSatisfy(row -> + { + assertThat(row.get("status")).isEqualTo("Sent"); + assertThat(row.get("when").toString()).containsPattern("[A-Z][a-z]{2} \\d{1,2}, \\d{4}, ").doesNotContain("T00:"); + }); + assertThat(recent.getLinkText()).isEqualTo("View all"); + assertThat(URLDecoder.decode(recent.getLinkURL(), StandardCharsets.UTF_8)).contains("alice", "ownerUserId"); + assertThat(new SlackApp.HomeRenderer().render(new RenderWidgetInput().withWidgetMetaData(env.instance().getWidget(SlackApp.RECENT)))).isNotNull(); + for(String name : SlackApp.ADMIN_WIDGETS) + { + assertThatThrownBy(() -> SlackHome.render(name)).hasMessageContaining("slack.admin"); + } } } @@ -149,9 +184,64 @@ class SlackAppTest SlackSecurity.applyToSession(session, "alice", true); assertThat(SlackHome.count("FAILED_AUTH", null, "modifyDate")).isEqualTo(2); assertThat(SlackHome.count("FAILED_AUTH", "alice", "createDate")).isEqualTo(1); - String html = SlackHome.overview().getHtml(); - assertThat(html).contains("Workspace readiness", "Team T1", "UNKNOWN", "Fix:", "Outbox", "Recent failures") - .doesNotContain("Team T2", "/app/slack_message/" + hidden + "\""); + TableData readiness = (TableData) SlackHome.render(SlackApp.READINESS); + assertThat(readiness.getRows()).hasSize(2); + assertThat(readiness.getRows().toString()).contains("Team T1", "NOT READY", "Run Check readiness", "Bot token and scopes", "Registered channels") + .doesNotContain("Team T2", "workspace:", "UNKNOWN"); + TableData failures = (TableData) SlackHome.render(SlackApp.FAILURES); + assertThat(failures.getRows()).hasSize(2); + assertThat(failures.getRows().toString()).contains("Failed: not authorized").doesNotContain("FAILED_AUTH", "/app/slack_message/" + hidden + "\""); + MultiStatisticsData outbox = (MultiStatisticsData) SlackHome.render(SlackApp.OUTBOX); + assertThat(outbox.getStatisticsGroupData()).hasSize(5); + assertThat(outbox.getStatisticsGroupData().get(4).getStatisticList().getFirst().getValue()).isEqualTo(2); + assertThat(outbox.getStatisticsGroupData()).allSatisfy(group -> + assertThat(group.getStatisticList().getFirst().getUrl()).startsWith("/app/slack_message?filter=")); + } + } + + /******************************************************************************* + ** Empty states, readable delivery labels and readiness remediation follow status. + *******************************************************************************/ + @Test + void typedEmptyAndReadyStates() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + env.asOwner("alice"); + assertThat(((TableData) SlackHome.render(SlackApp.HOME_WIDGET)).getRows()).isEmpty(); + assertThat(((TableData) SlackHome.render(SlackApp.RECENT)).getNoRowsFoundHTML()).isEqualTo("No deliveries."); + var empty = (MultiStatisticsData) SlackHome.render(SlackApp.DELIVERIES); + assertThat(empty.getStatisticsGroupData()).hasSize(1); + assertThat(empty.getStatisticsGroupData().getFirst().getHeader()).isEqualTo("Sent"); + assertThat(empty.getStatisticsGroupData().getFirst().getStatisticList().getFirst().getValue()).isZero(); + message(env, workspace, "alice", "WAITING_PARENT", env.clock().instant()); + env.asOwner("alice"); + assertThat(((TableData) SlackHome.render(SlackApp.RECENT)).getRows().getFirst().get("status")).isEqualTo("Waiting for parent"); + for(String scopes : List.of(com.kingsrook.qbits.slack.oauth.SlackConnect.BOT_SCOPES + ",usergroups:read,chat:write.customize", + com.kingsrook.qbits.slack.oauth.SlackConnect.BOT_SCOPES, "channels:read")) + { + env.asSystem(); + env.slack().script("auth.test", new FakeSlackServer.Reply(200, "{\"ok\":true,\"team_id\":\"T1\"}", java.util.Map.of("x-oauth-scopes", scopes))); + com.kingsrook.qbits.slack.readiness.SlackReadiness.workspace(workspace); + env.asOwner("alice").withPermissions(SlackPermissions.ADMIN); + TableData readiness = (TableData) SlackHome.render(SlackApp.READINESS); + String expected = scopes.equals("channels:read") ? "NOT READY" : scopes.contains("usergroups:read") ? "READY" : "DEGRADED"; + assertThat(readiness.getRows().getFirst().get("status")).isEqualTo(expected); + assertThat(readiness.getRows()).allSatisfy(row -> + { + if("READY".equals(row.get("status"))) + { + assertThat(row.get("fix")).isEqualTo(""); + } + else + { + assertThat(row.get("fix").toString()).contains("Run Check readiness"); + } + }); + } } } @@ -292,7 +382,7 @@ class SlackAppTest session.withPermissions(SlackPermissions.ADMIN); checker.checkPermissionsThrowing(new UpdateInput(SlackTables.TEMPLATE), env.instance().getTable(SlackTables.TEMPLATE)); session.setPermissions(Set.of("slack.hasAccess")); - assertThatThrownBy(() -> SlackHome.overview()).hasMessageContaining("slack.view"); + assertThatThrownBy(() -> SlackHome.render(SlackApp.HOME_WIDGET)).hasMessageContaining("slack.view"); } } -- 2.54.0