Skip to content

Commit 8f4b0f5

Browse files
Slack request invite (#3795)
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent f060144 commit 8f4b0f5

17 files changed

Lines changed: 877 additions & 32 deletions

File tree

apps/web/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ STORAGE_PRIVATE_BUCKET=
114114
DUB_SLACK_HOOK_CRON=
115115
DUB_SLACK_HOOK_LINKS=
116116

117+
# Slack Dub Support integration
118+
DUB_SLACK_ASSISTANT_BOT_TOKEN=
119+
117120
# Used for background jobs
118121
# Get your ngrok URL here: https://ngrok.com/
119122
NEXT_PUBLIC_NGROK_URL=
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"use client";
2+
3+
import { LoadingSpinner } from "@dub/ui";
4+
import { cn } from "@dub/utils";
5+
import { useState } from "react";
6+
import { useFormStatus } from "react-dom";
7+
import { toast } from "sonner";
8+
9+
export function SlackSupportInvite() {
10+
const [needsChannelId, setNeedsChannelId] = useState(false);
11+
12+
return (
13+
<div className="flex flex-col space-y-5">
14+
<form
15+
action={async (data) => {
16+
try {
17+
const res = await fetch("/api/admin/slack-support-invite", {
18+
method: "POST",
19+
body: JSON.stringify({
20+
email: data.get("email"),
21+
workspaceSlug: data.get("workspaceSlug"),
22+
channelId: data.get("channelId") || undefined,
23+
}),
24+
});
25+
26+
const json = await res.json().catch(() => ({}));
27+
28+
if (!res.ok) {
29+
if (json.nameTaken) {
30+
setNeedsChannelId(true);
31+
}
32+
toast.error(
33+
json.error ?? "Something went wrong. Please try again.",
34+
);
35+
return;
36+
}
37+
38+
setNeedsChannelId(false);
39+
toast.success(`Slack invite sent (ID: ${json.inviteId})`);
40+
} catch {
41+
toast.error(
42+
"Network error. Please check your connection and try again.",
43+
);
44+
}
45+
}}
46+
>
47+
<Form needsChannelId={needsChannelId} />
48+
</form>
49+
</div>
50+
);
51+
}
52+
53+
const Form = ({ needsChannelId }: { needsChannelId: boolean }) => {
54+
const { pending } = useFormStatus();
55+
56+
const inputClass = cn(
57+
"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
58+
pending && "bg-neutral-100",
59+
);
60+
61+
return (
62+
<div className="flex flex-col gap-2">
63+
<div className="relative flex w-full rounded-md shadow-sm">
64+
<input
65+
name="email"
66+
id="email"
67+
type="email"
68+
required
69+
disabled={pending}
70+
autoComplete="off"
71+
className={inputClass}
72+
placeholder="user@example.com"
73+
/>
74+
{pending && (
75+
<LoadingSpinner className="absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400" />
76+
)}
77+
</div>
78+
79+
<div className="relative flex w-full rounded-md shadow-sm">
80+
<span className="inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
81+
app.dub.co/
82+
</span>
83+
<input
84+
name="workspaceSlug"
85+
id="workspaceSlug"
86+
type="text"
87+
required
88+
disabled={pending}
89+
autoComplete="off"
90+
className={cn(
91+
"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
92+
pending && "bg-neutral-100",
93+
)}
94+
placeholder="acme"
95+
/>
96+
{pending && (
97+
<LoadingSpinner className="absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400" />
98+
)}
99+
</div>
100+
101+
{needsChannelId && (
102+
<div className="relative flex w-full rounded-md shadow-sm">
103+
<input
104+
name="channelId"
105+
id="channelId"
106+
type="text"
107+
required
108+
disabled={pending}
109+
autoComplete="off"
110+
className={inputClass}
111+
placeholder="Slack channel ID (e.g. C01234ABCDE)"
112+
pattern="^[CG][A-Z0-9]{8,}$"
113+
/>
114+
{pending && (
115+
<LoadingSpinner className="absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400" />
116+
)}
117+
</div>
118+
)}
119+
120+
<button
121+
type="submit"
122+
disabled={pending}
123+
className={cn(
124+
"rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 focus:outline-none",
125+
pending && "opacity-50",
126+
)}
127+
>
128+
{pending ? "Sending…" : "Send invite"}
129+
</button>
130+
</div>
131+
);
132+
};

apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ImpersonateUser } from "./components/impersonate-user";
44
import { ImpersonateWorkspace } from "./components/impersonate-workspace";
55
import { RefreshDomain } from "./components/refresh-domain";
66
import { ResetLoginAttempts } from "./components/reset-login-attempts";
7+
import { SlackSupportInvite } from "./components/slack-support-invite";
78

89
export default function AdminPage() {
910
return (
@@ -55,6 +56,14 @@ export default function AdminPage() {
5556
</p>
5657
<ResetLoginAttempts />
5758
</div>
59+
<div className="flex flex-col space-y-4 px-5 py-10">
60+
<h2 className="text-xl font-semibold">Slack Support Invite</h2>
61+
<p className="text-sm text-neutral-500">
62+
Manually send a priority Slack Connect invite to a user for a given
63+
workspace (bypasses the plan check).
64+
</p>
65+
<SlackSupportInvite />
66+
</div>
5867
</div>
5968
);
6069
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { withAdmin } from "@/lib/auth";
2+
import {
3+
inviteToSlackSupportChannel,
4+
sharedSupportChannelName,
5+
} from "@/lib/slack/support-invite";
6+
import { NextResponse } from "next/server";
7+
8+
// POST /api/admin/slack-support-invite — manually send a Slack Connect invite on behalf of a workspace.
9+
// Pass channelId when the channel already exists (name_taken) and you know the ID.
10+
export const POST = withAdmin(async ({ req }) => {
11+
const { email, workspaceSlug, channelId } = await req.json();
12+
13+
if (!email || !workspaceSlug) {
14+
return NextResponse.json(
15+
{ error: "email and workspaceSlug are required" },
16+
{ status: 400 },
17+
);
18+
}
19+
20+
const { inviteId, nameTaken } = await inviteToSlackSupportChannel({
21+
email,
22+
workspaceSlug,
23+
channelId: channelId || undefined,
24+
});
25+
26+
if (nameTaken) {
27+
return NextResponse.json(
28+
{
29+
error: `Channel #${sharedSupportChannelName(workspaceSlug)} already exists. Provide its Slack channel ID (C…) to send the invite.`,
30+
nameTaken: true,
31+
},
32+
{ status: 409 },
33+
);
34+
}
35+
36+
return NextResponse.json({ success: true, inviteId });
37+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { DubApiError } from "@/lib/api/errors";
2+
import { withWorkspace } from "@/lib/auth";
3+
import { getPlanCapabilities } from "@/lib/plan-capabilities";
4+
import { requestSlackConnectSupportInvite } from "@/lib/slack/support-invite";
5+
import { ratelimit } from "@/lib/upstash";
6+
import { isWorkspaceBillingTrialActive } from "@dub/utils";
7+
import { NextResponse } from "next/server";
8+
9+
// POST /api/workspaces/[idOrSlug]/support/slack-invite — Slack Connect invite to priority support
10+
export const POST = withWorkspace(
11+
async ({ workspace, session }) => {
12+
if (!getPlanCapabilities(workspace.plan).canRequestSlackSupportInvite) {
13+
throw new DubApiError({
14+
code: "forbidden",
15+
message:
16+
"Priority Slack support is only available on Advanced and Enterprise plans. Upgrade your workspace to request access.",
17+
});
18+
}
19+
20+
if (isWorkspaceBillingTrialActive(workspace.trialEndsAt)) {
21+
throw new DubApiError({
22+
code: "forbidden",
23+
message:
24+
"Priority Slack support is not available during a free trial. Activate your subscription to request access.",
25+
});
26+
}
27+
28+
const email = session.user.email?.trim();
29+
if (!email) {
30+
throw new DubApiError({
31+
code: "bad_request",
32+
message:
33+
"Your account does not have an email address. Add one to request a Slack invite.",
34+
});
35+
}
36+
37+
const { success: workspaceSuccess } = await ratelimit(5, "1 d").limit(
38+
`slack-support-invite:workspace:${workspace.id}`,
39+
);
40+
41+
if (!workspaceSuccess) {
42+
throw new DubApiError({
43+
code: "rate_limit_exceeded",
44+
message:
45+
"This workspace has reached the daily limit for Slack invite requests. Please try again tomorrow.",
46+
});
47+
}
48+
49+
const { success: userSuccess } = await ratelimit(10, "1 h").limit(
50+
`slack-support-invite:${workspace.id}:${session.user.id}`,
51+
);
52+
53+
if (!userSuccess) {
54+
throw new DubApiError({
55+
code: "rate_limit_exceeded",
56+
message:
57+
"You've requested too many Slack invites recently. Please try again later.",
58+
});
59+
}
60+
61+
const { inviteId } = await requestSlackConnectSupportInvite({
62+
workspaceSlug: workspace.slug,
63+
email,
64+
});
65+
66+
return NextResponse.json({ inviteId });
67+
},
68+
{
69+
requiredPlan: ["advanced", "enterprise"],
70+
requiredPermissions: ["workspaces.write"],
71+
},
72+
);

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/page-client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { clientAccessCheck } from "@/lib/client-access-check";
44
import useWorkspace from "@/lib/swr/use-workspace";
55
import DeleteWorkspace from "@/ui/workspaces/delete-workspace";
6+
import { SlackSupportSettingsCard } from "@/ui/workspaces/slack-support-settings-card";
67
import UploadLogo from "@/ui/workspaces/upload-logo";
78
import { Form } from "@dub/ui";
89
import { useSession } from "next-auth/react";
@@ -23,6 +24,7 @@ export default function WorkspaceSettingsClient() {
2324

2425
return (
2526
<div className="mb-6 space-y-6">
27+
<SlackSupportSettingsCard />
2628
<Form
2729
title="Workspace Name"
2830
description={`This is the name of your workspace on ${process.env.NEXT_PUBLIC_APP_NAME}.`}

0 commit comments

Comments
 (0)