347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
"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<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"]) */
|
|
enabledChannels: string[];
|
|
/** Current channelUsers from the PiecedTenant spec */
|
|
initialChannelUsers: Record<string, string[]>;
|
|
/** 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<Record<string, string>>({});
|
|
const [channelUsers, setChannelUsers] =
|
|
useState<Record<string, string[]>>(initialChannelUsers);
|
|
/** Which channel's QR helper modal is open, if any. */
|
|
const [showQrFor, setShowQrFor] = useState<string | null>(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<Set<string>>(() => new Set());
|
|
|
|
const updateChannelUsers = useCallback(
|
|
async (updated: Record<string, string[]>) => {
|
|
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 (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
|
|
{t("title")}
|
|
</h3>
|
|
<p className="text-xs text-text-muted mb-4">{t("description")}</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
|
{error}
|
|
<button
|
|
onClick={() => setError("")}
|
|
className="ml-2 text-red-300 hover:text-red-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{enabledChannels.map((channel) => {
|
|
const users = channelUsers[channel] || [];
|
|
const helpKey = CHANNEL_ID_HELP[channel];
|
|
|
|
return (
|
|
<div
|
|
key={channel}
|
|
className="bg-surface-2 border border-border rounded-lg p-4"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-sm font-medium text-text-primary capitalize">
|
|
{channel}
|
|
</h4>
|
|
<span className="text-xs text-text-muted tabular-nums">
|
|
{users.length} {t("users")}
|
|
</span>
|
|
</div>
|
|
|
|
{channel === "threema" && (
|
|
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
|
|
<div className="flex items-start gap-2 flex-1">
|
|
<svg
|
|
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
|
|
/>
|
|
</svg>
|
|
<div className="text-xs text-text-secondary leading-relaxed">
|
|
<p className="font-medium text-text-primary mb-0.5">
|
|
{t("threemaSetup.bannerTitle")}
|
|
</p>
|
|
<p>{t("threemaSetup.bannerBody")}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowQrFor("threema")}
|
|
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
|
|
>
|
|
{t("threemaSetup.bannerButton")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{helpKey && (
|
|
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
|
|
{t(helpKey)}
|
|
</p>
|
|
)}
|
|
|
|
{/* Current users */}
|
|
{users.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{users.map((userId) => (
|
|
<span
|
|
key={userId}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full"
|
|
>
|
|
{userId}
|
|
{canEdit && (
|
|
<button
|
|
onClick={() => handleRemove(channel, userId)}
|
|
disabled={saving}
|
|
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
|
|
title={t("remove")}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add user — hidden in read-only mode */}
|
|
{canEdit && (
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={inputValues[channel] || ""}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
<button
|
|
onClick={() => handleAdd(channel)}
|
|
disabled={saving || !inputValues[channel]?.trim()}
|
|
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{saving ? "…" : t("add")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<ThreemaQrModal
|
|
open={showQrFor === "threema"}
|
|
onClose={() => setShowQrFor(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
} |