432 lines
16 KiB
JavaScript
432 lines
16 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react';
|
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
import { ArrowLeft, Bell, Pencil, Plus, Save, Trash2 } from 'lucide-react';
|
|
import { request } from '../api/client.js';
|
|
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
|
import { Field } from '../components/Field.jsx';
|
|
import { useToast } from '../components/Toast.jsx';
|
|
|
|
const unitToHours = {
|
|
hours: 1,
|
|
days: 24,
|
|
weeks: 168,
|
|
};
|
|
|
|
function toDisplayThreshold(thresholdHours) {
|
|
if (thresholdHours % unitToHours.weeks === 0) {
|
|
return { value: thresholdHours / unitToHours.weeks, unit: 'weeks' };
|
|
}
|
|
if (thresholdHours % unitToHours.days === 0) {
|
|
return { value: thresholdHours / unitToHours.days, unit: 'days' };
|
|
}
|
|
return { value: thresholdHours, unit: 'hours' };
|
|
}
|
|
|
|
function toThresholdHours(condition) {
|
|
const value = String(condition.value).trim();
|
|
if (!/^\d+$/.test(value)) return Number.NaN;
|
|
return Number.parseInt(value, 10) * unitToHours[condition.unit];
|
|
}
|
|
|
|
function formatCertificateValue(value) {
|
|
if (!value) return '未取得';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '未取得';
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function formatCondition(condition) {
|
|
const unitLabels = {
|
|
hours: '時間前',
|
|
days: '日前',
|
|
weeks: '週間前',
|
|
};
|
|
return `${condition.value}${unitLabels[condition.unit]}`;
|
|
}
|
|
|
|
export function SiteSettingsPanel({ site, onBack }) {
|
|
const [alias, setAlias] = useState(site.alias);
|
|
const [savedAlias, setSavedAlias] = useState(site.alias);
|
|
const [conditions, setConditions] = useState([]);
|
|
const [timingDialogOpen, setTimingDialogOpen] = useState(false);
|
|
const [editingConditionIndex, setEditingConditionIndex] = useState(null);
|
|
const [timingForm, setTimingForm] = useState({ value: '', unit: 'days' });
|
|
const [availableWebhooks, setAvailableWebhooks] = useState([]);
|
|
const [webhookMethodIds, setWebhookMethodIds] = useState([]);
|
|
const [pushEnabled, setPushEnabled] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const { showToast } = useToast();
|
|
|
|
const previewText = useMemo(() => {
|
|
const hours = conditions
|
|
.map(toThresholdHours)
|
|
.filter((value) => Number.isInteger(value) && value > 0)
|
|
.sort((a, b) => a - b);
|
|
if (hours.length === 0) return '通知タイミング未設定';
|
|
return hours.map((hour) => `${hour} 時間前`).join(' / ');
|
|
}, [conditions]);
|
|
|
|
useEffect(() => {
|
|
setAlias(site.alias);
|
|
setSavedAlias(site.alias);
|
|
}, [site.alias, site.siteId]);
|
|
|
|
useEffect(() => {
|
|
async function loadSettings() {
|
|
const data = await request(`/api/sites/${site.siteId}/settings`);
|
|
setAvailableWebhooks(data.settings.availableWebhooks);
|
|
if (data.settings.conditions.length > 0) {
|
|
setConditions(
|
|
data.settings.conditions.map((condition) => toDisplayThreshold(condition.thresholdHours)),
|
|
);
|
|
setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds);
|
|
setPushEnabled(data.settings.conditions[0].pushEnabled);
|
|
} else {
|
|
setConditions([]);
|
|
setWebhookMethodIds([]);
|
|
setPushEnabled(false);
|
|
}
|
|
}
|
|
loadSettings().catch((err) => showToast({ type: 'error', message: err.message }));
|
|
}, [showToast, site.siteId]);
|
|
|
|
function openAddConditionDialog() {
|
|
setEditingConditionIndex(null);
|
|
setTimingForm({ value: '', unit: 'days' });
|
|
setTimingDialogOpen(true);
|
|
}
|
|
|
|
function openEditConditionDialog(index) {
|
|
setEditingConditionIndex(index);
|
|
setTimingForm({
|
|
value: String(conditions[index].value),
|
|
unit: conditions[index].unit,
|
|
});
|
|
setTimingDialogOpen(true);
|
|
}
|
|
|
|
function saveTimingCondition(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const thresholdHours = toThresholdHours(timingForm);
|
|
if (!Number.isInteger(thresholdHours) || thresholdHours <= 0 || thresholdHours > 17520) {
|
|
showToast({
|
|
type: 'error',
|
|
message: '通知タイミングは 1 時間以上、2 年以内で指定してください',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const duplicated = conditions.some((condition, index) => {
|
|
if (index === editingConditionIndex) return false;
|
|
return toThresholdHours(condition) === thresholdHours;
|
|
});
|
|
if (duplicated) {
|
|
showToast({ type: 'error', message: '同じ通知タイミングは重複して登録できません' });
|
|
return;
|
|
}
|
|
|
|
setConditions((current) => {
|
|
const nextCondition = {
|
|
value: Number.parseInt(String(timingForm.value).trim(), 10),
|
|
unit: timingForm.unit,
|
|
};
|
|
if (editingConditionIndex === null) {
|
|
return [...current, nextCondition];
|
|
}
|
|
return current.map((condition, index) =>
|
|
index === editingConditionIndex ? nextCondition : condition,
|
|
);
|
|
});
|
|
setTimingDialogOpen(false);
|
|
}
|
|
|
|
function removeCondition(index) {
|
|
setConditions((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
|
}
|
|
|
|
function toggleWebhook(methodId) {
|
|
setWebhookMethodIds((current) =>
|
|
current.includes(methodId) ? current.filter((id) => id !== methodId) : [...current, methodId],
|
|
);
|
|
}
|
|
|
|
async function saveSettings(event) {
|
|
event.preventDefault();
|
|
setBusy(true);
|
|
try {
|
|
const nextAlias = alias.trim();
|
|
if (!nextAlias) {
|
|
throw new Error('エイリアス名を入力してください');
|
|
}
|
|
if (nextAlias.length > 120) {
|
|
throw new Error('エイリアス名は120文字以内で入力してください');
|
|
}
|
|
const thresholdHours = conditions.map(toThresholdHours);
|
|
if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) {
|
|
throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください');
|
|
}
|
|
if (new Set(thresholdHours).size !== thresholdHours.length) {
|
|
throw new Error('同じ通知タイミングは重複して登録できません');
|
|
}
|
|
if (nextAlias !== savedAlias) {
|
|
await request(`/api/sites/${site.siteId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ alias: nextAlias }),
|
|
});
|
|
setAlias(nextAlias);
|
|
setSavedAlias(nextAlias);
|
|
}
|
|
if (thresholdHours.length === 0) {
|
|
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
|
|
setWebhookMethodIds([]);
|
|
setPushEnabled(false);
|
|
} else {
|
|
await request(`/api/sites/${site.siteId}/settings`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
|
|
webhookMethodIds,
|
|
pushEnabled,
|
|
}),
|
|
});
|
|
}
|
|
showToast({ type: 'success', message: '設定を保存しました' });
|
|
} catch (err) {
|
|
showToast({ type: 'error', message: err.message });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function deleteSettings() {
|
|
setBusy(true);
|
|
try {
|
|
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
|
|
setConditions([]);
|
|
setWebhookMethodIds([]);
|
|
setPushEnabled(false);
|
|
showToast({ type: 'success', message: '設定を削除しました' });
|
|
} catch (err) {
|
|
showToast({ type: 'error', message: err.message });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="app-shell">
|
|
<header className="topbar">
|
|
<button className="back-button" onClick={onBack}>
|
|
<ArrowLeft aria-hidden="true" size={18} />
|
|
サイト一覧
|
|
</button>
|
|
<div className="settings-title">
|
|
<span className="eyebrow">サイト設定</span>
|
|
<h1>{savedAlias}</h1>
|
|
<p>{site.url}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="workspace settings-layout">
|
|
<form className="settings-form" onSubmit={saveSettings}>
|
|
<section className="panel">
|
|
<h2>基本情報</h2>
|
|
<Field label="エイリアス名">
|
|
<input
|
|
value={alias}
|
|
onChange={(event) => setAlias(event.target.value)}
|
|
maxLength="120"
|
|
required
|
|
/>
|
|
</Field>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<h2>証明書情報</h2>
|
|
<dl className="certificate-details">
|
|
<div>
|
|
<dt>発行元</dt>
|
|
<dd>{site.certificateIssuer || '未取得'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>発行日時</dt>
|
|
<dd>{formatCertificateValue(site.certificateIssuedAt)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>失効日時</dt>
|
|
<dd>{formatCertificateValue(site.certificateExpiresAt)}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<Bell aria-hidden="true" size={20} />
|
|
<div>
|
|
<h2>通知タイミング</h2>
|
|
<p>{previewText}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="condition-list">
|
|
{conditions.length === 0 ? (
|
|
<p className="condition-empty">通知タイミングはまだ設定されていません。</p>
|
|
) : (
|
|
conditions.map((condition, index) => (
|
|
<div
|
|
className="condition-row"
|
|
key={`${condition.unit}-${condition.value}-${index}`}
|
|
>
|
|
<span className="condition-value">{formatCondition(condition)}</span>
|
|
<div className="condition-actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={() => openEditConditionDialog(index)}
|
|
aria-label="通知タイミングを編集"
|
|
title="編集"
|
|
>
|
|
<Pencil aria-hidden="true" size={18} />
|
|
</button>
|
|
<ConfirmDialog
|
|
title="通知タイミングを削除"
|
|
description="この通知タイミングを設定から削除します。"
|
|
onConfirm={() => removeCondition(index)}
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
className="icon-button danger"
|
|
aria-label="通知タイミングを削除"
|
|
title="削除"
|
|
>
|
|
<Trash2 aria-hidden="true" size={18} />
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<button type="button" className="secondary" onClick={openAddConditionDialog}>
|
|
<Plus aria-hidden="true" size={18} />
|
|
タイミングを追加
|
|
</button>
|
|
|
|
<Dialog.Root open={timingDialogOpen} onOpenChange={setTimingDialogOpen}>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay className="dialog-overlay" />
|
|
<Dialog.Content className="dialog-content">
|
|
<Dialog.Title className="dialog-title">
|
|
{editingConditionIndex === null
|
|
? '通知タイミングを追加'
|
|
: '通知タイミングを編集'}
|
|
</Dialog.Title>
|
|
<Dialog.Description className="dialog-description">
|
|
証明書の失効前に通知するタイミングを指定します。
|
|
</Dialog.Description>
|
|
<form className="dialog-form timing-dialog-form" onSubmit={saveTimingCondition}>
|
|
<Field label="値">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="17520"
|
|
value={timingForm.value}
|
|
onChange={(event) =>
|
|
setTimingForm({ ...timingForm, value: event.target.value })
|
|
}
|
|
placeholder="例: 7"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="単位">
|
|
<select
|
|
value={timingForm.unit}
|
|
onChange={(event) =>
|
|
setTimingForm({ ...timingForm, unit: event.target.value })
|
|
}
|
|
>
|
|
<option value="hours">時間前</option>
|
|
<option value="days">日前</option>
|
|
<option value="weeks">週間前</option>
|
|
</select>
|
|
</Field>
|
|
<div className="dialog-actions">
|
|
<Dialog.Close asChild>
|
|
<button className="secondary" type="button">
|
|
キャンセル
|
|
</button>
|
|
</Dialog.Close>
|
|
<button className="primary" type="submit">
|
|
{editingConditionIndex === null ? '追加' : '更新'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<h2>通知方法</h2>
|
|
<label className="check-row locked">
|
|
<input type="checkbox" checked readOnly />
|
|
アプリ内アラート
|
|
</label>
|
|
<label className="check-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={pushEnabled}
|
|
onChange={(event) => setPushEnabled(event.target.checked)}
|
|
/>
|
|
プッシュ通知
|
|
</label>
|
|
|
|
<div className="webhook-box">
|
|
<h3>Webhook</h3>
|
|
{availableWebhooks.length === 0 ? (
|
|
<p className="muted">登録済みのWebhookはありません。</p>
|
|
) : (
|
|
availableWebhooks.map((webhook) => (
|
|
<label className="check-row" key={webhook.notificationMethodId}>
|
|
<input
|
|
type="checkbox"
|
|
checked={webhookMethodIds.includes(webhook.notificationMethodId)}
|
|
onChange={() => toggleWebhook(webhook.notificationMethodId)}
|
|
/>
|
|
<span>
|
|
<strong>{webhook.alias}</strong>
|
|
<small>{webhook.url}</small>
|
|
</span>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="settings-actions">
|
|
<button className="primary" disabled={busy}>
|
|
<Save aria-hidden="true" size={18} />
|
|
保存
|
|
</button>
|
|
<ConfirmDialog
|
|
title="サイト設定を削除"
|
|
description="このサイトの通知タイミングと通知方法の設定を削除します。"
|
|
onConfirm={deleteSettings}
|
|
disabled={busy}
|
|
trigger={
|
|
<button type="button" className="secondary danger-text" disabled={busy}>
|
|
設定を削除
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|