This commit is contained in:
@@ -8,12 +8,26 @@ import { useRouter } from "next/navigation";
|
||||
const CHANNEL_ID_HELP: Record<string, string> = {
|
||||
telegram: "telegramIdHelp",
|
||||
discord: "discordIdHelp",
|
||||
threema: "threemaIdHelp",
|
||||
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
|
||||
// the `mail` skill (category=skill, not channel), so it never appears
|
||||
// in `enabledChannels`. If a future channel is added to the catalog,
|
||||
// give it an entry here so the help blurb renders.
|
||||
};
|
||||
|
||||
/**
|
||||
* Channels whose user list is managed through a dedicated endpoint
|
||||
* instead of the generic PATCH /api/tenants/:name flow.
|
||||
*
|
||||
* Threema is the only one today — adding/removing a Threema ID has to
|
||||
* synchronise with the central pieced-threema-gateway relay's `routes`
|
||||
* table (uniqueness enforced there, not in K8s). The
|
||||
* /api/tenants/:name/threema/routes endpoint owns that two-step
|
||||
* coordination (relay first, then K8s, with compensation on K8s
|
||||
* failure). Other channels just patch the K8s spec directly.
|
||||
*/
|
||||
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
|
||||
|
||||
interface ChannelUsersProps {
|
||||
tenantName: string;
|
||||
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
|
||||
@@ -63,6 +77,70 @@ export function ChannelUsers({
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
/**
|
||||
* Threema (and any future relay-managed channel) uses a dedicated
|
||||
* endpoint that synchronises the central relay's routes table with
|
||||
* the K8s spec atomically. We call it per-ID rather than sending the
|
||||
* whole array because uniqueness is enforced ID-by-ID at the relay,
|
||||
* and the error UX of "this ID is taken" is per-add.
|
||||
*/
|
||||
const addToRelayChannel = useCallback(
|
||||
async (channel: string, threemaId: string) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tenants/${tenantName}/${channel}/routes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ threemaId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
|
||||
}
|
||||
setChannelUsers((prev) => {
|
||||
const current = prev[channel] ?? [];
|
||||
if (current.includes(threemaId)) return prev;
|
||||
return { ...prev, [channel]: [...current, threemaId] };
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
const removeFromRelayChannel = useCallback(
|
||||
async (channel: string, threemaId: string) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
|
||||
}
|
||||
setChannelUsers((prev) => {
|
||||
const current = prev[channel] ?? [];
|
||||
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[tenantName, router]
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(channel: string) => {
|
||||
const userId = inputValues[channel]?.trim();
|
||||
@@ -74,18 +152,27 @@ export function ChannelUsers({
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
setInputValues((prev) => ({ ...prev, [channel]: "" }));
|
||||
updateChannelUsers(updated);
|
||||
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void addToRelayChannel(channel, userId);
|
||||
} else {
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
}
|
||||
},
|
||||
[channelUsers, inputValues, updateChannelUsers, t]
|
||||
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(channel: string, userId: string) => {
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void removeFromRelayChannel(channel, userId);
|
||||
return;
|
||||
}
|
||||
const current = channelUsers[channel] || [];
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
@@ -93,7 +180,7 @@ export function ChannelUsers({
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
},
|
||||
[channelUsers, updateChannelUsers]
|
||||
[channelUsers, updateChannelUsers, removeFromRelayChannel]
|
||||
);
|
||||
|
||||
if (enabledChannels.length === 0) return null;
|
||||
|
||||
@@ -30,6 +30,26 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleEnable() {
|
||||
if (pkg.customProvisioning) {
|
||||
// Platform-side provisioning, then add to packages list.
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!provRes.ok) {
|
||||
const err = await provRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pkg.requiresSecrets) {
|
||||
setShowModal(true);
|
||||
setSecrets({});
|
||||
@@ -40,6 +60,34 @@ export function PackageCard({
|
||||
await togglePackage(true);
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (pkg.customProvisioning) {
|
||||
// Revoke platform-side credentials FIRST so the relay drops
|
||||
// routes before the operator removes the channel from the
|
||||
// OpenClaw config. Partial-success (token revoked, OpenBao
|
||||
// delete failed) returns 503 with partial=true and we surface
|
||||
// the error rather than continuing — the secret may still be
|
||||
// valid in OpenBao and rolling back the relay revoke isn't
|
||||
// possible (it cascaded to routes).
|
||||
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!deprovRes.ok) {
|
||||
const err = await deprovRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
|
||||
}
|
||||
}
|
||||
await togglePackage(false);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePackage(enable: boolean) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -124,7 +172,7 @@ export function PackageCard({
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
|
||||
Reference in New Issue
Block a user