XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
import { useEffect, useState, type FormEvent } from 'react';
import QRCode from 'qrcode';
import { Boxes, Cable, KeyRound, LockKeyhole, Network, ShieldAlert, ShieldCheck, Trash2, UserPlus, Users } from 'lucide-react';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import type { AccountView, CrashProtectionSettings, FeatureFlags, OperationStatus, Role, ServerSettings, TotpEnrollment } from '../types';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Select, TableShell, Textarea, Toast } from '../components/ui';

const featureSwitches = [
  { flag: 'consoleEnabled', setting: 'webConsoleEnabled', label: 'Web 控制台 / Web console', restart: false },
  { flag: 'moderationEnabled', setting: 'moderationEnabled', label: '处罚与维护 / Moderation', restart: false },
  { flag: 'claimsEnabled', setting: 'claimsEnabled', label: '区块认领 / Claims', restart: false },
  { flag: 'worldTrackingEnabled', setting: 'worldTrackingEnabled', label: '世界追踪 / World tracking', restart: false },
  { flag: 'rollbackEnabled', setting: 'rollbackEnabled', label: '世界回滚 / Rollback', restart: false },
  { flag: 'metricsEnabled', setting: 'metricsEnabled', label: 'OpenMetrics', restart: true },
] as const satisfies ReadonlyArray<{ flag: keyof FeatureFlags; setting: keyof ServerSettings; label: string; restart: boolean }>;

function validTimeZone(value: string): boolean {
  if (!value.trim()) return false;
  try {
    new Intl.DateTimeFormat('en-US', { timeZone: value.trim() }).format();
    return true;
  } catch {
    return false;
  }
}

function validDefaultMessageSender(value: string): boolean {
  const normalized = value.trim();
  return normalized.length >= 1 && normalized.length <= 64
    && !/[\u0000-\u001f\u007f-\u009f]/u.test(value);
}

export function SettingsPage() {
  const { t } = useI18n();
  const { api, session } = useServer();
  const resource = useApiResource(() => api.settings(), [], ['settings']);
  const isOwner = session?.actor?.roles.includes('owner') === true;

  return (
    <div className="page">
      <PageHeader title={t('settings.title')} description={t('settings.description')} actions={isOwner ? <Button type="button" variant="secondary" onClick={() => document.getElementById('server-configuration')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}><ShieldCheck size={15} />编辑服务器配置</Button> : undefined} />
      <ResourceState loading={resource.loading || !resource.data} error={resource.error} onRetry={resource.reload}>
        <div className="settings-grid">
          {resource.data && <FeatureSwitchEditor initial={resource.data} owner={isOwner} onSaved={resource.reload} />}
          {resource.data && <CrashProtectionEditor initial={resource.data} owner={isOwner} onSaved={resource.reload} />}
          {isOwner && resource.data && <div id="server-configuration" className="configuration-anchor"><ConfigurationEditor initial={resource.data} onSaved={resource.reload} /></div>}
          <Panel title={<><Network size={18} />{t('settings.network')}</>}>
            <p className="panel-description">{t('settings.readOnly')}</p>
            <dl className="definition-list">
              <div><dt>{t('settings.bind')}</dt><dd><code>{resource.data?.bindAddress ?? '—'}</code></dd></div>
              <div><dt>{t('settings.port')}</dt><dd><code>{resource.data?.port ?? '—'}</code></dd></div>
              <div><dt>{t('settings.proxies')}</dt><dd>{resource.data?.trustedProxyCidrs?.length ? resource.data.trustedProxyCidrs.map((cidr) => <code key={cidr}>{cidr}</code>) : '—'}</dd></div>
            </dl>
          </Panel>
          <Panel title={<><LockKeyhole size={18} />{t('settings.security')}</>}>
            <dl className="definition-list">
              <div><dt>{t('settings.offlineMode')}</dt><dd><Badge tone={resource.data?.offlineMode ? 'warn' : 'good'}>{resource.data?.offlineMode === undefined ? '—' : String(resource.data.offlineMode)}</Badge></dd></div>
              <div><dt>Metrics token</dt><dd><Badge tone={resource.data?.metricsRequireToken ? 'good' : 'warn'}>{resource.data?.metricsRequireToken ? t('common.enabled') : t('common.disabled')}</Badge></dd></div>
            </dl>
            <SecuritySession />
          </Panel>
          <Panel title={<><Cable size={18} />{t('settings.integrations')}</>}>
            <div className="integration-grid"><div><span>Spark</span><Badge tone={resource.data?.sparkDetected ? 'good' : 'neutral'}>{resource.data?.sparkDetected ? t('common.enabled') : t('common.notAvailable')}</Badge></div><div><span>BlueMap</span><Badge tone={resource.data?.blueMapDetected ? 'good' : 'neutral'}>{resource.data?.blueMapDetected ? t('common.enabled') : t('common.notAvailable')}</Badge></div></div>
          </Panel>
          {isOwner && <AccountManagement />}
        </div>
      </ResourceState>
    </div>
  );
}

function CrashProtectionEditor({ initial, owner, onSaved }: {
  initial: ServerSettings; owner: boolean; onSaved: () => void;
}) {
  const { api } = useServer();
  const [value, setValue] = useState(initial.crashProtection);
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState<string>();
  const [error, setError] = useState<string>();
  useEffect(() => setValue(initial.crashProtection), [initial.crashProtection]);
  type BooleanKey = 'enabled' | 'blockExcessSpawns' | 'removeExcessItems' | 'stopRunawayCommandBlocks';
  type NumberKey = Exclude<keyof CrashProtectionSettings, BooleanKey>;
  const number = (key: NumberKey, next: string) =>
    setValue((current) => ({ ...current, [key]: Number(next) }));
  const toggle = (key: BooleanKey, checked: boolean) =>
    setValue((current) => ({ ...current, [key]: checked }));
  const valid = value.scanIntervalTicks >= 20 && value.scanIntervalTicks <= 1200
    && value.itemEntitiesPerChunk >= 1 && value.itemEntitiesPerChunk <= value.itemEntitiesPerDimension
    && value.livingEntitiesPerChunk >= 1 && value.livingEntitiesPerChunk <= value.livingEntitiesPerDimension
    && value.totalEntitiesPerChunk >= 1 && value.totalEntitiesPerChunk <= value.totalEntitiesPerDimension
    && value.itemEntitiesPerDimension <= value.totalEntitiesPerDimension
    && value.livingEntitiesPerDimension <= value.totalEntitiesPerDimension
    && value.entitiesPerNamespacePerDimension >= 1
    && value.entitiesPerNamespacePerDimension <= value.totalEntitiesPerDimension
    && value.loadedChunksPerDimension >= 1 && value.spawnBurstLimit >= 1
    && value.spawnBurstWindowTicks >= 20 && value.spawnBurstWindowTicks <= 72000
    && value.commandBlockCommandsPerSecond >= 1 && value.commandBlockCommandsPerSecond <= 100000
    && value.slowTickMillis >= 50 && value.slowTickMillis <= 60000
    && value.consecutiveSlowTicks >= 1 && value.consecutiveSlowTicks <= 1200
    && value.heapUsagePercent >= 50 && value.heapUsagePercent <= 99;
  const dirty = JSON.stringify(value) !== JSON.stringify(initial.crashProtection);
  const save = async () => {
    if (!owner || !valid || !dirty) return;
    setBusy(true); setError(undefined); setMessage(undefined);
    try {
      // The API supports partial settings updates. Sending only this section prevents a stale
      // editor tab from reverting an independently saved feature or network setting.
      await api.updateSettings({ crashProtection: value });
      setMessage('崩服防护设置已保存并立即生效。');
      onSaved();
    } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setBusy(false); }
  };
  const fields: Array<[NumberKey, string, number, number, string]> = [
    ['scanIntervalTicks', '扫描间隔(游戏刻)', 20, 1200, '100 = 约 5 秒;扫描只遍历已加载实体'],
    ['itemEntitiesPerDimension', '每维度掉落物硬上限', 1, 1000000, '超量时可自动清理并阻止继续生成'],
    ['itemEntitiesPerChunk', '每区块掉落物硬上限', 1, 1000000, '防止物品集中导致碰撞计算爆炸'],
    ['livingEntitiesPerDimension', '每维度生物硬上限', 1, 1000000, '只阻止继续生成,不删除已有生物'],
    ['livingEntitiesPerChunk', '每区块生物硬上限', 1, 1000000, '玩家永远不会被此规则拦截'],
    ['totalEntitiesPerDimension', '每维度实体总上限', 1, 2000000, '包含掉落物、投射物、载具和模组实体'],
    ['totalEntitiesPerChunk', '每区块实体总上限', 1, 2000000, '限制恶意集中生成'],
    ['entitiesPerNamespacePerDimension', '单个模组实体上限', 1, 2000000, '对 create 等第三方命名空间生效;原版使用分类/总上限'],
    ['loadedChunksPerDimension', '每维度加载区块告警阈值', 1, 1000000, '用于检测区块加载器或强加载失控'],
    ['spawnBurstLimit', '生成速率硬上限', 1, 1000000, '统计窗口内允许的新实体数量'],
    ['spawnBurstWindowTicks', '生成速率窗口(游戏刻)', 20, 72000, '100 = 约 5 秒'],
    ['commandBlockCommandsPerSecond', '命令方块每秒执行上限', 1, 100000, '超过后熔断本秒后续命令'],
    ['slowTickMillis', '慢刻阈值(毫秒)', 50, 60000, '连续慢刻会产生防护事件'],
    ['consecutiveSlowTicks', '连续慢刻数量', 1, 1200, '达到后触发 protection.slow_tick'],
    ['heapUsagePercent', '堆内存告警百分比', 50, 99, '用于 protection.memory_pressure 事件'],
  ];
  return <Panel title={<><ShieldAlert size={18} />崩服防护 / Crash protection</>} className="crash-protection-panel">
    <p className="panel-description">检测实体洪泛、掉落物碰撞、生物集中生成、模组实体失控、命令方块循环、区块强加载、慢刻和内存压力。所有设置均可热更新。</p>
    <label className="feature-switch crash-protection-master"><span><strong>启用崩服防护</strong><small>关闭后仅保留原版 watchdog</small></span>
      <input type="checkbox" role="switch" checked={value.enabled} disabled={!owner || busy}
        onChange={(event) => toggle('enabled', event.target.checked)} /></label>
    <div className="crash-protection-grid">{fields.map(([key, label, min, max, hint]) =>
      <Field key={key} label={label} hint={hint}><Input type="number" min={min} max={max} step={1}
        aria-label={label} disabled={!owner || busy || !value.enabled} value={value[key]}
        onChange={(event) => number(key, event.target.value)} /></Field>)}</div>
    <div className="configuration-flags">
      <label><input type="checkbox" checked={value.blockExcessSpawns} disabled={!owner || busy || !value.enabled}
        onChange={(event) => toggle('blockExcessSpawns', event.target.checked)} /><span>阻止超过硬限制的新实体生成</span></label>
      <label><input type="checkbox" checked={value.removeExcessItems} disabled={!owner || busy || !value.enabled}
        onChange={(event) => toggle('removeExcessItems', event.target.checked)} /><span>自动清理超过硬限制的掉落物</span></label>
      <label><input type="checkbox" checked={value.stopRunawayCommandBlocks} disabled={!owner || busy || !value.enabled}
        onChange={(event) => toggle('stopRunawayCommandBlocks', event.target.checked)} /><span>熔断高频命令方块</span></label>
    </div>
    {!valid && <div className="form-error" role="alert">防护数值无效;区块/分类上限不能超过对应总上限。</div>}
    {!owner && <div className="security-strip"><LockKeyhole size={15} /><span>只有 owner 可以修改崩服防护设置。</span></div>}
    {error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
    {message && <Toast message={message} tone="good" onClose={() => setMessage(undefined)} />}
    {owner && <div className="modal__actions"><Button type="button" disabled={busy || !dirty || !valid}
      onClick={() => void save()}><ShieldCheck size={15} />保存并立即应用</Button></div>}
  </Panel>;
}

function FeatureSwitchEditor({ initial, owner, onSaved }: { initial: ServerSettings; owner: boolean; onSaved: () => void }) {
  const { t } = useI18n();
  const { api } = useServer();
  const [value, setValue] = useState(initial);
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState<string>();
  const [error, setError] = useState<string>();
  const [stopPreview, setStopPreview] = useState<OperationStatus>();
  useEffect(() => setValue(initial), [initial]);

  const timeZoneValid = validTimeZone(value.announcementTimeZone);
  const defaultSender = value.defaultMessageSender.trim();
  const defaultSenderValid = validDefaultMessageSender(value.defaultMessageSender);
  const dirty = featureSwitches.some(({ setting }) => value[setting] !== initial[setting])
    || value.announcementTimeZone.trim() !== initial.announcementTimeZone
    || defaultSender !== initial.defaultMessageSender;
  const save = async () => {
    if (!owner || !dirty || !timeZoneValid || !defaultSenderValid) return;
    setBusy(true); setError(undefined); setMessage(undefined);
    try {
      const operation = await api.updateSettings({ ...value,
        announcementTimeZone: value.announcementTimeZone.trim(), defaultMessageSender: defaultSender });
      setMessage(operation.message ?? (operation.restartRequired ? '设置已保存,需重启的项目将在重启后生效。' : '设置已保存并立即生效。'));
      setStopPreview(undefined);
      onSaved();
    } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setBusy(false); }
  };

  const restart = async () => {
    setBusy(true); setError(undefined);
    try {
      if (!stopPreview?.previewToken) {
        setStopPreview(await api.submitOperation('system.stop', 'Apply XFEServerManager feature configuration'));
      } else {
        await api.submitOperation('system.stop', 'Apply XFEServerManager feature configuration', stopPreview.previewToken);
        setMessage('已保存世界和数据库并发出优雅关服请求;外部守护程序将按标准流程重新启动服务器。');
        setStopPreview(undefined);
      }
    } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setBusy(false); }
  };

  return <Panel title={<><Boxes size={18} />{t('settings.features')}</>} className="feature-panel">
    <p className="panel-description">Owner 可直接修改。除 OpenMetrics 监听与令牌初始化外,功能开关保存后立即生效;入服消息及公告已统一移至“触发器”。</p>
    <div className="feature-switch-grid">{featureSwitches.map(({ flag, setting, label, restart }) => {
      const configured = value[setting] === true;
      const effective = initial.features[flag] === true;
      const pending = configured !== effective;
      return <label className="feature-switch" key={flag}>
        <span><strong>{label}</strong><small>{pending ? (restart ? (configured ? '重启后启用 / Enable after restart' : '重启后停用 / Disable after restart') : (configured ? '保存后立即启用 / Enable on save' : '保存后立即停用 / Disable on save')) : (effective ? '当前已生效 / Active' : '当前未启用 / Inactive')}</small></span>
        <input type="checkbox" role="switch" checked={configured} disabled={!owner || busy} onChange={(event) => setValue((current) => ({ ...current, [setting]: event.target.checked }))} />
      </label>;
    })}</div>
    <div className="trigger-settings-inline">
      <div><strong>触发器迁移兼容 / Trigger migration compatibility</strong>
        <p>已迁移的每日公告与规则提醒使用此时区计算 <code>player.dailyDue</code>。</p></div>
      <Field label="每日触发器时区 / Daily trigger time zone"
        hint="IANA zone · Asia/Shanghai">
        <Input aria-label="每日触发器时区 / Daily trigger time zone" autoComplete="off" spellCheck={false}
          placeholder="Asia/Shanghai" value={value.announcementTimeZone} disabled={!owner || busy}
          aria-invalid={!timeZoneValid}
          onChange={(event) => setValue((current) => ({ ...current, announcementTimeZone: event.target.value }))} />
      </Field>
      <Field label="默认消息发送者 / Default message sender"
        hint="1–64 characters · inherited by rich messages">
        <Input aria-label="默认消息发送者 / Default message sender" autoComplete="off" maxLength={64}
          value={value.defaultMessageSender} disabled={!owner || busy} aria-invalid={!defaultSenderValid}
          onChange={(event) => setValue((current) => ({ ...current, defaultMessageSender: event.target.value }))} />
      </Field>
    </div>
    {!owner && <div className="security-strip"><LockKeyhole size={15} /><span>只有 owner 可以修改服务端功能开关。</span></div>}
    {!timeZoneValid && <div className="form-error" role="alert">请输入有效的 IANA 时区,例如 Asia/Shanghai。</div>}
    {!defaultSenderValid && <div className="form-error" role="alert">默认消息发送者需为 1–64 个字符,且不能包含换行或控制字符。</div>}
    {error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
    {message && <Toast message={message} tone="good" />}
    {owner && <div className="modal__actions">
      <Button type="button" disabled={busy || !dirty || !timeZoneValid || !defaultSenderValid} onClick={() => void save()}><ShieldCheck size={15} />保存功能与触发器设置</Button>
      {initial.restartRequired && <Button type="button" variant="danger" disabled={busy} onClick={() => void restart()}>{stopPreview?.previewToken ? '确认保存并优雅关服' : '预览标准重启'}</Button>}
      {initial.restartRequired && <Badge tone="warn">等待重启 / Restart pending</Badge>}
    </div>}
    {stopPreview?.previewToken && <div className="confirm-box confirm-box--danger"><p>确认后执行 save-all flush、SQLite checkpoint 和优雅关服;进程拉起由 systemd、Docker 或服务器面板负责。</p></div>}
  </Panel>;
}

function ConfigurationEditor({ initial, onSaved }: { initial: ServerSettings; onSaved: () => void }) {
  const { api } = useServer();
  const [value, setValue] = useState<ServerSettings>(initial);
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState<string>();
  const [error, setError] = useState<string>();
  const [stopPreview, setStopPreview] = useState<OperationStatus>();
  useEffect(() => setValue(initial), [initial]);

  const numberField = (key: keyof ServerSettings, next: string) => {
    setValue((current) => ({ ...current, [key]: Number(next) }));
  };
  const textField = (key: keyof ServerSettings, next: string) => {
    setValue((current) => ({ ...current, [key]: next }));
  };
  const toggle = (key: keyof ServerSettings, checked: boolean) => {
    setValue((current) => ({ ...current, [key]: checked }));
  };

  const save = async (event: FormEvent) => {
    event.preventDefault();
    setBusy(true); setError(undefined); setMessage(undefined); setStopPreview(undefined);
    try {
      const operation = await api.updateSettings(value);
      setMessage(operation.message ?? '配置已保存,需要重启服务器后生效。');
      onSaved();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : String(cause));
    } finally { setBusy(false); }
  };

  const prepareRestart = async () => {
    setBusy(true); setError(undefined);
    try {
      setStopPreview(await api.submitOperation('system.stop', 'Apply XFEServerManager configuration update'));
    } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setBusy(false); }
  };

  const confirmRestart = async () => {
    if (!stopPreview?.previewToken) return;
    setBusy(true); setError(undefined);
    try {
      await api.submitOperation('system.stop', 'Apply XFEServerManager configuration update', stopPreview.previewToken);
      setMessage('已完成审计、保存和优雅关服请求;外部 systemd/Docker/面板应按标准流程拉起服务器。');
      setStopPreview(undefined);
    } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setBusy(false); }
  };

  return <Panel title="服务器端配置 / Server configuration" className="configuration-panel">
    <p className="panel-description">保存时会校验并原子替换服务端配置文件。运行中的配置不会热切换,需通过下方审计流程优雅重启。</p>
    <form className="form-stack configuration-form" onSubmit={save}>
      <div className="form-grid">
        <Field label="监听地址"><Input value={value.bindAddress} onChange={(event) => textField('bindAddress', event.target.value)} required /></Field>
        <Field label="端口"><Input type="number" min={1} max={65535} value={value.port} onChange={(event) => numberField('port', event.target.value)} required /></Field>
        <Field label="公开访问地址"><Input type="url" value={value.publicBaseUrl} onChange={(event) => textField('publicBaseUrl', event.target.value)} required /></Field>
        <Field label="HTTP 工作线程"><Input type="number" min={2} max={64} value={value.httpThreads} onChange={(event) => numberField('httpThreads', event.target.value)} required /></Field>
        <Field label="SSE 最大连接"><Input type="number" min={1} max={1000} value={value.maximumSseClients} onChange={(event) => numberField('maximumSseClients', event.target.value)} required /></Field>
        <Field label="近期授权秒数"><Input type="number" min={30} value={value.recentAuthenticationSeconds} onChange={(event) => numberField('recentAuthenticationSeconds', event.target.value)} required /></Field>
        <Field label="空闲会话秒数"><Input type="number" min={60} value={value.idleSessionSeconds} onChange={(event) => numberField('idleSessionSeconds', event.target.value)} required /></Field>
        <Field label="最长会话秒数"><Input type="number" min={60} value={value.absoluteSessionSeconds} onChange={(event) => numberField('absoluteSessionSeconds', event.target.value)} required /></Field>
      </div>
      <Field label="可信代理 CIDR(每行一个)"><Textarea rows={3} value={value.trustedProxyCidrs.join('\n')} onChange={(event) => setValue((current) => ({ ...current, trustedProxyCidrs: event.target.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) }))} /></Field>
      <div className="configuration-flags"><label><input type="checkbox" checked={value.rawItemEditingEnabled} onChange={(event) => toggle('rawItemEditingEnabled', event.target.checked)} /><span>危险原始物品数据编辑</span></label></div>
      {error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
      {message && <Toast message={message} tone="good" />}
      <div className="modal__actions">
        <Button type="submit" disabled={busy}><ShieldCheck size={15} />保存配置</Button>
        {message && !stopPreview && <Button type="button" variant="danger" onClick={prepareRestart} disabled={busy}>预览标准重启</Button>}
        {stopPreview?.previewToken && <Button type="button" variant="danger" onClick={confirmRestart} disabled={busy}>确认保存并优雅关服</Button>}
      </div>
      {stopPreview?.previewToken && <div className="confirm-box confirm-box--danger"><p>目标服务器已冻结在本次预览中。确认后先执行审计,再调用 Minecraft 的 stop;进程拉起由外部守护完成。</p></div>}
    </form>
  </Panel>;
}

function SecuritySession() {
  const { t } = useI18n();
  const { api, session, refresh } = useServer();
  const actor = session?.actor;
  const [password, setPassword] = useState('');
  const [totp, setTotp] = useState('');
  const [enrollment, setEnrollment] = useState<TotpEnrollment>();
  const [recoveryCodes, setRecoveryCodes] = useState<string[]>();
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState<{ value: string; tone: 'good' | 'danger' }>();
  const needsEnrollment = actor?.totpEnabled === false || (actor?.totpEnabled === undefined && !actor?.totpVerified);

  const beginEnrollment = async (event: FormEvent) => {
    event.preventDefault();
    setBusy(true);
    try {
      setEnrollment(await api.beginTotpEnrollment(password));
      setPassword('');
      setTotp('');
      setMessage(undefined);
    } catch (error) {
      setMessage({ value: error instanceof Error ? error.message : String(error), tone: 'danger' });
    } finally {
      setBusy(false);
    }
  };

  const confirmEnrollment = async (event: FormEvent) => {
    event.preventDefault();
    setBusy(true);
    try {
      const result = await api.confirmTotpEnrollment(totp);
      setRecoveryCodes(result.recoveryCodes);
      setEnrollment(undefined);
      setTotp('');
      setMessage({ value: t('auth.securityUpdated'), tone: 'good' });
      await refresh();
    } catch (error) {
      setMessage({ value: error instanceof Error ? error.message : String(error), tone: 'danger' });
    } finally {
      setBusy(false);
    }
  };

  const reauthenticate = async (event: FormEvent) => {
    event.preventDefault();
    setBusy(true);
    try {
      await api.reauthenticateWithTotp(totp);
      setTotp('');
      setMessage({ value: t('auth.securityUpdated'), tone: 'good' });
      await refresh();
    } catch (error) {
      setMessage({ value: error instanceof Error ? error.message : String(error), tone: 'danger' });
    } finally {
      setBusy(false);
    }
  };

  return <div className="security-session">
    {recoveryCodes && <div className="recovery-codes" role="status"><strong>{t('auth.recoveryCodes')}</strong><div>{recoveryCodes.map((code) => <code key={code}>{code}</code>)}</div></div>}
    {needsEnrollment && !enrollment && <form className="form-stack security-form" onSubmit={beginEnrollment}><h3><KeyRound size={16} />{t('auth.totpSetup')}</h3><p>{t('auth.totpSetupHint')}</p><Field label={t('auth.password')}><Input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></Field><Button type="submit" variant="secondary" disabled={busy || !password}><KeyRound size={15} />{t('auth.startEnrollment')}</Button></form>}
    {enrollment && <form className="form-stack security-form" onSubmit={confirmEnrollment}><h3><KeyRound size={16} />{t('auth.totpSetup')}</h3><TotpQr enrollment={enrollment} /><details><summary>{t('auth.manualSetup')}</summary><Field label={t('auth.secret')}><Input readOnly value={enrollment.secret} /></Field><Field label={t('auth.provisioningUri')}><Textarea readOnly rows={3} value={enrollment.provisioningUri} /></Field></details><Field label={t('auth.totp')}><Input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" value={totp} onChange={(event) => setTotp(event.target.value.replace(/\D/g, '').slice(0, 6))} required /></Field><Button type="submit" disabled={busy || totp.length !== 6}><ShieldCheck size={15} />{t('auth.confirmTotp')}</Button></form>}
    {!needsEnrollment && <form className="form-stack security-form" onSubmit={reauthenticate}><h3><ShieldCheck size={16} />{t('auth.reauthenticate')}</h3><p>{actor?.reauthenticationRequired ? t('auth.reauthenticateHint') : t('auth.securityReady')}</p><Field label={t('auth.totp')}><Input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" value={totp} onChange={(event) => setTotp(event.target.value.replace(/\D/g, '').slice(0, 6))} required /></Field><Button type="submit" variant="secondary" disabled={busy || totp.length !== 6}><ShieldCheck size={15} />{t('auth.verifyAgain')}</Button></form>}
    {message && <Toast message={message.value} tone={message.tone} onClose={() => setMessage(undefined)} />}
  </div>;
}

function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
  const [source, setSource] = useState<string>();
  useEffect(() => {
    let active = true;
    QRCode.toDataURL(enrollment.provisioningUri, {
      width: 240,
      margin: 1,
      errorCorrectionLevel: 'M',
      color: { dark: '#08100c', light: '#ffffff' },
    }).then((value) => { if (active) setSource(value); });
    return () => { active = false; };
  }, [enrollment.provisioningUri]);
  return source ? <div className="totp-qr"><img src={source} alt="TOTP QR code" /><span>使用身份验证器扫码,然后输入首次生成的验证码完成绑定。</span></div> : null;
}

const accountRoles: Role[] = ['observer', 'helper', 'moderator', 'administrator', 'owner'];

function AccountManagement() {
  const { t, locale } = useI18n();
  const { api, session, refresh } = useServer();
  const actor = session?.actor;
  const eligible = actor?.roles.includes('owner') === true
    && actor.totpVerified
    && actor.reauthenticationRequired !== true;
  const [accounts, setAccounts] = useState<AccountView[]>();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string>();
  const [message, setMessage] = useState<string>();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [role, setRole] = useState<Role>('observer');
  const [creating, setCreating] = useState(false);

  const load = async () => {
    if (!eligible) return;
    setLoading(true);
    setError(undefined);
    try { setAccounts(await api.accounts()); }
    catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
    finally { setLoading(false); }
  };

  useEffect(() => {
    if (!eligible) {
      setAccounts(undefined);
      return;
    }
    let active = true;
    setLoading(true);
    setError(undefined);
    api.accounts().then((value) => { if (active) setAccounts(value); }, (cause: unknown) => {
      if (active) setError(cause instanceof Error ? cause.message : String(cause));
    }).finally(() => { if (active) setLoading(false); });
    return () => { active = false; };
  }, [api, eligible]);

  const create = async (event: FormEvent) => {
    event.preventDefault();
    if (!eligible || !username.trim() || password.length < 12) return;
    setCreating(true); setError(undefined); setMessage(undefined);
    try {
      await api.createAccount(username.trim(), password, role);
      setUsername(''); setPassword(''); setRole('observer');
      setMessage(t('accounts.created'));
      await load();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : String(cause));
    } finally { setCreating(false); }
  };

  const changed = async (account: AccountView, change: { role?: Role; disabled?: boolean }) => {
    setError(undefined); setMessage(undefined);
    try {
      await api.updateAccount(account.id, change);
      setMessage(t('accounts.updated'));
      if (account.id === actor?.id) await refresh();
      else await load();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : String(cause));
    }
  };

  const deleted = async (account: AccountView) => {
    setError(undefined); setMessage(undefined);
    try {
      await api.deleteAccount(account.id);
      setMessage(t('accounts.deleted'));
      if (account.id === actor?.id) await refresh();
      else await load();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : String(cause));
    }
  };

  return <Panel title={<><Users size={18} />{t('accounts.title')}</>} className="accounts-panel">
    <p className="panel-description">{t('accounts.description')}</p>
    {!eligible ? <div className="security-strip accounts-locked"><LockKeyhole size={16} /><span>{t('accounts.locked')}</span></div> : <>
      <form className="account-create form-grid" onSubmit={create}>
        <Field label={t('accounts.username')}><Input autoComplete="off" minLength={3} value={username} onChange={(event) => setUsername(event.target.value)} required /></Field>
        <Field label={t('accounts.initialPassword')} hint={t('accounts.passwordHint')}><Input type="password" autoComplete="new-password" minLength={12} value={password} onChange={(event) => setPassword(event.target.value)} required /></Field>
        <Field label={t('accounts.role')}><Select value={role} onChange={(event) => setRole(event.target.value as Role)}>{accountRoles.map((value) => <option key={value} value={value}>{value}</option>)}</Select></Field>
        <div className="account-create__action"><Button type="submit" disabled={creating || !username.trim() || password.length < 12}><UserPlus size={15} />{t('accounts.create')}</Button></div>
      </form>
      {error && <Toast message={error} tone="danger" onClose={() => setError(undefined)} />}
      {message && <Toast message={message} tone="good" onClose={() => setMessage(undefined)} />}
      {loading && !accounts ? <div className="resource-state">{t('common.loading')}</div> : accounts && <div className="accounts-table-shell"><TableShell>
        <thead><tr><th>{t('accounts.username')}</th><th>{t('accounts.role')}</th><th>{t('accounts.status')}</th><th>{t('accounts.totp')}</th><th>{t('accounts.lastLogin')}</th><th><span className="sr-only">{t('players.actions')}</span></th></tr></thead>
        <tbody>{accounts.map((account) => <AccountRow key={account.id} account={account} current={account.id === actor?.id} locale={locale} onChange={changed} onDelete={deleted} />)}</tbody>
      </TableShell></div>}
    </>}
  </Panel>;
}

function AccountRow({ account, current, locale, onChange, onDelete }: {
  account: AccountView;
  current: boolean;
  locale: 'zh-CN' | 'en-US';
  onChange: (account: AccountView, change: { role?: Role; disabled?: boolean }) => Promise<void>;
  onDelete: (account: AccountView) => Promise<void>;
}) {
  const { t } = useI18n();
  const [role, setRole] = useState<Role>(account.role);
  const [disabled, setDisabled] = useState(account.disabled);
  const [changeConfirmed, setChangeConfirmed] = useState(false);
  const [deleteConfirmed, setDeleteConfirmed] = useState(false);
  const [busy, setBusy] = useState(false);
  const dirty = role !== account.role || disabled !== account.disabled;

  useEffect(() => {
    setRole(account.role); setDisabled(account.disabled); setChangeConfirmed(false); setDeleteConfirmed(false);
  }, [account]);

  const save = async () => {
    if (!dirty) return;
    if (!changeConfirmed) { setChangeConfirmed(true); setDeleteConfirmed(false); return; }
    setBusy(true);
    try { await onChange(account, { role, disabled }); }
    finally { setBusy(false); }
  };

  const remove = async () => {
    if (!deleteConfirmed) { setDeleteConfirmed(true); setChangeConfirmed(false); return; }
    setBusy(true);
    try { await onDelete(account); }
    finally { setBusy(false); }
  };

  return <tr>
    <td><strong>{account.username}</strong><small className="table-subline"><code>{account.id}</code>{current && <> · {t('accounts.current')}</>}</small></td>
    <td><Select aria-label={`${t('accounts.role')}: ${account.username}`} value={role} disabled={busy} onChange={(event) => { setRole(event.target.value as Role); setChangeConfirmed(false); }}>{accountRoles.map((value) => <option key={value} value={value}>{value}</option>)}</Select></td>
    <td><label className="account-disabled"><input type="checkbox" checked={disabled} disabled={busy} onChange={(event) => { setDisabled(event.target.checked); setChangeConfirmed(false); }} /><span>{disabled ? t('accounts.disabled') : t('accounts.active')}</span></label></td>
    <td><Badge tone={account.totpEnabled ? 'good' : 'warn'}>{account.totpEnabled ? t('common.enabled') : t('common.disabled')}</Badge></td>
    <td>{account.lastLoginAt ? new Date(account.lastLoginAt).toLocaleString(locale) : t('accounts.never')}</td>
    <td><div className="account-actions">
      {changeConfirmed && <small>{t('accounts.changeWarning')}</small>}
      {dirty && <Button variant={changeConfirmed ? 'danger' : 'secondary'} disabled={busy} onClick={() => void save()}>{changeConfirmed ? t('accounts.confirmChange') : t('accounts.reviewChange')}</Button>}
      {deleteConfirmed && <small>{t('accounts.deleteWarning')}</small>}
      <Button variant={deleteConfirmed ? 'danger' : 'ghost'} disabled={busy} onClick={() => void remove()}><Trash2 size={13} />{deleteConfirmed ? t('accounts.confirmDelete') : t('accounts.delete')}</Button>
    </div></td>
  </tr>;
}