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