mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
security: harden Slack approval handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getConnectors,
|
||||
getDmRoute,
|
||||
getInboxRouting,
|
||||
getRecentChannels,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
subscribeChannel,
|
||||
unsubscribeChannel,
|
||||
type RecentChannel,
|
||||
type Connector,
|
||||
type Subscription,
|
||||
type UnroutedItem,
|
||||
} from "../api";
|
||||
@@ -53,11 +55,14 @@ export function InboxConfigure() {
|
||||
// the "default" route (sessions fall back to it); pick a channel separate from any you subscribe to.
|
||||
function InboxRoutingCard() {
|
||||
const [recent, setRecent] = useState<RecentChannel[]>([]);
|
||||
const [connectors, setConnectors] = useState<Connector[]>([]);
|
||||
const [target, setTarget] = useState(""); // current default-binding address, e.g. "slack:C0123"
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
getRecentChannels().then(setRecent).catch(() => setRecent([]));
|
||||
getConnectors().then(setConnectors).catch(() => setConnectors([]));
|
||||
getInboxRouting()
|
||||
.then((bs) => {
|
||||
const def = bs.find((b) => b.name === "default");
|
||||
@@ -76,15 +81,43 @@ function InboxRoutingCard() {
|
||||
if (!addr) return;
|
||||
// "slack:C0123" → channel="slack", target="C0123"; a bare id assumes slack.
|
||||
const [platform, id] = addr.includes(":") ? addr.split(":", 2) : ["slack", addr];
|
||||
await setInboxBinding("default", platform, id);
|
||||
const result = await setInboxBinding("default", platform, id);
|
||||
if (!result.ok) {
|
||||
setError(result.error || "Could not update Inbox routing.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setDraft("");
|
||||
load();
|
||||
};
|
||||
const clear = async () => {
|
||||
await setInboxBinding("default", null, "");
|
||||
const result = await setInboxBinding("default", null, "");
|
||||
if (!result.ok) {
|
||||
setError(result.error || "Could not clear Inbox routing.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
load();
|
||||
};
|
||||
|
||||
const draftAddr = draft.trim();
|
||||
const [draftPlatform, draftTarget] = draftAddr.includes(":")
|
||||
? draftAddr.split(":", 2)
|
||||
: ["slack", draftAddr];
|
||||
const slack = connectors.find((c) => c.name === "slack");
|
||||
const teamId =
|
||||
draftPlatform === "slack" && draftTarget.includes("/")
|
||||
? draftTarget.split("/", 1)[0]
|
||||
: null;
|
||||
const owners =
|
||||
draftPlatform !== "slack"
|
||||
? []
|
||||
: teamId
|
||||
? slack?.workspaces?.find((w) => w.team_id === teamId)?.approval_owner_ids ?? []
|
||||
: slack?.approval_owner_ids ?? [];
|
||||
const missingSlackOwner =
|
||||
draftPlatform === "slack" && draftTarget.length > 0 && owners.length === 0;
|
||||
|
||||
// Show the channel's NAME when the recent list knows it (raw address as the fallback/tooltip).
|
||||
const known = recent.find((c) => c.channel === target)?.name;
|
||||
|
||||
@@ -103,7 +136,11 @@ function InboxRoutingCard() {
|
||||
<Icon name="plug" size={16} />
|
||||
</span>
|
||||
<ChannelPicker value={draft} onChange={setDraft} recent={recent} onSubmit={save} />
|
||||
<button className={BTN_ACCENT_SM} disabled={!draft.trim()} onClick={save}>
|
||||
<button
|
||||
className={BTN_ACCENT_SM}
|
||||
disabled={!draft.trim() || missingSlackOwner}
|
||||
onClick={save}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
{target && (
|
||||
@@ -112,6 +149,12 @@ function InboxRoutingCard() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{missingSlackOwner && (
|
||||
<p className="text-[11.5px] text-warnInk mt-2">
|
||||
Choose an approval owner under Integrations → Slack before routing approvals here.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-[11.5px] text-warnInk mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addSlackApprovalOwner,
|
||||
allowUser,
|
||||
disallowUser,
|
||||
disconnectSlackWorkspace,
|
||||
getSlackDirectory,
|
||||
getSubscriptions,
|
||||
resolveUnauthorized,
|
||||
removeSlackApprovalOwner,
|
||||
unsubscribeChannel,
|
||||
type Connector,
|
||||
type ParkedMessage,
|
||||
@@ -130,10 +132,17 @@ export function SlackDetail({ c, cloud, slack, onChanged }: DetailProps) {
|
||||
<PeopleRow
|
||||
allowed={c.allowed_users}
|
||||
names={c.allowed_user_names}
|
||||
protectedIds={c.approval_owner_ids}
|
||||
teamId={null}
|
||||
onRemove={(u) => disallowUser("slack", u).then(changed)}
|
||||
onChanged={changed}
|
||||
/>
|
||||
<ApprovalOwnersRow
|
||||
owners={c.approval_owner_ids ?? []}
|
||||
names={c.approval_owner_names}
|
||||
editable
|
||||
onChanged={changed}
|
||||
/>
|
||||
{(c.unauthorized ?? [])
|
||||
.filter((m) => !m.team_id)
|
||||
.map((m) => (
|
||||
@@ -209,24 +218,43 @@ function WorkspaceGroup({
|
||||
</div>
|
||||
<div className={GRP}>
|
||||
{empty ? (
|
||||
<div className={ROW}>
|
||||
<span className="min-w-0 flex-1 text-[12.5px] text-muted flex items-center gap-2 flex-wrap">
|
||||
<span>No one allowed yet — mentions of the bot show up here for your OK.</span>
|
||||
<PersonPicker teamId={w.team_id} allowed={[]} onChanged={onChanged} />
|
||||
</span>
|
||||
<DisconnectBtn teamId={w.team_id} busy={busy} onClick={disconnect} />
|
||||
</div>
|
||||
<>
|
||||
<div className={ROW}>
|
||||
<span className="min-w-0 flex-1 text-[12.5px] text-muted flex items-center gap-2 flex-wrap">
|
||||
<span>No one allowed yet — mentions of the bot show up here for your OK.</span>
|
||||
<PersonPicker teamId={w.team_id} allowed={[]} onChanged={onChanged} />
|
||||
</span>
|
||||
<DisconnectBtn teamId={w.team_id} busy={busy} onClick={disconnect} />
|
||||
</div>
|
||||
<ApprovalOwnersRow
|
||||
owners={w.approval_owner_ids ?? []}
|
||||
names={w.approval_owner_names}
|
||||
installerId={w.installer_user_id}
|
||||
installerName={w.installer_name}
|
||||
editable={false}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PeopleRow
|
||||
allowed={w.allowed_users}
|
||||
names={w.allowed_user_names}
|
||||
protectedIds={w.approval_owner_ids}
|
||||
teamId={w.team_id}
|
||||
installerId={w.installer_user_id}
|
||||
installerName={w.installer_name}
|
||||
onRemove={(u) => disallowUser("slack", u, w.team_id).then(onChanged)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<ApprovalOwnersRow
|
||||
owners={w.approval_owner_ids ?? []}
|
||||
names={w.approval_owner_names}
|
||||
installerId={w.installer_user_id}
|
||||
installerName={w.installer_name}
|
||||
editable={false}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
{parked.map((m) => (
|
||||
<WaitingRow key={m.id} m={m} onChanged={onChanged} />
|
||||
))}
|
||||
@@ -259,6 +287,7 @@ function DisconnectBtn({ teamId, busy, onClick }: { teamId: string; busy: boolea
|
||||
function PeopleRow({
|
||||
allowed,
|
||||
names,
|
||||
protectedIds,
|
||||
teamId,
|
||||
installerId,
|
||||
installerName,
|
||||
@@ -267,6 +296,7 @@ function PeopleRow({
|
||||
}: {
|
||||
allowed: string[];
|
||||
names?: Record<string, string | null>;
|
||||
protectedIds?: string[];
|
||||
teamId: string | null; // null = manual flat list (directory queries as "default")
|
||||
installerId?: string; // authed_user — pre-added on managed connect (UX-027)
|
||||
installerName?: string;
|
||||
@@ -296,9 +326,18 @@ function PeopleRow({
|
||||
</span>
|
||||
{label(u)}
|
||||
{u === installerId && <span className="text-[10.5px] text-faint">· you</span>}
|
||||
<button className={XBTN} title="remove" onClick={() => onRemove(u)}>
|
||||
×
|
||||
</button>
|
||||
{protectedIds?.includes(u) ? (
|
||||
<span
|
||||
className="text-[10.5px] text-faint"
|
||||
title="Remove approval-owner access before removing this person."
|
||||
>
|
||||
· owner
|
||||
</span>
|
||||
) : (
|
||||
<button className={XBTN} title="remove" onClick={() => onRemove(u)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<PersonPicker teamId={teamId} allowed={allowed} onChanged={onChanged} />
|
||||
@@ -314,10 +353,16 @@ function PersonPicker({
|
||||
teamId,
|
||||
allowed,
|
||||
onChanged,
|
||||
onPick,
|
||||
buttonLabel = "+ Add person",
|
||||
testId,
|
||||
}: {
|
||||
teamId: string | null;
|
||||
allowed: string[];
|
||||
onChanged: () => void;
|
||||
onPick?: (member: SlackMember) => Promise<{ ok: boolean; error?: string }>;
|
||||
buttonLabel?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
@@ -360,7 +405,13 @@ function PersonPicker({
|
||||
}, [open]);
|
||||
|
||||
const pick = async (m: SlackMember) => {
|
||||
await allowUser("slack", m.id, teamId, m.name);
|
||||
const result = onPick
|
||||
? await onPick(m)
|
||||
: await allowUser("slack", m.id, teamId, m.name);
|
||||
if (result?.ok === false) {
|
||||
setErr(result.error || "could not add person");
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
onChanged();
|
||||
@@ -372,11 +423,11 @@ function PersonPicker({
|
||||
<button
|
||||
ref={btn}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full border border-dashed border-line text-[12.5px] text-muted hover:text-ink hover:border-faint"
|
||||
data-testid={`add-person-${teamId || "default"}`}
|
||||
data-testid={testId || `add-person-${teamId || "default"}`}
|
||||
title="Pick from the workspace directory"
|
||||
onClick={toggle}
|
||||
>
|
||||
+ Add person
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
@@ -432,6 +483,80 @@ function PersonPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalOwnersRow({
|
||||
owners,
|
||||
names,
|
||||
installerId,
|
||||
installerName,
|
||||
editable,
|
||||
onChanged,
|
||||
}: {
|
||||
owners: string[];
|
||||
names?: Record<string, string | null>;
|
||||
installerId?: string;
|
||||
installerName?: string;
|
||||
editable: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const label = (u: string) =>
|
||||
names?.[u] || (u === installerId ? installerName || "You" : u);
|
||||
const remove = async (userId: string) => {
|
||||
const result = await removeSlackApprovalOwner(userId);
|
||||
if (!result.ok) {
|
||||
setErr(result.error || "could not remove approval owner");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
onChanged();
|
||||
};
|
||||
return (
|
||||
<div className={ROW} data-testid="slack-approval-owners">
|
||||
<span className={LABEL}>Approvals</span>
|
||||
<span className="min-w-0 flex-1 flex flex-wrap items-center gap-1.5">
|
||||
{owners.length === 0 && (
|
||||
<span className="text-[12px] text-warnInk">
|
||||
Choose at least one owner before routing Inbox approvals to Slack.
|
||||
</span>
|
||||
)}
|
||||
{owners.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="inline-flex items-center gap-1.5 pl-1 pr-2 py-0.5 rounded-full bg-paper border border-line text-[12.5px]"
|
||||
title={`id ${u}`}
|
||||
data-testid={`approval-owner-${u}`}
|
||||
>
|
||||
<span className="w-5 h-5 rounded-full bg-accentSoft text-accent grid place-items-center text-[9px] font-bold">
|
||||
{initials(label(u))}
|
||||
</span>
|
||||
{label(u)}
|
||||
{u === installerId && <span className="text-[10.5px] text-faint">· installer</span>}
|
||||
{editable && (
|
||||
<button className={XBTN} title="remove approval owner" onClick={() => remove(u)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{editable && (
|
||||
<PersonPicker
|
||||
teamId={null}
|
||||
allowed={owners}
|
||||
onChanged={onChanged}
|
||||
onPick={(m) => addSlackApprovalOwner(m.id, m.name)}
|
||||
buttonLabel="+ Add owner"
|
||||
testId="add-approval-owner"
|
||||
/>
|
||||
)}
|
||||
{!editable && owners.length > 0 && (
|
||||
<span className="text-[11.5px] text-faint">Set by the workspace installer.</span>
|
||||
)}
|
||||
{err && <span className="basis-full text-[11.5px] text-warnInk">{err}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingRow({ m, onChanged }: { m: ParkedMessage; onChanged: () => void }) {
|
||||
const act = async (action: "dismiss" | "allow" | "allow_deliver") => {
|
||||
await resolveUnauthorized("slack", m.id, action);
|
||||
|
||||
Reference in New Issue
Block a user