"use client"; import { useState, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { ThreemaQrModal } from "./threema-qr-modal"; /** Maps channel IDs to the instructions for finding the user ID. */ const CHANNEL_ID_HELP: Record = { 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"]) */ enabledChannels: string[]; /** Current channelUsers from the PiecedTenant spec */ initialChannelUsers: Record; /** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */ canEdit?: boolean; } export function ChannelUsers({ tenantName, enabledChannels, initialChannelUsers, canEdit = true, }: ChannelUsersProps) { const t = useTranslations("channelUsers"); const router = useRouter(); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [inputValues, setInputValues] = useState>({}); const [channelUsers, setChannelUsers] = useState>(initialChannelUsers); /** Which channel's QR helper modal is open, if any. */ const [showQrFor, setShowQrFor] = useState(null); /** * Tracks channels for which we've already auto-opened the helper * modal on this page load. Prevents the modal from re-popping every * time the user refocuses the input after dismissing it. */ const [autoOpened, setAutoOpened] = useState>(() => new Set()); const updateChannelUsers = useCallback( async (updated: Record) => { setSaving(true); setError(""); try { const res = await fetch(`/api/tenants/${tenantName}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channelUsers: updated }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Update failed"); } setChannelUsers(updated); router.refresh(); } catch (e: any) { setError(e.message); } finally { setSaving(false); } }, [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(); if (!userId) return; const current = channelUsers[channel] || []; if (current.includes(userId)) { setError(t("alreadyAdded")); return; } setInputValues((prev) => ({ ...prev, [channel]: "" })); if (RELAY_MANAGED_CHANNELS.has(channel)) { void addToRelayChannel(channel, userId); } else { const updated = { ...channelUsers, [channel]: [...current, userId], }; updateChannelUsers(updated); } }, [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, [channel]: current.filter((id) => id !== userId), }; updateChannelUsers(updated); }, [channelUsers, updateChannelUsers, removeFromRelayChannel] ); if (enabledChannels.length === 0) return null; return (

{t("title")}

{t("description")}

{error && (
{error}
)} {enabledChannels.map((channel) => { const users = channelUsers[channel] || []; const helpKey = CHANNEL_ID_HELP[channel]; return (

{channel}

{users.length} {t("users")}
{channel === "threema" && (

{t("threemaSetup.bannerTitle")}

{t("threemaSetup.bannerBody")}

)} {helpKey && (

{t(helpKey)}

)} {/* Current users */} {users.length > 0 && (
{users.map((userId) => ( {userId} {canEdit && ( )} ))}
)} {/* Add user — hidden in read-only mode */} {canEdit && (
setInputValues((prev) => ({ ...prev, [channel]: e.target.value, })) } onFocus={() => { // For threema specifically, open the QR helper the // first time the user clicks into the input on this // page load. We don't repeat after dismiss — the // "Show QR" button next to the channel name covers // re-opens on demand. if (channel === "threema" && !autoOpened.has("threema")) { setShowQrFor("threema"); setAutoOpened((prev) => new Set(prev).add("threema")); } }} onKeyDown={(e) => { if (e.key === "Enter") handleAdd(channel); }} placeholder={t("placeholder")} className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" />
)}
); })} setShowQrFor(null)} />
); }