"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 (
);
}
function MaskedValueWithCopy({
configured,
masked,
copyLabel,
canCopy,
copying,
onCopy,
}: {
configured: boolean;
masked: string | null;
copyLabel: string;
canCopy: boolean;
copying: boolean;
onCopy: () => void;
}): React.ReactElement {
return (
{configured ? (masked ?? "••••••••") : "—"}
{configured && canCopy ? (
) : null}
);
}
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([]);
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(null);
const [form, setForm] = useState(EMPTY_FORM);
const [rotateTarget, setRotateTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(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(
null,
);
const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001");
const [connectivityCurrency, setConnectivityCurrency] = useState("NPR");
const [connectivityBusy, setConnectivityBusy] = useState(false);
const [connectivityResult, setConnectivityResult] =
useState(null);
const [exportBusyId, setExportBusyId] = useState(null);
const [secretCopyBusyKey, setSecretCopyBusyKey] = useState(null);
const secretsCacheRef = useRef(new Map());
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 {
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(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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 (
<>
{t("integrationSites.create")}
) : null
}
>
{loading ? (
) : items.length === 0 ? (
{canCreate ? (
) : null}
) : (
{t("integrationSites.columns.code")}
{t("integrationSites.columns.name")}
{t("integrationSites.columns.currency")}
{t("integrationSites.columns.status")}
{t("integrationSites.columns.lineRoot")}
{t("integrationSites.columns.walletUrl")}
{t("integrationSites.columns.ssoSecret")}
{t("integrationSites.columns.walletApiKey")}
{t("integrationSites.columns.actions")}
{items.map((row) => (
{row.code}
void copyText(t("integrationSites.columns.code"), row.code)}
/>
{row.name}
{row.currency_code}
{row.status === 1
? t("integrationSites.statusEnabled")
: t("integrationSites.statusDisabled")}
{row.has_line_root
? t("integrationSites.lineRootBound")
: t("integrationSites.lineRootUnbound")}
{row.wallet_api_url ?? "—"}
{row.wallet_api_url ? (
void copyText(
t("integrationSites.columns.walletUrl"),
row.wallet_api_url ?? "",
)
}
/>
) : null}
void copySiteSecret(row, "sso")}
/>
void copySiteSecret(row, "wallet")}
/>
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),
},
]}
/>
))}
)}
>
);
}