feat: phase 1f - Slack app, home dashboard, permissions, integrations, preferences #6
No files matched your search
@@ -74,6 +74,12 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.19</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
|
||||
@@ -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<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Main tables publish insert, update and delete events with safe fields.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames()
|
||||
{
|
||||
return (SlackTables.MAIN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Main tables exposed under normal viewer permissions.
|
||||
*******************************************************************************/
|
||||
public static List<String> apiTableNames()
|
||||
{
|
||||
return (SlackTables.MAIN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicitly exclude both token columns, even if a host unhides them.
|
||||
*******************************************************************************/
|
||||
public static List<String> 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<String> 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<SearchableTable> 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"))));
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,9 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables()));
|
||||
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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String> ADMIN_WIDGETS = List.of(READINESS, OUTBOX, FAILURES);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackApp()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Navigation in owner-requested order.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(Map<String, QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName("slack").withLabel("Slack").withIcon(new QIcon("chat"))
|
||||
.withPermissionRules(SlackTables.permissionRules()).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList());
|
||||
SlackTables.MAIN.forEach(name -> app.withChild(tables.get(name)));
|
||||
return (app);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each section occupies all twelve grid columns, including on small screens.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
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()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.render(input.getWidgetMetaData().getName())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.ArrayList;
|
||||
import java.util.List;
|
||||
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.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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact counts and bounded recent lists, always under the viewer's record locks.
|
||||
*******************************************************************************/
|
||||
public final class SlackHome
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackHome()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed sections fetch only their data, with permissions checked on direct calls.
|
||||
*******************************************************************************/
|
||||
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();
|
||||
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<QRecord> 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<QRecord> 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<StatisticsGroupData> groups = new ArrayList<>();
|
||||
for(var status : SlackEnums.MessageStatus.values())
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
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<StatisticsGroupData> 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++)
|
||||
{
|
||||
QQueryFilter filter = countFilter(statuses[i], null, dates[i]);
|
||||
groups.add(statistic(labels[i], count(filter), filter));
|
||||
}
|
||||
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)
|
||||
{
|
||||
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 (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());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<QRecord> 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")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never switch to a system context for dashboard queries.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table, QQueryFilter filter, Set<String> fields) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(fields)).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed tables inherit responsive layout and themes from the QQQ dashboard.
|
||||
*******************************************************************************/
|
||||
public final class SlackHomeView
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackHomeView()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Workspace names link to the viewer's connection, never an admin-only record.
|
||||
*******************************************************************************/
|
||||
public static TableData connections(List<QRecord> connections, List<QRecord> workspaces)
|
||||
{
|
||||
List<Map<String, Object>> 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"));
|
||||
rows.add(Map.of("workspace", link(SlackTables.CONNECTION, row, workspace),
|
||||
"status", label(row.getValueString("status")), "connected", date(row.getValueInstant("connectedAt"))));
|
||||
}
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public static TableData messages(List<QRecord> messages, QQueryFilter filter)
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord row : messages)
|
||||
{
|
||||
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"))));
|
||||
}
|
||||
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)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One row per workspace check; successful checks have no remediation hint.
|
||||
*******************************************************************************/
|
||||
public static TableData readiness(List<QRecord> workspaces)
|
||||
{
|
||||
List<Map<String, Object>> 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<TableData.Column> columns, List<Map<String, Object>> rows, String empty)
|
||||
{
|
||||
return (new TableData(null, columns, rows).withRowsPerPage(Math.max(10, rows.size()))
|
||||
.withHidePaginationDropdown(true).withNoRowsFoundHTML(empty));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only integer record ids and internal table names enter URLs.
|
||||
*******************************************************************************/
|
||||
private static String link(String table, QRecord row, String label)
|
||||
{
|
||||
return ("<a href=\"/app/" + table + "/" + row.getValueInteger("id") + "\">" + esc(label) + "</a>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape record-sourced labels in HTML cells.
|
||||
*******************************************************************************/
|
||||
private static String esc(String value)
|
||||
{
|
||||
return (value == null ? "Not recorded" : value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
}
|
||||
@@ -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<String> MAIN = List.of(MESSAGE, CONNECTION, WORKSPACE, CHANNEL, USER_GROUP, TEMPLATE);
|
||||
|
||||
public static final List<String> 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))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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<Boolean> 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> T workspaceLookup(com.kingsrook.qbits.slack.support.SlackStore.Work<T> 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 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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> ALL = List.of(VIEW, CONNECT, INSTALL, ADMIN, SEND, BLOCKS, BODY);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackPermissions()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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<QRecord> connections, List<String> actions)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit owner filter keeps an admin's preference list personal too.
|
||||
*******************************************************************************/
|
||||
public Section section() throws QException
|
||||
{
|
||||
List<QRecord> connections = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION)
|
||||
.withFilter(SlackStore.equal("userId", owner())).withFieldNamesToInclude(Set.of("id", "workspaceId", "status", "connectedAt"))).getRecords();
|
||||
List<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -49,6 +49,18 @@ public final class SlackReadiness
|
||||
{
|
||||
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private static final Map<QInstance, Map<Integer, SlackApiReadiness>> 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);
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -125,6 +125,11 @@ public final class SlackStore
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* 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.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Home, integrations, preferences and record actions with real locked H2 data.
|
||||
*******************************************************************************/
|
||||
class SlackAppTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Main navigation contains tables only, with full-width typed dashboard sections.
|
||||
*******************************************************************************/
|
||||
@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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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");
|
||||
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<Integer> 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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.render(SlackApp.HOME_WIDGET)).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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, String> 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<String, String> modules = (Map<String, String>) 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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user