diff --git a/README.md b/README.md index d7a59f1..fcb7d7e 100644 --- a/README.md +++ b/README.md @@ -115,4 +115,5 @@ CAPTCHA is disabled by default. To enable it, set `CAPTCHA_PROVIDER` to `turnsti - `pnpm monitor:once` remains available for manual checks or external schedulers. - The monitor limits concurrent external certificate checks and records per-site failures without stopping the whole run. - Webhook URLs and monitored site URLs must be HTTPS and reject localhost/private IPv4 targets. +- The account profile timezone is used when formatting user-facing dates such as webhook alert expiry times. Webhook alert messages can be customized per user from the notification methods screen; if unset, CertRemind uses the default template. - Existing browser Push subscriptions require valid VAPID keys to deliver successfully. diff --git a/db/schema.sql b/db/schema.sql index 6c8884d..e634cec 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -99,6 +99,23 @@ CREATE TRIGGER notification_methods_set_updated_at BEFORE UPDATE ON notification_methods FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TABLE IF NOT EXISTS user_notification_settings ( + user_id uuid PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + webhook_message_template text, + timezone text NOT NULL DEFAULT 'Asia/Tokyo', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE user_notification_settings + ADD COLUMN IF NOT EXISTS webhook_message_template text, + ADD COLUMN IF NOT EXISTS timezone text NOT NULL DEFAULT 'Asia/Tokyo'; + +DROP TRIGGER IF EXISTS user_notification_settings_set_updated_at ON user_notification_settings; +CREATE TRIGGER user_notification_settings_set_updated_at +BEFORE UPDATE ON user_notification_settings +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TABLE IF NOT EXISTS site_alert_conditions ( site_alert_condition_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_id uuid NOT NULL REFERENCES sites(site_id) ON DELETE CASCADE, diff --git a/development_status.md b/development_status.md index a5ecf4f..6793df0 100644 --- a/development_status.md +++ b/development_status.md @@ -42,6 +42,7 @@ - Webhook 登録 API - Webhook 更新 API - Webhook 削除 API +- Webhook メッセージ設定 API - 現在ブラウザの Push 購読状態確認 API - Push 購読情報登録 API - Push 購読情報解除 API @@ -52,6 +53,7 @@ - 通知条件ごとの証明書期限単位の送信済み管理 - 証明書取得失敗時のアラート履歴作成 - Webhook 通知送信処理 +- ユーザー設定テンプレートによる Webhook 通知本文生成 - Push 通知送信処理 - `/push-sw.js` の明示的な静的配信 - 二重送信を防ぐ `dedupe_key` 利用 @@ -59,7 +61,7 @@ - 監視ジョブの一回実行スクリプト - 1 時間ごとに監視ジョブを実行する Node worker - アカウント情報取得 API -- 表示名更新 API +- 表示名・タイムゾーン更新 API - パスワード更新 API - パスワード更新時の全セッション無効化 - TOTP セットアップ API @@ -110,6 +112,7 @@ - `src/server/modules/alerts/routes.js` - `src/server/modules/auth/routes.js` - `src/server/modules/notificationMethods/routes.js` +- `src/server/modules/notificationMethods/webhookMessageSettings.js` - `src/server/modules/monitoring/certificate.js` - `src/server/modules/monitoring/monitor.js` - `src/server/modules/monitoring/notifications.js` @@ -143,6 +146,7 @@ - `sessions` - `sites` - `notification_methods` +- `user_notification_settings` - `site_alert_conditions` - `alert_history` @@ -187,6 +191,8 @@ GET /api/notification-methods POST /api/notification-methods/webhooks PATCH /api/notification-methods/webhooks/:methodId DELETE /api/notification-methods/webhooks/:methodId +PUT /api/notification-methods/webhook-message-settings +DELETE /api/notification-methods/webhook-message-settings POST /api/notification-methods/push-subscription-status POST /api/notification-methods/push-subscriptions DELETE /api/notification-methods/push-subscriptions @@ -245,6 +251,7 @@ pnpm monitor:worker - 通知方法管理画面 - Webhook 登録 - モーダルでの Webhook 編集 + - モーダルでの Webhook メッセージ編集 - 確認ダイアログ付き Webhook 削除 - ブラウザ Push 通知の許可状態表示 - 現在のブラウザの Push 登録状態表示 @@ -252,6 +259,7 @@ pnpm monitor:worker - 現在のブラウザが登録済みの場合の Push 購読解除 - アカウント設定画面 - 表示名更新 + - タイムゾーン設定 - ダイアログでのパスワード更新 - ステップ式ポップアップでの 2 段階認証セットアップ - 2 段階認証 QR コード表示 @@ -293,6 +301,8 @@ pnpm monitor:worker - Webhook URL は HTTPS のみ許可。 - Webhook URL は `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否。 - Webhook 更新・削除はログインユーザーの通知方法のみ対象。 +- Webhook メッセージ設定はログインユーザー単位で保存し、未設定時は既定テンプレートを使用。 +- タイムゾーンはアカウントのユーザー情報として保存し、未設定時は `Asia/Tokyo` を使用。 - Push endpoint は HTTPS のみ許可。 - Push 設定画面では登録済みデバイス一覧を返さず、現在ブラウザの登録状態のみ確認。 - Push 購読解除はログインユーザーの現在ブラウザ endpoint のみ対象。 diff --git a/src/client/constants/timezones.js b/src/client/constants/timezones.js new file mode 100644 index 0000000..044db3a --- /dev/null +++ b/src/client/constants/timezones.js @@ -0,0 +1,42 @@ +export const timezoneOptions = [ + { label: 'Midway, USA (UTC-11:00)', tz: 'Pacific/Midway' }, + { label: 'Honolulu, USA (UTC-10:00)', tz: 'Pacific/Honolulu' }, + { label: 'Anchorage, USA (UTC-09:00)', tz: 'America/Anchorage' }, + { label: 'Los Angeles, USA (UTC-08:00)', tz: 'America/Los_Angeles' }, + { label: 'Denver, USA (UTC-07:00)', tz: 'America/Denver' }, + { label: 'Chicago, USA (UTC-06:00)', tz: 'America/Chicago' }, + { label: 'New York, USA (UTC-05:00)', tz: 'America/New_York' }, + { label: 'Santiago, Chile (UTC-04:00)', tz: 'America/Santiago' }, + { label: 'Sao Paulo, Brazil (UTC-03:00)', tz: 'America/Sao_Paulo' }, + { label: 'South Georgia, UK (UTC-02:00)', tz: 'Atlantic/South_Georgia' }, + { label: 'Azores, Portugal (UTC-01:00)', tz: 'Atlantic/Azores' }, + { label: 'London, UK (UTC+00:00)', tz: 'Europe/London' }, + { label: 'Paris, France (UTC+01:00)', tz: 'Europe/Paris' }, + { label: 'Cairo, Egypt (UTC+02:00)', tz: 'Africa/Cairo' }, + { label: 'Moscow, Russia (UTC+03:00)', tz: 'Europe/Moscow' }, + { label: 'Dubai, UAE (UTC+04:00)', tz: 'Asia/Dubai' }, + { label: 'Karachi, Pakistan (UTC+05:00)', tz: 'Asia/Karachi' }, + { label: 'Dhaka, Bangladesh (UTC+06:00)', tz: 'Asia/Dhaka' }, + { label: 'Bangkok, Thailand (UTC+07:00)', tz: 'Asia/Bangkok' }, + { label: 'Singapore, Singapore (UTC+08:00)', tz: 'Asia/Singapore' }, + { label: 'Tokyo, Japan (UTC+09:00)', tz: 'Asia/Tokyo' }, + { label: 'Sydney, Australia (UTC+10:00)', tz: 'Australia/Sydney' }, + { label: 'Noumea, New Caledonia (UTC+11:00)', tz: 'Pacific/Noumea' }, + { label: 'Auckland, New Zealand (UTC+12:00)', tz: 'Pacific/Auckland' }, + { label: "Nuku'alofa, Tonga (UTC+13:00)", tz: 'Pacific/Tongatapu' }, + { label: 'Kiritimati, Kiribati (UTC+14:00)', tz: 'Pacific/Kiritimati' }, +]; + +export function timezoneOptionLabel(timezone) { + const option = timezoneOptions.find((item) => item.tz === timezone); + return option ? `${option.label} - ${option.tz}` : timezone; +} + +export function filterTimezoneOptions(search) { + const query = search.trim().toLowerCase(); + if (!query) return timezoneOptions; + return timezoneOptions.filter( + (option) => + option.label.toLowerCase().includes(query) || option.tz.toLowerCase().includes(query), + ); +} diff --git a/src/client/routes/AccountView.jsx b/src/client/routes/AccountView.jsx index 00aa0ee..a39d90c 100644 --- a/src/client/routes/AccountView.jsx +++ b/src/client/routes/AccountView.jsx @@ -1,11 +1,15 @@ import { useEffect, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; -import { ArrowLeft, KeyRound, ShieldCheck, Trash2, UserRound } from 'lucide-react'; +import { ArrowLeft, ChevronDown, KeyRound, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.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'; +import { + filterTimezoneOptions, + timezoneOptionLabel, +} from '../constants/timezones.js'; function requireValue(value, label) { if (!value.trim()) { @@ -32,7 +36,9 @@ function validateOtp(value) { export function AccountView({ onBack, onSignedOut }) { const [account, setAccount] = useState(null); - const [profile, setProfile] = useState({ displayName: '' }); + const [profile, setProfile] = useState({ displayName: '', timezone: 'Asia/Tokyo' }); + const [timezoneDropdownOpen, setTimezoneDropdownOpen] = useState(false); + const [timezoneSearch, setTimezoneSearch] = useState(''); const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '' }); const [totpSetup, setTotpSetup] = useState(null); const [totpStep, setTotpStep] = useState(1); @@ -46,7 +52,7 @@ export function AccountView({ onBack, onSignedOut }) { async function loadAccount() { const data = await request('/api/account'); setAccount(data.account); - setProfile({ displayName: data.account.displayName }); + setProfile({ displayName: data.account.displayName, timezone: data.account.timezone }); } useEffect(() => { @@ -63,10 +69,13 @@ export function AccountView({ onBack, onSignedOut }) { } const data = await request('/api/account/profile', { method: 'PATCH', - body: JSON.stringify({ displayName: profile.displayName.trim() }), + body: JSON.stringify({ + displayName: profile.displayName.trim(), + timezone: profile.timezone, + }), }); setAccount(data.account); - showToast({ type: 'success', message: '表示名を更新しました' }); + showToast({ type: 'success', message: 'ユーザー情報を更新しました' }); } catch (err) { showToast({ type: 'error', message: err.message }); } finally { @@ -171,6 +180,8 @@ export function AccountView({ onBack, onSignedOut }) { return
CertRemind
; } + const filteredTimezoneOptions = filterTimezoneOptions(timezoneSearch); + return (
@@ -193,11 +204,62 @@ export function AccountView({ onBack, onSignedOut }) { setProfile({ displayName: event.target.value })} + onChange={(event) => setProfile({ ...profile, displayName: event.target.value })} maxLength="80" required /> + +
+ + {timezoneDropdownOpen ? ( +
+ +
+ {filteredTimezoneOptions.length === 0 ? ( +
一致するタイムゾーンがありません。
+ ) : ( + filteredTimezoneOptions.map((option) => ( + + )) + )} +
+
+ ) : null} +
+
diff --git a/src/client/routes/NotificationMethodsView.jsx b/src/client/routes/NotificationMethodsView.jsx index c4a2437..0707769 100644 --- a/src/client/routes/NotificationMethodsView.jsx +++ b/src/client/routes/NotificationMethodsView.jsx @@ -1,6 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; -import { ArrowLeft, BellOff, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react'; +import { + ArrowLeft, + BellOff, + BellRing, + Link, + MessageSquareText, + Pencil, + Plus, + RotateCcw, + Save, + Trash2, +} from 'lucide-react'; import { request } from '../api/client.js'; import { ConfirmDialog } from '../components/ConfirmDialog.jsx'; import { Field } from '../components/Field.jsx'; @@ -22,6 +33,24 @@ function validateWebhookForm(form) { } } +function validateWebhookMessageForm(form) { + if (!form.webhookMessageTemplate.trim()) { + throw new Error('メッセージを入力してください'); + } + if (form.webhookMessageTemplate.trim().length > 2000) { + throw new Error('メッセージは2000文字以内で入力してください'); + } +} + +const variableDescriptions = { + expiryDate: '有効期限(日付) yyyy/mm/dd', + expiryDateTime: '有効期限(日付+日時) yyyy/mm/dd hh:mm:ss', + expiryTime: '有効期限(時間) hh:mm:ss', + siteName: 'サイトエイリアス名', + siteDomain: 'サイトドメイン', + condTiming: '通知タイミング', +}; + function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/'); @@ -55,11 +84,16 @@ async function getPushRegistration() { export function NotificationMethodsView({ onBack }) { const [webhooks, setWebhooks] = useState([]); const [vapidPublicKey, setVapidPublicKey] = useState(''); + const [webhookMessageSettings, setWebhookMessageSettings] = useState(null); const [currentPushStatus, setCurrentPushStatus] = useState('unchecked'); const [form, setForm] = useState({ alias: '', url: '' }); const [editingWebhook, setEditingWebhook] = useState(null); const [editForm, setEditForm] = useState({ alias: '', url: '' }); const [editDialogOpen, setEditDialogOpen] = useState(false); + const [messageDialogOpen, setMessageDialogOpen] = useState(false); + const [messageForm, setMessageForm] = useState({ + webhookMessageTemplate: '', + }); const [permission, setPermission] = useState( typeof Notification === 'undefined' ? 'unsupported' : Notification.permission, ); @@ -112,6 +146,7 @@ export function NotificationMethodsView({ onBack }) { const data = await request('/api/notification-methods'); setWebhooks(data.webhooks); setVapidPublicKey(data.vapidPublicKey); + setWebhookMessageSettings(data.webhookMessageSettings); await refreshCurrentPushStatus(data.vapidPublicKey); }, [refreshCurrentPushStatus]); @@ -144,6 +179,13 @@ export function NotificationMethodsView({ onBack }) { setEditDialogOpen(true); } + function openMessageDialog() { + setMessageForm({ + webhookMessageTemplate: webhookMessageSettings?.webhookMessageTemplate ?? '', + }); + setMessageDialogOpen(true); + } + function handleEditDialogOpenChange(open) { setEditDialogOpen(open); if (!open) { @@ -173,6 +215,41 @@ export function NotificationMethodsView({ onBack }) { } } + async function submitWebhookMessageSettings(event) { + event.preventDefault(); + setBusy(true); + try { + validateWebhookMessageForm(messageForm); + await request('/api/notification-methods/webhook-message-settings', { + method: 'PUT', + body: JSON.stringify({ + webhookMessageTemplate: messageForm.webhookMessageTemplate.trim(), + }), + }); + showToast({ type: 'success', message: 'Webhookメッセージを更新しました' }); + setMessageDialogOpen(false); + await loadMethods(); + } catch (err) { + showToast({ type: 'error', message: err.message }); + } finally { + setBusy(false); + } + } + + async function resetWebhookMessageSettings() { + setBusy(true); + try { + await request('/api/notification-methods/webhook-message-settings', { method: 'DELETE' }); + showToast({ type: 'success', message: 'Webhookメッセージをデフォルトに戻しました' }); + setMessageDialogOpen(false); + await loadMethods(); + } catch (err) { + showToast({ type: 'error', message: err.message }); + } finally { + setBusy(false); + } + } + async function deleteWebhook(methodId) { setBusy(true); try { @@ -270,12 +347,18 @@ export function NotificationMethodsView({ onBack }) {
-
- -
-

Webhook

-

Slack互換Webhookとして送信します。

+
+
+ +
+

Webhook

+

Slack互換Webhookとして送信します。

+
+
@@ -304,6 +387,64 @@ export function NotificationMethodsView({ onBack }) {
+ + + + + Webhookメッセージを編集 + + Webhookの本文に使うテンプレートを設定します。日時のタイムゾーンはアカウントのユーザー情報で設定します。 + +
+ +