Files
lotteryAdmin/src/modules/integration/integration-sites-console.tsx
kang b774e22352 refactor(i18n, admin): remove page guide components and documentation links across modules
Deleted AdminPageGuide component and ADMIN_DOC_LINKS constants. Removed pageGuide text from Chinese translations and eliminated guide displays from agents, draws, integration sites, players, reports, and settlement center consoles. Cleaned up unused CardDescription elements in reconcile console.
2026-06-15 18:06:43 +08:00

1028 lines
38 KiB
TypeScript

"use client";
import { Copy, Download, Link2, Pencil, ShieldAlert, Trash2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
deleteAdminIntegrationSite,
getAdminIntegrationSite,
getAdminIntegrationSiteExport,
getAdminIntegrationSites,
getAdminIntegrationSiteSecrets,
postAdminIntegrationSite,
postAdminIntegrationSiteConnectivityTest,
postAdminIntegrationSiteRotateSecrets,
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminIntegrationSiteCreatePayload,
AdminIntegrationSiteConnectivityResult,
AdminIntegrationSiteUpdatePayload,
AdminIntegrationSiteRow,
AdminIntegrationSiteDetail,
AdminIntegrationSiteSecrets,
AdminIntegrationSiteWithSecrets,
} from "@/types/api/admin-integration-site";
function CopyIconButton({
label,
onClick,
disabled,
busy,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
busy?: boolean;
}): React.ReactElement {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={disabled || busy}
aria-label={label}
title={label}
onClick={onClick}
>
<Copy className="h-3.5 w-3.5" />
</Button>
);
}
function MaskedValueWithCopy({
configured,
masked,
copyLabel,
canCopy,
copying,
onCopy,
}: {
configured: boolean;
masked: string | null;
copyLabel: string;
canCopy: boolean;
copying: boolean;
onCopy: () => void;
}): React.ReactElement {
return (
<div className="flex min-w-[7.5rem] max-w-[11rem] items-center gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">
{configured ? (masked ?? "••••••••") : "—"}
</span>
{configured && canCopy ? (
<CopyIconButton label={copyLabel} onClick={onCopy} busy={copying} />
) : null}
</div>
);
}
type FormState = {
code: string;
name: string;
admin_username: string;
admin_nickname: string;
admin_password: string;
admin_email: string;
currency_code: string;
status: number;
wallet_api_url: string;
wallet_debit_path: string;
wallet_credit_path: string;
wallet_balance_path: string;
wallet_timeout_seconds: string;
iframe_allowed_origins: string;
lottery_h5_base_url: string;
notes: string;
};
const EMPTY_FORM: FormState = {
code: "",
name: "",
admin_username: "",
admin_nickname: "",
admin_password: "",
admin_email: "",
currency_code: "NPR",
status: 1,
wallet_api_url: "",
wallet_debit_path: "/wallet/debit-for-lottery",
wallet_credit_path: "/wallet/credit-from-lottery",
wallet_balance_path: "/wallet/balance",
wallet_timeout_seconds: "10",
iframe_allowed_origins: "",
lottery_h5_base_url: "",
notes: "",
};
function originsToText(origins: string[] | undefined): string {
return (origins ?? []).join("\n");
}
function textToOrigins(text: string): string[] {
return text
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
function rowToForm(row: AdminIntegrationSiteDetail): FormState {
return {
code: row.code,
name: row.name,
admin_username: "",
admin_nickname: "",
admin_password: "",
admin_email: "",
currency_code: row.currency_code,
status: row.status,
wallet_api_url: row.wallet_api_url ?? "",
wallet_debit_path: row.wallet_debit_path ?? "/wallet/debit-for-lottery",
wallet_credit_path: row.wallet_credit_path ?? "/wallet/credit-from-lottery",
wallet_balance_path: row.wallet_balance_path ?? "/wallet/balance",
wallet_timeout_seconds: String(row.wallet_timeout_seconds ?? 10),
iframe_allowed_origins: originsToText(row.iframe_allowed_origins as string[] | undefined),
lottery_h5_base_url: row.lottery_h5_base_url ?? "",
notes: row.notes ?? "",
};
}
function formToPayload(form: FormState, includeCode: true): AdminIntegrationSiteCreatePayload;
function formToPayload(form: FormState, includeCode: false): AdminIntegrationSiteUpdatePayload;
function formToPayload(
form: FormState,
includeCode: boolean,
): AdminIntegrationSiteCreatePayload | AdminIntegrationSiteUpdatePayload {
const base = {
name: form.name.trim(),
currency_code: form.currency_code.trim() || "NPR",
status: form.status,
wallet_api_url: form.wallet_api_url.trim() || null,
wallet_debit_path: form.wallet_debit_path.trim(),
wallet_credit_path: form.wallet_credit_path.trim(),
wallet_balance_path: form.wallet_balance_path.trim(),
wallet_timeout_seconds: Number.parseInt(form.wallet_timeout_seconds, 10) || 10,
iframe_allowed_origins: textToOrigins(form.iframe_allowed_origins),
lottery_h5_base_url: form.lottery_h5_base_url.trim() || null,
notes: form.notes.trim() || null,
};
if (includeCode) {
return {
code: form.code.trim(),
admin_account: {
username: form.admin_username.trim(),
nickname: form.admin_nickname.trim(),
password: form.admin_password,
email: form.admin_email.trim() || null,
},
...base,
};
}
return base;
}
type IntegrationSitesConsoleProps = {
/** 为 true 时仅超管可新建站点(默认有 integration.site.manage 即可创建)。 */
restrictCreateToSuperAdmin?: boolean;
};
export function IntegrationSitesConsole({
restrictCreateToSuperAdmin = false,
}: IntegrationSitesConsoleProps = {}) {
const { t } = useTranslation("config");
const tRef = useTranslationRef("config");
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(
profile?.permissions,
getAdminPageBundle("integration-sites", "manage"),
);
const canCreate =
canManage &&
(!restrictCreateToSuperAdmin || profile?.is_super_admin === true);
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [mode, setMode] = useState<"create" | "edit">("create");
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [rotateBusy, setRotateBusy] = useState(false);
const [secretsDialog, setSecretsDialog] = useState<{
siteCode: string;
secrets: AdminIntegrationSiteSecrets;
} | null>(null);
const [connectivityTarget, setConnectivityTarget] = useState<AdminIntegrationSiteRow | null>(
null,
);
const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001");
const [connectivityCurrency, setConnectivityCurrency] = useState("NPR");
const [connectivityBusy, setConnectivityBusy] = useState(false);
const [connectivityResult, setConnectivityResult] =
useState<AdminIntegrationSiteConnectivityResult | null>(null);
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
const [secretCopyBusyKey, setSecretCopyBusyKey] = useState<string | null>(null);
const secretsCacheRef = useRef(new Map<number, AdminIntegrationSiteSecrets>());
const load = useCallback(async () => {
setLoading(true);
secretsCacheRef.current.clear();
try {
const data = await getAdminIntegrationSites();
setItems(data.items);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"),
);
} finally {
setLoading(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load();
}, []);
function openCreate(): void {
setMode("create");
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
}
async function openEdit(row: AdminIntegrationSiteRow): Promise<void> {
setMode("edit");
setEditingId(row.id);
setDialogOpen(true);
try {
const detail = await getAdminIntegrationSite(row.id);
setForm(rowToForm(detail));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
);
setDialogOpen(false);
}
}
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]): void {
setForm((prev) => ({ ...prev, [key]: value }));
}
function showSecretsOnce(result: AdminIntegrationSiteWithSecrets): void {
if (result.secrets) {
setSecretsDialog({ siteCode: result.code, secrets: result.secrets });
}
}
async function handleSubmit(): Promise<void> {
if (!canManage) return;
if (form.name.trim() === "") {
toast.error(t("integrationSites.form.required"));
return;
}
if (mode === "create" && form.code.trim() === "") {
toast.error(t("integrationSites.form.codeRequired"));
return;
}
if (mode === "create" && form.admin_username.trim() === "") {
toast.error(t("integrationSites.form.adminUsernameRequired"));
return;
}
if (mode === "create" && form.admin_nickname.trim() === "") {
toast.error(t("integrationSites.form.adminNicknameRequired"));
return;
}
if (mode === "create" && form.admin_password.trim().length < 8) {
toast.error(t("integrationSites.form.adminPasswordRequired"));
return;
}
setSaving(true);
try {
if (mode === "create") {
const created = await postAdminIntegrationSite(formToPayload(form, true));
toast.success(t("integrationSites.createSuccess", { code: created.code }));
if (created.admin_user?.username) {
toast.success(
t("integrationSites.adminAccountCreated", {
username: created.admin_user.username,
defaultValue: "已同时创建站点后台账号 {{username}}",
}),
);
}
showSecretsOnce(created);
} else if (editingId !== null) {
await putAdminIntegrationSite(editingId, formToPayload(form, false));
toast.success(t("integrationSites.updateSuccess", { code: form.code }));
}
setDialogOpen(false);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.saveFailed"),
);
} finally {
setSaving(false);
}
}
async function confirmRotate(): Promise<void> {
if (!rotateTarget || !canManage) return;
setRotateBusy(true);
try {
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
setRotateTarget(null);
secretsCacheRef.current.delete(rotateTarget.id);
showSecretsOnce(result);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.rotateFailed"),
);
} finally {
setRotateBusy(false);
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget || !canManage) return;
setDeleteBusy(true);
try {
await deleteAdminIntegrationSite(deleteTarget.id);
toast.success(t("integrationSites.deleteSuccess", { code: deleteTarget.code }));
secretsCacheRef.current.delete(deleteTarget.id);
setDeleteTarget(null);
await load();
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.deleteFailed"),
);
} finally {
setDeleteBusy(false);
}
}
function openConnectivity(row: AdminIntegrationSiteRow): void {
setConnectivityTarget(row);
setConnectivityPlayerId("10001");
setConnectivityCurrency(row.currency_code || "NPR");
setConnectivityResult(null);
}
async function runConnectivityTest(): Promise<void> {
if (!connectivityTarget) return;
setConnectivityBusy(true);
setConnectivityResult(null);
try {
const result = await postAdminIntegrationSiteConnectivityTest(connectivityTarget.id, {
site_player_id: connectivityPlayerId.trim(),
currency_code: connectivityCurrency.trim() || undefined,
});
setConnectivityResult(result);
if (result.probe.success) {
toast.success(t("integrationSites.connectivitySuccess"));
} else {
toast.error(result.probe.message ?? t("integrationSites.connectivityFailed"));
}
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("integrationSites.connectivityFailed"),
);
} finally {
setConnectivityBusy(false);
}
}
async function exportParameterSheet(row: AdminIntegrationSiteRow): Promise<void> {
setExportBusyId(row.id);
try {
const sheet = await getAdminIntegrationSiteExport(row.id);
const blob = new Blob([JSON.stringify(sheet, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `integration-${row.code}.json`;
anchor.click();
URL.revokeObjectURL(url);
toast.success(t("integrationSites.exportSuccess", { code: row.code }));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.exportFailed"),
);
} finally {
setExportBusyId(null);
}
}
async function copyText(label: string, value: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
toast.success(t("integrationSites.copied", { field: label }));
} catch {
toast.error(t("integrationSites.copyFailed"));
}
}
async function resolveSiteSecrets(siteId: number): Promise<AdminIntegrationSiteSecrets> {
const cached = secretsCacheRef.current.get(siteId);
if (cached) {
return cached;
}
const secrets = await getAdminIntegrationSiteSecrets(siteId);
secretsCacheRef.current.set(siteId, secrets);
return secrets;
}
async function copySiteSecret(
row: AdminIntegrationSiteRow,
field: "sso" | "wallet",
): Promise<void> {
if (!canManage) {
toast.error(t("integrationSites.secretCopyRequiresManage"));
return;
}
const configured = field === "sso" ? row.has_sso_secret : row.has_wallet_api_key;
if (!configured) {
toast.error(t("integrationSites.secretNotConfigured"));
return;
}
const busyKey = `${row.id}:${field}`;
setSecretCopyBusyKey(busyKey);
try {
const secrets = await resolveSiteSecrets(row.id);
const value = field === "sso" ? secrets.sso_jwt_secret : secrets.wallet_api_key;
if (!value) {
toast.error(t("integrationSites.secretNotConfigured"));
return;
}
await copyText(
field === "sso"
? t("integrationSites.fields.ssoSecret")
: t("integrationSites.fields.walletApiKey"),
value,
);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.copyFailed"),
);
} finally {
setSecretCopyBusyKey(null);
}
}
return (
<>
<AdminPageCard
title={t("integrationSites.title")}
description={t("integrationSites.description")}
actions={
canCreate ? (
<Button type="button" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null
}
>
{loading ? (
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? (
<AdminNoResourceState message={t("integrationSites.empty")}>
{canCreate ? (
<Button type="button" size="sm" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null}
</AdminNoResourceState>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("integrationSites.columns.code")}</TableHead>
<TableHead>{t("integrationSites.columns.name")}</TableHead>
<TableHead>{t("integrationSites.columns.currency")}</TableHead>
<TableHead>{t("integrationSites.columns.status")}</TableHead>
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
<TableHead>{t("integrationSites.columns.ssoSecret")}</TableHead>
<TableHead>{t("integrationSites.columns.walletApiKey")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="flex max-w-[10rem] items-center gap-0.5">
<span className="truncate font-mono text-xs">{row.code}</span>
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() => void copyText(t("integrationSites.columns.code"), row.code)}
/>
</div>
</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell className="font-mono text-xs">{row.currency_code}</TableCell>
<TableCell>
<AdminStatusBadge
tone={row.status === 1 ? "success" : "neutral"}
>
{row.status === 1
? t("integrationSites.statusEnabled")
: t("integrationSites.statusDisabled")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<AdminStatusBadge tone={row.has_line_root ? "success" : "neutral"}>
{row.has_line_root
? t("integrationSites.lineRootBound")
: t("integrationSites.lineRootUnbound")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex max-w-[14rem] items-center gap-0.5">
<span className="truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
</span>
{row.wallet_api_url ? (
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.columns.walletUrl"),
row.wallet_api_url ?? "",
)
}
/>
) : null}
</div>
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_sso_secret}
masked={row.sso_secret_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:sso`}
onCopy={() => void copySiteSecret(row, "sso")}
/>
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_wallet_api_key}
masked={row.wallet_api_key_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:wallet`}
onCopy={() => void copySiteSecret(row, "wallet")}
/>
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={exportBusyId === row.id}
actions={[
{
key: "connectivity",
label: t("integrationSites.connectivityTest"),
icon: Link2,
hidden: !canManage,
onClick: () => openConnectivity(row),
},
{
key: "export",
label: t("integrationSites.exportParams"),
icon: Download,
disabled: exportBusyId === row.id,
onClick: () => void exportParameterSheet(row),
},
{
key: "edit",
label: t("integrationSites.edit"),
icon: Pencil,
hidden: !canManage,
onClick: () => void openEdit(row),
},
{
key: "rotate",
label: t("integrationSites.rotateSecrets"),
icon: ShieldAlert,
destructive: true,
hidden: !canManage,
onClick: () => setRotateTarget(row),
},
{
key: "delete",
label: t("integrationSites.delete"),
icon: Trash2,
destructive: true,
hidden: !canManage,
onClick: () => setDeleteTarget(row),
},
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</AdminPageCard>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create"
? t("integrationSites.dialogCreateTitle")
: t("integrationSites.dialogEditTitle")}
</DialogTitle>
<DialogDescription>{t("integrationSites.dialogDescription")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="is-code">{t("integrationSites.fields.code")}</Label>
<Input
id="is-code"
value={form.code}
disabled={mode === "edit"}
onChange={(e) => updateForm("code", e.target.value)}
placeholder={t("integrationSites.placeholders.code")}
/>
{mode === "edit" ? (
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="is-name">{t("integrationSites.fields.name")}</Label>
<Input
id="is-name"
value={form.name}
placeholder={t("integrationSites.placeholders.name")}
onChange={(e) => updateForm("name", e.target.value)}
/>
</div>
{mode === "create" ? (
<>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="mb-3">
<p className="text-sm font-medium text-foreground">
{t("integrationSites.adminAccountSectionTitle", {
defaultValue: "站点后台管理账号",
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("integrationSites.adminAccountSectionDescription", {
defaultValue: "创建站点时将同步创建一个绑定该站点的后台管理账号。",
})}
</p>
</div>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-admin-username">
{t("integrationSites.fields.adminUsername", { defaultValue: "后台登录名" })}
</Label>
<Input
id="is-admin-username"
value={form.admin_username}
placeholder={t("integrationSites.placeholders.adminUsername", {
defaultValue: "请输入后台登录名",
})}
onChange={(e) => updateForm("admin_username", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-admin-nickname">
{t("integrationSites.fields.adminNickname", { defaultValue: "账号昵称" })}
</Label>
<Input
id="is-admin-nickname"
value={form.admin_nickname}
placeholder={t("integrationSites.placeholders.adminNickname", {
defaultValue: "请输入账号昵称",
})}
onChange={(e) => updateForm("admin_nickname", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-admin-password">
{t("integrationSites.fields.adminPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="is-admin-password"
type="password"
value={form.admin_password}
placeholder={t("integrationSites.placeholders.adminPassword", {
defaultValue: "至少 8 位",
})}
onChange={(e) => updateForm("admin_password", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-admin-email">
{t("integrationSites.fields.adminEmail", { defaultValue: "邮箱(可选)" })}
</Label>
<Input
id="is-admin-email"
value={form.admin_email}
placeholder={t("integrationSites.placeholders.adminEmail", {
defaultValue: "请输入邮箱",
})}
onChange={(e) => updateForm("admin_email", e.target.value)}
/>
</div>
</div>
</div>
</div>
</>
) : null}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
<Input
id="is-currency"
value={form.currency_code}
placeholder={t("integrationSites.placeholders.currency")}
onChange={(e) => updateForm("currency_code", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-status">{t("integrationSites.fields.status")}</Label>
<select
id="is-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={form.status}
onChange={(e) => updateForm("status", Number(e.target.value))}
>
<option value={1}>{t("integrationSites.statusEnabled")}</option>
<option value={0}>{t("integrationSites.statusDisabled")}</option>
</select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="is-wallet-url">{t("integrationSites.fields.walletApiUrl")}</Label>
<Input
id="is-wallet-url"
value={form.wallet_api_url}
placeholder={t("integrationSites.placeholders.walletApiUrl")}
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
<Textarea
id="is-origins"
rows={3}
value={form.iframe_allowed_origins}
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
placeholder={t("integrationSites.placeholders.iframeOrigins")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="is-notes">{t("integrationSites.fields.notes")}</Label>
<Textarea
id="is-notes"
rows={2}
value={form.notes}
placeholder={t("integrationSites.placeholders.notes")}
onChange={(e) => updateForm("notes", e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("integrationSites.cancel")}
</Button>
{canManage ? (
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? t("integrationSites.saving") : t("integrationSites.save")}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rotateTarget !== null} onOpenChange={(open) => !open && setRotateTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.rotateConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.rotateConfirmDescription", {
code: rotateTarget?.code ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRotateTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={rotateBusy}
onClick={() => void confirmRotate()}
>
{rotateBusy ? t("integrationSites.saving") : t("integrationSites.rotateConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.deleteConfirmDescription", {
code: deleteTarget?.code ?? "",
name: deleteTarget?.name ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? t("integrationSites.deleting") : t("integrationSites.deleteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={connectivityTarget !== null}
onOpenChange={(open) => {
if (!open) {
setConnectivityTarget(null);
setConnectivityResult(null);
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("integrationSites.connectivityTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.connectivityDescription", {
code: connectivityTarget?.code ?? "",
})}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="ct-player">{t("integrationSites.connectivityPlayerId")}</Label>
<Input
id="ct-player"
value={connectivityPlayerId}
onChange={(e) => setConnectivityPlayerId(e.target.value)}
placeholder={t("integrationSites.placeholders.connectivityPlayerId")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ct-currency">{t("integrationSites.fields.currency")}</Label>
<Input
id="ct-currency"
value={connectivityCurrency}
placeholder={t("integrationSites.placeholders.currency")}
onChange={(e) => setConnectivityCurrency(e.target.value)}
/>
</div>
{connectivityResult ? (
<div className="rounded-md border bg-muted/40 p-3 text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(connectivityResult.probe, null, 2)}
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConnectivityTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
disabled={!canManage || connectivityBusy || connectivityPlayerId.trim() === ""}
onClick={() => void runConnectivityTest()}
>
{connectivityBusy
? t("integrationSites.connectivityRunning")
: t("integrationSites.connectivityRun")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={secretsDialog !== null} onOpenChange={(open) => !open && setSecretsDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.secretsTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.secretsDescription", { code: secretsDialog?.siteCode ?? "" })}
</DialogDescription>
</DialogHeader>
{secretsDialog ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.ssoSecret"),
secretsDialog.secrets.sso_jwt_secret,
)
}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.walletApiKey"),
secretsDialog.secrets.wallet_api_key,
)
}
/>
</div>
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" onClick={() => setSecretsDialog(null)}>
{t("integrationSites.secretsDismiss")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}