The Slack App Home tab is the bot's "home page" - a one-on-one
private space between the app and a single user. Calling
views.publish with the user's ID updates that user's view; other
users don't see the change. This makes App Home a natural place to
render a per-user, per-role dashboard.
A basic Home Tab renders the same content for every user. This pattern renders different content per user based on a row in an Excel-backed users table, with three role tiers:
super_admin- sees all features, including user management and compliance centeradmin- sees most features but no user managementtechniker- sees only scan-related actions
The role check is performed on every Home Tab render, not cached.
Any change to a user's role (or to their active status) is visible
the next time they re-open the home tab.
- Not a real RBAC system. Three hard-coded roles, three branches in the renderer. There's no permission inheritance, no group-based access, no audit trail of permission grants.
- Not real-time. If an admin changes a user's role, the user has to close and reopen the home tab to see the new view. There's no push notification.
- Not a substitute for server-side checks. The role-based UI hides actions, but the backend (interaction workflow) re-checks the user's role before executing any action. Hiding a button isn't security; the server-side recheck is.
1. User opens the bot's Home tab in Slack
▼
2. Slack fires app_home_opened with the user's Slack ID
▼
3. n8n: read the Excel users table via Microsoft Graph
▼
4. n8n: find the row where slack_id == event.user
▼
5. Branch on user state:
- not found → access-denied block
- active = "No" → account-deactivated block
- role-based → role-specific blocks
▼
6. Call Slack views.publish with the user-scoped block list
Step 3 is the hottest read in the platform. Optimization: the users table is small (single-digit rows), so the cost is one Microsoft Graph call per home-tab open. For larger user populations, cache the users table (e.g. an n8n static-data Memory node refreshed every 5 minutes).
The cleanest implementation reads the user's row, then sets three booleans:
const isSuperAdmin = currentUser.role === "super_admin";
const isAdmin = currentUser.role === "admin";
const isTechniker = currentUser.role === "techniker";Then builds the block list conditionally:
const blocks = [];
// Header & user info - same for everyone
blocks.push(/* greeting */, /* role badge */, /* divider */);
// Branding - same for everyone
blocks.push(/* product name + tagline */);
// Quick actions - present in all three roles
blocks.push({
type: "actions",
elements: [
button("New Einwilligung", "create_einwilligung"),
button("Search", "search_open"),
button("My cases", "my_cases"),
],
});
// Admin-only actions
if (isSuperAdmin || isAdmin) {
blocks.push({
type: "actions",
elements: [
button("Reports", "reports_open"),
button("Help", "help_open"),
],
});
}
// Super-admin-only actions
if (isSuperAdmin) {
blocks.push(
{ type: "divider" },
{
type: "section",
text: { type: "mrkdwn", text: "*🔒 Administration*" },
},
{
type: "actions",
elements: [
button("👥 Users", "view_users"),
button("⚙️ Settings", "system_settings"),
button("🔐 Compliance", "compliance_center"),
],
},
);
}
// Footer - same for everyone
blocks.push(/* version + sync time */);The full implementation is in
../snippets/role-based-home-tab.js.
Hiding a button doesn't prevent a determined user from constructing the underlying interaction payload manually and sending it to the Slack interactions URL. Slack will dutifully forward it to your webhook. So:
- The interaction workflow's authorization layer runs before any branch executes
- It re-reads the user's role from the same users table
- It rejects the action if the user's role doesn't have permission
This means the role-based Home Tab is a convenience layer for the user's eyes, not the security boundary. The security boundary is the server-side recheck.
The auth-layer pattern is documented in
multi-source-authorization.md.
| Failure | What you see | Mitigation |
|---|---|---|
| User row missing from Excel | "Access denied - contact admin" home tab | Expected; user really isn't onboarded |
User row has active = "No" |
"Account deactivated" home tab | Expected; deactivation flow does this |
| Microsoft Graph 401 | Empty or stale home tab | Refresh the OAuth token; n8n's OAuth credentials handle this if configured |
| Microsoft Graph rate limit (429) | Empty home tab; user re-opens and it works | Acceptable for low traffic; for higher, cache the users table |
| Block Kit JSON exceeds 100 blocks | views.publish returns invalid_blocks |
Slack hard limit. Trim sections or paginate. |
slack_id column has a typo |
User sees "access denied" despite being onboarded | Add admin tooling to surface mismatches; common cause is copy-paste of Slack ID with leading/trailing whitespace |
The Home Tab renders a greeting based on time of day. The platform
operates in Europe/Berlin, so the renderer explicitly converts
the current UTC time to Berlin time before deciding "Guten Morgen /
Tag / Abend". Skipping this conversion gives a wrong greeting
during n8n's UTC nights for any deployment outside the
Europe/Berlin timezone.
const berlinTime = new Date(
new Date().toLocaleString("en-US", { timeZone: "Europe/Berlin" }),
);
const hour = berlinTime.getHours();
const greeting =
hour < 12 ? "Guten Morgen" : hour < 18 ? "Guten Tag" : "Guten Abend";This is the kind of detail that's easy to skip and hard to debug later when users report "the bot says good morning at 11 PM".