Compare commits

...

4 Commits

Author SHA1 Message Date
CyberRex
38acbd35bb Webhookのメッセージをカスタマイズできるように 2026-05-27 10:59:58 +09:00
CyberRex
2a4050d442 Webhookの編集をモーダル化 2026-05-27 09:17:36 +09:00
CyberRex
e89f7b4cf3 通知タイミングの追加・編集をモーダルに 2026-05-27 09:06:00 +09:00
CyberRex
a0356e630e ・プッシュ通知の修正
・メニューをスマホに最適化
・アラート送信済みの条件が再度発動しないように修正
2026-05-25 16:29:38 +09:00
17 changed files with 2136 additions and 211 deletions

View File

@@ -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.

View File

@@ -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,
@@ -106,11 +123,19 @@ CREATE TABLE IF NOT EXISTS site_alert_conditions (
threshold_hours integer NOT NULL CHECK (threshold_hours > 0 AND threshold_hours <= 17520),
webhook_method_ids uuid[] NOT NULL DEFAULT '{}',
push_enabled boolean NOT NULL DEFAULT false,
last_notified_certificate_expires_at timestamptz,
last_notified_at timestamptz,
last_notification_skipped_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (site_id, condition_type, threshold_hours)
);
ALTER TABLE site_alert_conditions
ADD COLUMN IF NOT EXISTS last_notified_certificate_expires_at timestamptz,
ADD COLUMN IF NOT EXISTS last_notified_at timestamptz,
ADD COLUMN IF NOT EXISTS last_notification_skipped_at timestamptz;
CREATE INDEX IF NOT EXISTS site_alert_conditions_site_id_idx ON site_alert_conditions(site_id);
DROP TRIGGER IF EXISTS site_alert_conditions_set_updated_at ON site_alert_conditions;

View File

@@ -1,6 +1,6 @@
# CertRemind 開発進捗
最終更新: 2026-05-25
最終更新: 2026-05-27
## 現在の実装状況
@@ -42,13 +42,18 @@
- Webhook 登録 API
- Webhook 更新 API
- Webhook 削除 API
- Webhook メッセージ設定 API
- 現在ブラウザの Push 購読状態確認 API
- Push 購読情報登録 API
- Push 購読情報解除 API
- OpenSSL による証明書発行元・発行日時・期限取得処理
- 監視ジョブで取得した最新の証明書発行元・発行日時・期限・確認日時・取得失敗状態をサイトに保存
- サイトごとの通知条件評価
- 条件一致時のアラート履歴作成
- 通知条件ごとの証明書期限単位の送信済み管理
- 証明書取得失敗時のアラート履歴作成
- Webhook 通知送信処理
- ユーザー設定テンプレートによる Webhook 通知本文生成
- Push 通知送信処理
- `/push-sw.js` の明示的な静的配信
- 二重送信を防ぐ `dedupe_key` 利用
@@ -56,7 +61,7 @@
- 監視ジョブの一回実行スクリプト
- 1 時間ごとに監視ジョブを実行する Node worker
- アカウント情報取得 API
- 表示名更新 API
- 表示名・タイムゾーン更新 API
- パスワード更新 API
- パスワード更新時の全セッション無効化
- TOTP セットアップ API
@@ -107,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`
@@ -140,6 +146,7 @@
- `sessions`
- `sites`
- `notification_methods`
- `user_notification_settings`
- `site_alert_conditions`
- `alert_history`
@@ -151,6 +158,7 @@
- `updated_at` 更新用トリガーを定義。
- ユーザー関連データは `ON DELETE CASCADE` を中心に設計。
- サイト削除時は通知条件も削除される。
- 通知条件は、対象の証明書期限ごとに送信済み状態を保持する。
- アラート履歴のサイト参照は `ON DELETE SET NULL`
## API
@@ -183,7 +191,11 @@ 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
GET /api/account
PATCH /api/account/profile
@@ -223,7 +235,10 @@ pnpm monitor:worker
- 証明書情報として発行元、発行日時、失効日時を表示
- 通知タイミング設定
- 時間 / 日 / 週間の単位指定
- 通知タイミング未設定時の空状態表示
- モーダルでの通知タイミング追加・編集
- 複数タイミングの追加・確認ダイアログ付き削除
- 通知タイミング0件での設定保存
- アプリ内アラート必須表示
- Webhook 選択
- プッシュ通知フラグ設定
@@ -235,12 +250,16 @@ pnpm monitor:worker
- 確認ダイアログ付き履歴削除
- 通知方法管理画面
- Webhook 登録
- Webhook 編集
- モーダルでの Webhook 編集
- モーダルでの Webhook メッセージ編集
- 確認ダイアログ付き Webhook 削除
- ブラウザ Push 通知の許可状態表示
- 現在のブラウザの Push 登録状態表示
- VAPID public key がある場合の Push 購読登録
- 現在のブラウザが登録済みの場合の Push 購読解除
- アカウント設定画面
- 表示名更新
- タイムゾーン設定
- ダイアログでのパスワード更新
- ステップ式ポップアップでの 2 段階認証セットアップ
- 2 段階認証 QR コード表示
@@ -248,7 +267,7 @@ pnpm monitor:worker
- 確認ダイアログ付きアカウント削除
- 認証後画面共通の左サイドメニュー
- PC 幅では展開表示
- スマートフォンなど狭い幅ではアイコンのみの畳み表示
- スマートフォンなど狭い幅では上部の横メニューバーとハンバーガーメニューで表示
- サイト一覧、アラート履歴、通知方法、アカウント、ログアウトに対応
- 認証後画面の URL ルーティング
- サイト一覧、サイト設定、アラート履歴、通知方法、アカウントに個別 URL を付与
@@ -282,7 +301,11 @@ pnpm monitor:worker
- Webhook URL は HTTPS のみ許可。
- Webhook URL は `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否。
- Webhook 更新・削除はログインユーザーの通知方法のみ対象。
- Webhook メッセージ設定はログインユーザー単位で保存し、未設定時は既定テンプレートを使用。
- タイムゾーンはアカウントのユーザー情報として保存し、未設定時は `Asia/Tokyo` を使用。
- Push endpoint は HTTPS のみ許可。
- Push 設定画面では登録済みデバイス一覧を返さず、現在ブラウザの登録状態のみ確認。
- Push 購読解除はログインユーザーの現在ブラウザ endpoint のみ対象。
- OpenSSL 呼び出しはタイムアウトを設定。
- 監視ジョブはサイト単位の失敗で全体を止めない。
- 外部通信を伴う監視処理は並列数を制限。
@@ -336,9 +359,12 @@ API 動作確認:
- API 経由で Webhook 登録成功。
- API 経由で Webhook 更新成功。
- API 経由で Webhook 削除成功。
- API 経由で現在ブラウザの Push 登録状態確認成功。
- API 経由で Push 購読情報登録成功。
- API 経由で Push 購読情報解除成功。
- `pnpm monitor:once` 成功。
- API セキュリティ境界と証明書監視処理のテスト成功。
- 通知条件ごとの重複送信防止、1 時間以上経過時の送信済み扱い、証明書更新時の再通知をテストで確認。
- `/push-sw.js` が SPA fallback ではなく Service Worker JavaScript として返ることをテストで確認。
- サイト登録時の証明書期限初期取得と取得失敗時の登録拒否テスト成功。
- 権限付き実行で OpenSSL による実サイトの証明書期限取得成功。

View File

@@ -14,6 +14,8 @@ services:
interval: 10s
timeout: 5s
retries: 5
ports:
- '127.0.0.1:54320:5432'
app:
build: .

View File

@@ -1,4 +1,5 @@
import { Bell, Globe2, Link, LogOut, ShieldCheck, UserRound } from 'lucide-react';
import { useState } from 'react';
import { Bell, Globe2, Link, LogOut, Menu, ShieldCheck, UserRound, X } from 'lucide-react';
const navItems = [
{ view: 'sites', label: 'サイト一覧', icon: Globe2 },
@@ -8,14 +9,44 @@ const navItems = [
];
export function Sidebar({ activeView, user, onNavigate, onLogout }) {
const [menuOpen, setMenuOpen] = useState(false);
const MenuIcon = menuOpen ? X : Menu;
function handleNavigate(view) {
onNavigate(view);
setMenuOpen(false);
}
function handleLogout() {
setMenuOpen(false);
onLogout();
}
return (
<aside className="sidebar" aria-label="メインメニュー">
<aside className={`sidebar ${menuOpen ? 'menu-open' : ''}`} aria-label="メインメニュー">
<div className="sidebar-brand">
<ShieldCheck aria-hidden="true" size={24} />
<span>CertRemind</span>
</div>
<nav className="sidebar-nav">
<div className="sidebar-mobile-user">
<UserRound aria-hidden="true" size={18} />
<span>{user.displayName}</span>
</div>
<button
className="sidebar-menu-button"
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-controls="sidebar-menu"
aria-label={menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
title={menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
>
<MenuIcon aria-hidden="true" size={22} />
</button>
<nav className="sidebar-nav" id="sidebar-menu">
{navItems.map((item) => {
const Icon = item.icon;
const active = activeView === item.view;
@@ -23,7 +54,7 @@ export function Sidebar({ activeView, user, onNavigate, onLogout }) {
<button
className={`sidebar-link ${active ? 'active' : ''}`}
key={item.view}
onClick={() => onNavigate(item.view)}
onClick={() => handleNavigate(item.view)}
aria-current={active ? 'page' : undefined}
title={item.label}
>
@@ -39,7 +70,7 @@ export function Sidebar({ activeView, user, onNavigate, onLogout }) {
<UserRound aria-hidden="true" size={18} />
<span>{user.displayName}</span>
</div>
<button className="sidebar-link" onClick={onLogout} title="ログアウト">
<button className="sidebar-link" onClick={handleLogout} title="ログアウト">
<LogOut aria-hidden="true" size={20} />
<span>ログアウト</span>
</button>

View File

@@ -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),
);
}

View File

@@ -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 <div className="loading">CertRemind</div>;
}
const filteredTimezoneOptions = filterTimezoneOptions(timezoneSearch);
return (
<main className="app-shell">
<header className="topbar">
@@ -193,11 +204,62 @@ export function AccountView({ onBack, onSignedOut }) {
<Field label="表示名">
<input
value={profile.displayName}
onChange={(event) => setProfile({ displayName: event.target.value })}
onChange={(event) => setProfile({ ...profile, displayName: event.target.value })}
maxLength="80"
required
/>
</Field>
<Field label="タイムゾーン">
<div className="timezone-select">
<button
className="timezone-select-trigger"
type="button"
onClick={() => setTimezoneDropdownOpen((open) => !open)}
aria-expanded={timezoneDropdownOpen}
>
<span>{timezoneOptionLabel(profile.timezone)}</span>
<ChevronDown aria-hidden="true" size={18} />
</button>
{timezoneDropdownOpen ? (
<div className="timezone-select-menu">
<label className="timezone-search">
<Search aria-hidden="true" size={16} />
<input
value={timezoneSearch}
onChange={(event) => setTimezoneSearch(event.target.value)}
placeholder="都市名、国名、UTC、タイムゾーンで検索"
autoFocus
/>
</label>
<div className="timezone-options">
{filteredTimezoneOptions.length === 0 ? (
<div className="timezone-empty">一致するタイムゾーンがありません</div>
) : (
filteredTimezoneOptions.map((option) => (
<button
className={
option.tz === profile.timezone
? 'timezone-option selected'
: 'timezone-option'
}
type="button"
key={option.tz}
onClick={() => {
setProfile({ ...profile, timezone: option.tz });
setTimezoneDropdownOpen(false);
setTimezoneSearch('');
}}
>
<strong>{option.label}</strong>
<span>{option.tz}</span>
</button>
))
)}
</div>
</div>
) : null}
</div>
</Field>
<button className="primary fit-button" disabled={busy}>
保存
</button>

View File

@@ -1,5 +1,17 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
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';
@@ -21,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('_', '/');
@@ -29,52 +59,112 @@ function urlBase64ToUint8Array(base64String) {
}
function permissionText(permission) {
if (permission === 'unsupported') return '非対応';
if (permission === 'granted') return '許可済み';
if (permission === 'denied') return '拒否されています';
return '未設定';
}
function pushStatusText(status) {
if (status === 'checking') return '確認中';
if (status === 'registered') return 'このブラウザは登録済みです';
if (status === 'unregistered') return 'このブラウザは未登録です';
if (status === 'no-subscription') return 'このブラウザは未登録です';
if (status === 'permission-denied') return 'ブラウザ通知が拒否されています';
if (status === 'unconfigured') return 'VAPID public key が未設定です';
if (status === 'unsupported') return 'このブラウザは非対応です';
if (status === 'error') return '状態を確認できませんでした';
return '未確認';
}
async function getPushRegistration() {
return navigator.serviceWorker.register('/push-sw.js');
}
export function NotificationMethodsView({ onBack }) {
const [webhooks, setWebhooks] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [vapidPublicKey, setVapidPublicKey] = useState('');
const [webhookMessageSettings, setWebhookMessageSettings] = useState(null);
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
const [form, setForm] = useState({ alias: '', url: '' });
const [editingId, setEditingId] = useState('');
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,
);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
async function loadMethods() {
const refreshCurrentPushStatus = useCallback(
async (publicKey = vapidPublicKey) => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setCurrentPushStatus('unsupported');
return;
}
if (!publicKey) {
setCurrentPushStatus('unconfigured');
return;
}
if (typeof Notification === 'undefined') {
setCurrentPushStatus('unsupported');
return;
}
setPermission(Notification.permission);
if (Notification.permission === 'denied') {
setCurrentPushStatus('permission-denied');
return;
}
setCurrentPushStatus('checking');
try {
const registration = await getPushRegistration();
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
setCurrentPushStatus('no-subscription');
return;
}
const data = await request('/api/notification-methods/push-subscription-status', {
method: 'POST',
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
setCurrentPushStatus(data.registered ? 'registered' : 'unregistered');
} catch (err) {
setCurrentPushStatus('error');
showToast({ type: 'error', message: err.message });
}
},
[showToast, vapidPublicKey],
);
const loadMethods = useCallback(async () => {
const data = await request('/api/notification-methods');
setWebhooks(data.webhooks);
setPushSubscriptions(data.pushSubscriptions);
setVapidPublicKey(data.vapidPublicKey);
}
setWebhookMessageSettings(data.webhookMessageSettings);
await refreshCurrentPushStatus(data.vapidPublicKey);
}, [refreshCurrentPushStatus]);
useEffect(() => {
loadMethods().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast]);
}, [loadMethods, showToast]);
async function submitWebhook(event) {
event.preventDefault();
setBusy(true);
try {
validateWebhookForm(form);
const endpoint = editingId
? `/api/notification-methods/webhooks/${editingId}`
: '/api/notification-methods/webhooks';
await request(endpoint, {
method: editingId ? 'PATCH' : 'POST',
await request('/api/notification-methods/webhooks', {
method: 'POST',
body: JSON.stringify({ alias: form.alias.trim(), url: form.url.trim() }),
});
setForm({ alias: '', url: '' });
setEditingId('');
showToast({
type: 'success',
message: editingId ? 'Webhookを更新しました' : 'Webhookを登録しました',
});
showToast({ type: 'success', message: 'Webhookを登録しました' });
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
@@ -84,8 +174,80 @@ export function NotificationMethodsView({ onBack }) {
}
function startEdit(webhook) {
setEditingId(webhook.notificationMethodId);
setForm({ alias: webhook.alias, url: webhook.url });
setEditingWebhook(webhook);
setEditForm({ alias: webhook.alias, url: webhook.url });
setEditDialogOpen(true);
}
function openMessageDialog() {
setMessageForm({
webhookMessageTemplate: webhookMessageSettings?.webhookMessageTemplate ?? '',
});
setMessageDialogOpen(true);
}
function handleEditDialogOpenChange(open) {
setEditDialogOpen(open);
if (!open) {
setEditingWebhook(null);
setEditForm({ alias: '', url: '' });
}
}
async function submitWebhookEdit(event) {
event.preventDefault();
if (!editingWebhook) return;
setBusy(true);
try {
validateWebhookForm(editForm);
await request(`/api/notification-methods/webhooks/${editingWebhook.notificationMethodId}`, {
method: 'PATCH',
body: JSON.stringify({ alias: editForm.alias.trim(), url: editForm.url.trim() }),
});
showToast({ type: 'success', message: 'Webhookを更新しました' });
handleEditDialogOpenChange(false);
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
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) {
@@ -110,6 +272,9 @@ export function NotificationMethodsView({ onBack }) {
if (!vapidPublicKey) {
throw new Error('VAPID public key が設定されていません');
}
if (typeof Notification === 'undefined') {
throw new Error('このブラウザはプッシュ通知に対応していません');
}
const nextPermission = await Notification.requestPermission();
setPermission(nextPermission);
@@ -117,18 +282,49 @@ export function NotificationMethodsView({ onBack }) {
throw new Error('ブラウザ通知が許可されませんでした');
}
const registration = await navigator.serviceWorker.register('/push-sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
const registration = await getPushRegistration();
const existingSubscription = await registration.pushManager.getSubscription();
const subscription =
existingSubscription ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
}));
await request('/api/notification-methods/push-subscriptions', {
method: 'POST',
body: JSON.stringify(subscription.toJSON()),
});
showToast({ type: 'success', message: 'プッシュ通知を登録しました' });
await loadMethods();
await refreshCurrentPushStatus();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function unsubscribePush() {
setBusy(true);
try {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('このブラウザはプッシュ通知に対応していません');
}
const registration = await getPushRegistration();
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
await refreshCurrentPushStatus();
throw new Error('解除対象のプッシュ通知登録が見つかりません');
}
await request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
showToast({ type: 'success', message: 'プッシュ通知を解除しました' });
await refreshCurrentPushStatus();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
@@ -151,12 +347,18 @@ export function NotificationMethodsView({ onBack }) {
<section className="workspace notification-layout">
<form className="panel" onSubmit={submitWebhook}>
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
<div className="panel-heading-with-actions">
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
</div>
</div>
<button className="secondary fit-button" type="button" onClick={openMessageDialog}>
<MessageSquareText aria-hidden="true" size={18} />
メッセージの編集
</button>
</div>
<div className="webhook-form">
@@ -180,11 +382,108 @@ export function NotificationMethodsView({ onBack }) {
</Field>
<button className="primary" disabled={busy}>
<Plus aria-hidden="true" size={18} />
{editingId ? '更新' : '登録'}
登録
</button>
</div>
</form>
<Dialog.Root open={messageDialogOpen} onOpenChange={setMessageDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content webhook-message-dialog">
<Dialog.Title className="dialog-title">Webhookメッセージを編集</Dialog.Title>
<Dialog.Description className="dialog-description">
Webhookの本文に使うテンプレートを設定します日時のタイムゾーンはアカウントのユーザー情報で設定します
</Dialog.Description>
<form className="dialog-form" onSubmit={submitWebhookMessageSettings}>
<Field label="メッセージ">
<textarea
value={messageForm.webhookMessageTemplate}
onChange={(event) =>
setMessageForm({
...messageForm,
webhookMessageTemplate: event.target.value,
})
}
maxLength="2000"
rows="6"
required
/>
</Field>
<div className="template-variable-list">
{(webhookMessageSettings?.availableVariables ?? []).map((variable) => (
<div className="template-variable" key={variable}>
<code>{`{{${variable}}}`}</code>
<span>{variableDescriptions[variable]}</span>
</div>
))}
</div>
<div className="dialog-actions split-actions">
<button
className="secondary danger-text"
type="button"
onClick={resetWebhookMessageSettings}
disabled={busy || webhookMessageSettings?.usesDefault}
>
<RotateCcw aria-hidden="true" size={18} />
デフォルトに戻す
</button>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
キャンセル
</button>
</Dialog.Close>
<button className="primary" disabled={busy}>
<Save aria-hidden="true" size={18} />
保存
</button>
</div>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title className="dialog-title">Webhookを編集</Dialog.Title>
<form className="dialog-form" onSubmit={submitWebhookEdit}>
<Field label="エイリアス名">
<input
value={editForm.alias}
onChange={(event) => setEditForm({ ...editForm, alias: event.target.value })}
placeholder="Slack 通知"
maxLength="120"
required
/>
</Field>
<Field label="URL">
<input
value={editForm.url}
onChange={(event) => setEditForm({ ...editForm, url: event.target.value })}
placeholder="https://hooks.slack.com/services/..."
maxLength="2048"
required
/>
</Field>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
キャンセル
</button>
</Dialog.Close>
<button className="primary" disabled={busy || !editingWebhook}>
更新
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<section className="panel">
<h2>登録済みWebhook</h2>
<div className="method-list">
@@ -239,35 +538,45 @@ export function NotificationMethodsView({ onBack }) {
</div>
</div>
<div className="method-list">
<article className="method-row">
<div>
<strong>現在のブラウザ</strong>
<span>{pushStatusText(currentPushStatus)}</span>
</div>
</article>
</div>
<div className="push-actions">
<button
className="secondary"
type="button"
onClick={subscribePush}
disabled={busy || !vapidPublicKey}
>
<BellRing aria-hidden="true" size={18} />
このブラウザを登録
</button>
{currentPushStatus === 'registered' ? (
<ConfirmDialog
title="プッシュ通知を解除"
description="現在のブラウザをプッシュ通知の登録から解除します。"
confirmLabel="解除"
onConfirm={unsubscribePush}
disabled={busy}
trigger={
<button className="secondary danger-text" type="button" disabled={busy}>
<BellOff aria-hidden="true" size={18} />
このブラウザを解除
</button>
}
/>
) : (
<button
className="secondary"
type="button"
onClick={subscribePush}
disabled={busy || !vapidPublicKey}
>
<BellRing aria-hidden="true" size={18} />
このブラウザを登録
</button>
)}
{!vapidPublicKey ? (
<p className="muted">VAPID public key を設定すると登録できます</p>
) : null}
</div>
<div className="method-list">
{pushSubscriptions.length === 0 ? (
<div className="empty">登録済みのブラウザはありません</div>
) : (
pushSubscriptions.map((subscription) => (
<article className="method-row" key={subscription.notificationMethodId}>
<div>
<strong>Browser Push</strong>
<span>{subscription.endpoint}</span>
</div>
</article>
))
)}
</div>
</section>
</section>
</main>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Bell, Plus, Save, Trash2 } from 'lucide-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';
@@ -22,7 +23,9 @@ function toDisplayThreshold(thresholdHours) {
}
function toThresholdHours(condition) {
return Number.parseInt(condition.value, 10) * unitToHours[condition.unit];
const value = String(condition.value).trim();
if (!/^\d+$/.test(value)) return Number.NaN;
return Number.parseInt(value, 10) * unitToHours[condition.unit];
}
function formatCertificateValue(value) {
@@ -32,10 +35,22 @@ function formatCertificateValue(value) {
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([{ value: 7, unit: 'days' }]);
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);
@@ -66,21 +81,65 @@ export function SiteSettingsPanel({ site, onBack }) {
);
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 updateCondition(index, patch) {
setConditions((current) =>
current.map((condition, currentIndex) =>
currentIndex === index ? { ...condition, ...patch } : condition,
),
);
function openAddConditionDialog() {
setEditingConditionIndex(null);
setTimingForm({ value: '', unit: 'days' });
setTimingDialogOpen(true);
}
function addCondition() {
setConditions((current) => [...current, { value: 7, unit: 'days' }]);
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) {
@@ -108,6 +167,9 @@ export function SiteSettingsPanel({ site, onBack }) {
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',
@@ -116,14 +178,20 @@ export function SiteSettingsPanel({ site, onBack }) {
setAlias(nextAlias);
setSavedAlias(nextAlias);
}
await request(`/api/sites/${site.siteId}/settings`, {
method: 'PUT',
body: JSON.stringify({
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
webhookMethodIds,
pushEnabled,
}),
});
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 });
@@ -136,7 +204,7 @@ export function SiteSettingsPanel({ site, onBack }) {
setBusy(true);
try {
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
setConditions([{ value: 7, unit: 'days' }]);
setConditions([]);
setWebhookMethodIds([]);
setPushEnabled(false);
showToast({ type: 'success', message: '設定を削除しました' });
@@ -203,53 +271,103 @@ export function SiteSettingsPanel({ site, onBack }) {
</div>
<div className="condition-list">
{conditions.map((condition, index) => (
<div className="condition-row" key={index}>
<Field label="値">
<input
type="number"
min="1"
max="17520"
value={condition.value}
onChange={(event) => updateCondition(index, { value: event.target.value })}
required
/>
</Field>
<Field label="単位">
<select
value={condition.unit}
onChange={(event) => updateCondition(index, { unit: event.target.value })}
>
<option value="hours">時間前</option>
<option value="days">日前</option>
<option value="weeks">週間前</option>
</select>
</Field>
<ConfirmDialog
title="通知タイミングを削除"
description="この通知タイミングを設定から削除します。"
onConfirm={() => removeCondition(index)}
disabled={conditions.length === 1}
trigger={
{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 danger"
disabled={conditions.length === 1}
aria-label="通知タイミングを削除"
title="削除"
className="icon-button"
onClick={() => openEditConditionDialog(index)}
aria-label="通知タイミングを編集"
title="編集"
>
<Trash2 aria-hidden="true" size={18} />
<Pencil aria-hidden="true" size={18} />
</button>
}
/>
</div>
))}
<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={addCondition}>
<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">

View File

@@ -23,7 +23,8 @@ body {
button,
input,
select {
select,
textarea {
font: inherit;
}
@@ -132,7 +133,8 @@ h1 {
}
input,
select {
select,
textarea {
width: 100%;
min-height: 42px;
border: 1px solid #c9d4d0;
@@ -142,12 +144,18 @@ select {
background: #ffffff;
}
textarea {
resize: vertical;
line-height: 1.5;
}
input:focus {
outline: 3px solid rgba(39, 103, 97, 0.18);
border-color: #276761;
}
select:focus {
select:focus,
textarea:focus {
outline: 3px solid rgba(39, 103, 97, 0.18);
border-color: #276761;
}
@@ -300,7 +308,8 @@ select:focus {
.sidebar-brand,
.sidebar-link,
.sidebar-user {
.sidebar-user,
.sidebar-mobile-user {
display: flex;
align-items: center;
gap: 10px;
@@ -321,6 +330,11 @@ select:focus {
gap: 8px;
}
.sidebar-mobile-user,
.sidebar-menu-button {
display: none;
}
.sidebar-footer {
border-top: 1px solid #d9e1de;
padding-top: 12px;
@@ -344,7 +358,8 @@ select:focus {
color: #276761;
}
.sidebar-user {
.sidebar-user,
.sidebar-mobile-user {
min-width: 0;
min-height: 38px;
padding: 0 10px;
@@ -354,6 +369,7 @@ select:focus {
}
.sidebar-user span,
.sidebar-mobile-user span,
.sidebar-link span,
.sidebar-brand span {
overflow: hidden;
@@ -599,6 +615,13 @@ select:focus {
align-items: center;
}
.panel-heading-with-actions {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.panel-heading svg {
color: #276761;
flex: 0 0 auto;
@@ -617,9 +640,42 @@ select:focus {
.condition-row {
display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(140px, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
align-items: center;
border: 1px solid #d9e1de;
border-radius: 8px;
padding: 12px;
}
.condition-empty {
margin: 0;
border: 1px dashed #b9c7c2;
border-radius: 8px;
padding: 14px;
color: #5a6a65;
background: #f8fbfa;
}
.condition-value {
min-width: 0;
color: #17201d;
font-weight: 800;
}
.condition-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.timing-dialog-form {
grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr);
}
.timing-dialog-form .dialog-actions {
grid-column: 1 / -1;
}
.check-row {
@@ -726,6 +782,150 @@ select:focus {
width: fit-content;
}
.webhook-message-dialog {
width: min(640px, calc(100% - 32px));
}
.timezone-select {
position: relative;
}
.timezone-select-trigger {
width: 100%;
min-height: 42px;
border: 1px solid #c9d4d0;
border-radius: 6px;
padding: 9px 11px;
background: #ffffff;
color: #17201d;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
text-align: left;
}
.timezone-select-trigger:hover,
.timezone-select-trigger[aria-expanded='true'] {
border-color: #276761;
}
.timezone-select-trigger span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timezone-select-trigger svg {
flex: 0 0 auto;
color: #40504b;
}
.timezone-select-menu {
position: absolute;
z-index: 55;
top: calc(100% + 6px);
left: 0;
right: 0;
display: grid;
gap: 8px;
border: 1px solid #c9d4d0;
border-radius: 8px;
padding: 8px;
background: #ffffff;
box-shadow: 0 18px 42px rgba(26, 41, 37, 0.18);
}
.timezone-search {
min-height: 38px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
border: 1px solid #d9e1de;
border-radius: 6px;
padding: 0 10px;
color: #5a6a65;
}
.timezone-search input {
min-height: 36px;
border: 0;
padding: 0;
}
.timezone-search input:focus {
outline: 0;
border-color: transparent;
}
.timezone-options {
max-height: 240px;
overflow: auto;
display: grid;
gap: 4px;
}
.timezone-option {
min-height: 46px;
border: 0;
border-radius: 6px;
padding: 8px 10px;
background: transparent;
color: #17201d;
display: grid;
gap: 3px;
text-align: left;
}
.timezone-option:hover,
.timezone-option.selected {
background: #eef3f1;
color: #276761;
}
.timezone-option span,
.timezone-empty {
color: #5a6a65;
font-size: 13px;
}
.timezone-empty {
padding: 12px;
text-align: center;
}
.template-variable-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.template-variable {
display: grid;
gap: 4px;
border: 1px solid #d9e1de;
border-radius: 6px;
padding: 10px;
background: #f8fbfa;
}
.template-variable code {
color: #276761;
font-weight: 800;
overflow-wrap: anywhere;
}
.template-variable span {
color: #5a6a65;
font-size: 13px;
}
.split-actions {
justify-content: space-between;
}
.totp-box {
display: grid;
gap: 12px;
@@ -909,31 +1109,77 @@ select:focus {
@media (max-width: 720px) {
.app-frame {
grid-template-columns: 64px minmax(0, 1fr);
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.sidebar {
padding: 12px 8px;
gap: 12px;
z-index: 30;
height: auto;
grid-template-columns: minmax(0, 1fr) minmax(0, auto) auto;
grid-template-rows: auto auto auto;
align-items: center;
gap: 10px;
border-right: 0;
border-bottom: 1px solid #d9e1de;
padding: 10px 12px;
}
.sidebar-brand,
.sidebar-link,
.sidebar-user {
justify-content: center;
padding-left: 0;
padding-right: 0;
.sidebar-brand {
min-width: 0;
padding: 0;
}
.sidebar-brand span,
.sidebar-link span,
.sidebar-user span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
.sidebar-mobile-user {
display: flex;
justify-content: flex-end;
min-width: 0;
padding: 0;
}
.sidebar-menu-button {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid #c9d4d0;
border-radius: 6px;
background: #ffffff;
color: #40504b;
}
.sidebar-menu-button:hover {
border-color: #276761;
color: #276761;
}
.sidebar-nav,
.sidebar-footer {
grid-column: 1 / -1;
display: none;
}
.sidebar.menu-open .sidebar-nav,
.sidebar.menu-open .sidebar-footer {
display: grid;
}
.sidebar-nav {
border-top: 1px solid #d9e1de;
padding-top: 10px;
}
.sidebar-footer {
padding-top: 10px;
}
.sidebar-footer .sidebar-user {
display: none;
}
.sidebar-link {
justify-content: flex-start;
padding: 0 10px;
}
.topbar {
@@ -942,9 +1188,9 @@ select:focus {
}
.toast-viewport {
top: 172px;
top: 76px;
right: 12px;
width: min(360px, calc(100vw - 64px - 24px));
width: min(360px, calc(100vw - 24px));
}
.site-form {
@@ -981,20 +1227,35 @@ select:focus {
max-width: 100%;
}
.condition-row {
.condition-row,
.timing-dialog-form {
grid-template-columns: 1fr;
}
.timing-dialog-form .dialog-actions {
grid-column: auto;
}
.certificate-details div {
grid-template-columns: 1fr;
gap: 4px;
}
.webhook-form,
.template-variable-list,
.method-row {
grid-template-columns: 1fr;
}
.panel-heading-with-actions {
align-items: flex-start;
flex-direction: column;
}
.split-actions {
justify-content: flex-start;
}
.alert-filter,
.alert-row {
grid-template-columns: 1fr;

View File

@@ -5,11 +5,13 @@ import { z } from 'zod';
import { pool, query } from '../../db/pool.js';
import { destroySession, requireAuth } from '../../middleware/auth.js';
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
import { DEFAULT_TIMEZONE, validateTimezone } from '../notificationMethods/webhookMessageSettings.js';
const router = new Hono();
const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80),
timezone: z.string().trim().min(1).max(80),
});
const passwordSchema = z.object({
@@ -42,15 +44,22 @@ function publicAccount(row) {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
timezone: row.timezone ?? DEFAULT_TIMEZONE,
totpEnabled: Boolean(row.otp_secret),
};
}
async function getAccount(userId) {
const result = await query(
`SELECT u.user_id, u.username, u.display_name, u.password_hash, t.otp_secret
`SELECT u.user_id,
u.username,
u.display_name,
u.password_hash,
t.otp_secret,
ns.timezone
FROM users u
LEFT JOIN user_totp t ON t.user_id = u.user_id
LEFT JOIN user_notification_settings ns ON ns.user_id = u.user_id
WHERE u.user_id = $1`,
[userId],
);
@@ -82,20 +91,49 @@ router.patch('/profile', async (c) => {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const result = await query(
`WITH updated AS (
UPDATE users
SET display_name = $2
WHERE user_id = $1
RETURNING user_id, username, display_name
)
SELECT u.user_id, u.username, u.display_name, t.otp_secret
FROM updated u
LEFT JOIN user_totp t ON t.user_id = u.user_id`,
[c.get('user').user_id, body.data.displayName],
);
let timezone;
try {
timezone = validateTimezone(body.data.timezone);
} catch (error) {
throw badRequest(error.message);
}
return c.json({ account: publicAccount(result.rows[0]) });
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(
`WITH updated AS (
UPDATE users
SET display_name = $2
WHERE user_id = $1
RETURNING user_id, username, display_name
),
notification_settings AS (
INSERT INTO user_notification_settings (user_id, timezone)
VALUES ($1, $3)
ON CONFLICT (user_id)
DO UPDATE SET timezone = EXCLUDED.timezone
RETURNING user_id, timezone
)
SELECT u.user_id,
u.username,
u.display_name,
t.otp_secret,
ns.timezone
FROM updated u
LEFT JOIN user_totp t ON t.user_id = u.user_id
LEFT JOIN notification_settings ns ON ns.user_id = u.user_id`,
[c.get('user').user_id, body.data.displayName, timezone],
);
await client.query('COMMIT');
return c.json({ account: publicAccount(result.rows[0]) });
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
});
router.patch('/password', async (c) => {

View File

@@ -1,8 +1,25 @@
import { pool } from '../../db/pool.js';
import { renderWebhookExpiryMessage } from '../notificationMethods/webhookMessageSettings.js';
import { getCertificateExpiry } from './certificate.js';
import { deliverNotifications } from './notifications.js';
const DEFAULT_CONCURRENCY = 4;
const MISSED_NOTIFICATION_GRACE_HOURS = 1;
function toTimestamp(value) {
if (!value) return null;
return value instanceof Date ? value.getTime() : new Date(value).getTime();
}
function isSameCertificateExpiry(left, right) {
const leftTimestamp = toTimestamp(left);
const rightTimestamp = toTimestamp(right);
return leftTimestamp !== null && rightTimestamp !== null && leftTimestamp === rightTimestamp;
}
function hoursUntilExpiry(certificate) {
return (certificate.expiresAt.getTime() - Date.now()) / 3_600_000;
}
function alertChannels(condition) {
const channels = ['app'];
@@ -39,7 +56,11 @@ async function loadMonitoringTargets(client) {
'site_alert_condition_id', c.site_alert_condition_id,
'threshold_hours', c.threshold_hours,
'webhook_method_ids', c.webhook_method_ids,
'push_enabled', c.push_enabled
'push_enabled', c.push_enabled,
'last_notified_certificate_expires_at',
c.last_notified_certificate_expires_at,
'last_notified_at', c.last_notified_at,
'last_notification_skipped_at', c.last_notification_skipped_at
)
ORDER BY c.threshold_hours ASC
) FILTER (WHERE c.site_alert_condition_id IS NOT NULL),
@@ -78,6 +99,16 @@ async function loadPushSubscriptions(client, userId) {
return result.rows;
}
async function loadUserNotificationSettings(client, userId) {
const result = await client.query(
`SELECT webhook_message_template, timezone
FROM user_notification_settings
WHERE user_id = $1`,
[userId],
);
return result.rows[0] ?? null;
}
async function createAlert(
client,
{ site, alertType, message, deliveryChannels, deliveryResult, dedupeKey },
@@ -124,13 +155,52 @@ async function recordCertificateFailure(client, site, error) {
);
}
async function markConditionNotified(client, condition, certificate) {
await client.query(
`UPDATE site_alert_conditions
SET last_notified_certificate_expires_at = $2,
last_notified_at = now(),
last_notification_skipped_at = NULL
WHERE site_alert_condition_id = $1`,
[condition.site_alert_condition_id, certificate.expiresAt],
);
}
async function markConditionSkipped(client, condition, certificate) {
await client.query(
`UPDATE site_alert_conditions
SET last_notified_certificate_expires_at = $2,
last_notification_skipped_at = now()
WHERE site_alert_condition_id = $1`,
[condition.site_alert_condition_id, certificate.expiresAt],
);
}
async function processMatchingCondition(client, site, condition, certificate) {
if (certificate.hoursUntilExpiry > condition.threshold_hours) {
return { alerted: false };
}
if (
isSameCertificateExpiry(condition.last_notified_certificate_expires_at, certificate.expiresAt)
) {
return { alerted: false, alreadyNotified: true };
}
if (condition.threshold_hours - hoursUntilExpiry(certificate) >= MISSED_NOTIFICATION_GRACE_HOURS) {
await markConditionSkipped(client, condition, certificate);
return { alerted: false, skipped: true };
}
const message = buildExpiryMessage(site, condition, certificate);
const webhooks = await loadWebhooks(client, site.user_id, condition.webhook_method_ids);
const webhookMessageBody =
webhooks.length > 0
? renderWebhookExpiryMessage({
site,
condition,
certificate,
settings: await loadUserNotificationSettings(client, site.user_id),
})
: message.body;
const pushSubscriptions = condition.push_enabled
? await loadPushSubscriptions(client, site.user_id)
: [];
@@ -139,6 +209,7 @@ async function processMatchingCondition(client, site, condition, certificate) {
pushSubscriptions,
pushEnabled: condition.push_enabled,
message,
webhookMessageBody,
});
const dedupeKey = `certificate-expiring:${site.site_id}:${condition.threshold_hours}:${certificate.expiresAt.toISOString()}`;
const alert = await createAlert(client, {
@@ -149,6 +220,7 @@ async function processMatchingCondition(client, site, condition, certificate) {
deliveryResult,
dedupeKey,
});
await markConditionNotified(client, condition, certificate);
return { alerted: Boolean(alert), dedupeKey };
}

View File

@@ -11,7 +11,7 @@ function configureWebPush() {
return true;
}
export async function sendWebhookNotification(webhook, message) {
export async function sendWebhookNotification(webhook, message, webhookMessageBody = message.body) {
const response = await fetch(webhook.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -20,7 +20,7 @@ export async function sendWebhookNotification(webhook, message) {
attachments: [
{
color: message.severity === 'error' ? 'danger' : 'warning',
text: message.body,
text: webhookMessageBody,
},
],
}),
@@ -51,7 +51,13 @@ export async function sendPushNotification(subscription, message) {
);
}
export async function deliverNotifications({ webhooks, pushSubscriptions, pushEnabled, message }) {
export async function deliverNotifications({
webhooks,
pushSubscriptions,
pushEnabled,
message,
webhookMessageBody = message.body,
}) {
const results = {
app: { ok: true },
webhooks: [],
@@ -60,7 +66,7 @@ export async function deliverNotifications({ webhooks, pushSubscriptions, pushEn
for (const webhook of webhooks) {
try {
await sendWebhookNotification(webhook, message);
await sendWebhookNotification(webhook, message, webhookMessageBody);
results.webhooks.push({ notificationMethodId: webhook.notification_method_id, ok: true });
} catch (error) {
results.webhooks.push({

View File

@@ -5,6 +5,10 @@ import { query } from '../../db/pool.js';
import { requireAuth } from '../../middleware/auth.js';
import { badRequest, notFound } from '../../utils/httpErrors.js';
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
import {
serializeWebhookMessageSettings,
validateWebhookMessageTemplate,
} from './webhookMessageSettings.js';
const router = new Hono();
@@ -21,6 +25,14 @@ const pushSubscriptionSchema = z.object({
}),
});
const pushSubscriptionStatusSchema = z.object({
endpoint: z.string().trim().url().max(2048),
});
const webhookMessageSettingsSchema = z.object({
webhookMessageTemplate: z.string().max(2000),
});
function serializeWebhook(row) {
return {
notificationMethodId: row.notification_method_id,
@@ -31,41 +43,35 @@ function serializeWebhook(row) {
};
}
function serializePushSubscription(row) {
return {
notificationMethodId: row.notification_method_id,
endpoint: row.push_endpoint,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
router.use('*', requireAuth);
router.get('/', async (c) => {
const user = c.get('user');
const result = await query(
`SELECT notification_method_id,
notification_type,
alias,
url,
push_endpoint,
created_at,
updated_at
FROM notification_methods
WHERE user_id = $1
ORDER BY created_at DESC`,
[user.user_id],
);
const [webhookResult, settingsResult] = await Promise.all([
query(
`SELECT notification_method_id,
alias,
url,
created_at,
updated_at
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
ORDER BY created_at DESC`,
[user.user_id],
),
query(
`SELECT webhook_message_template, timezone
FROM user_notification_settings
WHERE user_id = $1`,
[user.user_id],
),
]);
return c.json({
webhooks: result.rows
.filter((row) => row.notification_type === 'webhook')
.map(serializeWebhook),
pushSubscriptions: result.rows
.filter((row) => row.notification_type === 'push')
.map(serializePushSubscription),
webhooks: webhookResult.rows.map(serializeWebhook),
vapidPublicKey: env.vapidPublicKey,
webhookMessageSettings: serializeWebhookMessageSettings(settingsResult.rows[0]),
});
});
@@ -150,6 +156,66 @@ router.delete('/webhooks/:methodId', async (c) => {
return c.json({ ok: true });
});
router.put('/webhook-message-settings', async (c) => {
const body = webhookMessageSettingsSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
let webhookMessageTemplate;
try {
webhookMessageTemplate = validateWebhookMessageTemplate(body.data.webhookMessageTemplate);
} catch (error) {
throw badRequest(error.message);
}
const result = await query(
`INSERT INTO user_notification_settings (user_id, webhook_message_template)
VALUES ($1, $2)
ON CONFLICT (user_id)
DO UPDATE SET webhook_message_template = EXCLUDED.webhook_message_template
RETURNING webhook_message_template, timezone`,
[c.get('user').user_id, webhookMessageTemplate],
);
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
});
router.delete('/webhook-message-settings', async (c) => {
const result = await query(
`UPDATE user_notification_settings
SET webhook_message_template = NULL
WHERE user_id = $1
RETURNING webhook_message_template, timezone`,
[c.get('user').user_id],
);
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
});
router.post('/push-subscription-status', async (c) => {
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('購読状態を確認できません', body.error.flatten());
}
const endpoint = new URL(body.data.endpoint);
if (endpoint.protocol !== 'https:') {
throw badRequest('Push endpoint は HTTPS である必要があります');
}
const result = await query(
`SELECT notification_method_id
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'push'
AND push_endpoint = $2
LIMIT 1`,
[c.get('user').user_id, body.data.endpoint],
);
return c.json({ registered: Boolean(result.rows[0]) });
});
router.post('/push-subscriptions', async (c) => {
const body = pushSubscriptionSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
@@ -178,7 +244,43 @@ router.post('/push-subscriptions', async (c) => {
[user.user_id, body.data.endpoint, body.data.keys.p256dh, body.data.keys.auth],
);
return c.json({ pushSubscription: serializePushSubscription(result.rows[0]) }, 201);
return c.json(
{
pushSubscription: {
notificationMethodId: result.rows[0].notification_method_id,
createdAt: result.rows[0].created_at,
updatedAt: result.rows[0].updated_at,
},
},
201,
);
});
router.delete('/push-subscriptions', async (c) => {
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('購読情報を確認してください', body.error.flatten());
}
const endpoint = new URL(body.data.endpoint);
if (endpoint.protocol !== 'https:') {
throw badRequest('Push endpoint は HTTPS である必要があります');
}
const result = await query(
`DELETE FROM notification_methods
WHERE user_id = $1
AND notification_type = 'push'
AND push_endpoint = $2
RETURNING notification_method_id`,
[c.get('user').user_id, body.data.endpoint],
);
if (!result.rows[0]) {
throw notFound('Push 購読情報が見つかりません');
}
return c.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,117 @@
export const DEFAULT_TIMEZONE = 'Asia/Tokyo';
export const DEFAULT_WEBHOOK_MESSAGE_TEMPLATE = `{{siteDomain}} の有効期限が {{condTiming}} になりました。
{{expiryDateTime}} に期限切れになります。`;
export const WEBHOOK_MESSAGE_VARIABLES = [
'expiryDate',
'expiryDateTime',
'expiryTime',
'siteName',
'siteDomain',
'condTiming',
];
const VARIABLE_PATTERN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
export function normalizeWebhookMessageTemplate(template) {
return String(template ?? '').trim();
}
export function validateWebhookMessageTemplate(template) {
const normalized = normalizeWebhookMessageTemplate(template);
if (!normalized) {
throw new Error('メッセージを入力してください');
}
if (normalized.length > 2000) {
throw new Error('メッセージは2000文字以内で入力してください');
}
const unsupportedVariables = new Set();
for (const match of normalized.matchAll(VARIABLE_PATTERN)) {
if (!WEBHOOK_MESSAGE_VARIABLES.includes(match[1])) {
unsupportedVariables.add(match[1]);
}
}
if (unsupportedVariables.size > 0) {
throw new Error(`未対応のテンプレート変数があります: ${[...unsupportedVariables].join(', ')}`);
}
return normalized;
}
export function validateTimezone(timezone) {
const normalized = String(timezone ?? '').trim();
if (!normalized) {
throw new Error('タイムゾーンを入力してください');
}
if (normalized.length > 80) {
throw new Error('タイムゾーンは80文字以内で入力してください');
}
try {
new Intl.DateTimeFormat('en-US', { timeZone: normalized }).format(new Date());
} catch {
throw new Error('タイムゾーンが不正です');
}
return normalized;
}
export function serializeWebhookMessageSettings(row) {
const hasCustomTemplate = Boolean(row?.webhook_message_template?.trim());
return {
webhookMessageTemplate: row?.webhook_message_template ?? DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
timezone: row?.timezone ?? DEFAULT_TIMEZONE,
usesDefault: !hasCustomTemplate,
defaultWebhookMessageTemplate: DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
availableVariables: WEBHOOK_MESSAGE_VARIABLES,
};
}
function dateParts(date, timezone) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
}
export function formatConditionTiming(thresholdHours) {
if (thresholdHours % 168 === 0) return `${thresholdHours / 168}週間前`;
if (thresholdHours % 24 === 0) return `${thresholdHours / 24}日前`;
return `${thresholdHours}時間前`;
}
export function renderWebhookExpiryMessage({ site, condition, certificate, settings }) {
const template =
settings?.webhook_message_template?.trim() || DEFAULT_WEBHOOK_MESSAGE_TEMPLATE;
const timezone = validateTimezone(settings?.timezone || DEFAULT_TIMEZONE);
const parts = dateParts(certificate.expiresAt, timezone);
const siteDomain = (() => {
try {
return new URL(site.url).hostname;
} catch {
return site.url;
}
})();
const values = {
expiryDate: `${parts.year}/${parts.month}/${parts.day}`,
expiryDateTime: `${parts.year}/${parts.month}/${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`,
expiryTime: `${parts.hour}:${parts.minute}:${parts.second}`,
siteName: site.alias,
siteDomain,
condTiming: formatConditionTiming(condition.threshold_hours),
};
return template.replace(VARIABLE_PATTERN, (match, name) => values[name] ?? match);
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/server/app.js';
import { query } from '../src/server/db/pool.js';
import { pool, query } from '../src/server/db/pool.js';
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
vi.mock('../src/server/db/pool.js', () => ({
@@ -18,6 +18,7 @@ const USER_ID = '11111111-1111-4111-8111-111111111111';
const SITE_ID = '22222222-2222-4222-8222-222222222222';
const ALERT_ID = '33333333-3333-4333-8333-333333333333';
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
const PUSH_ENDPOINT = 'https://push.example.com/subscription';
function authCookie() {
return 'certremind_session=session-1';
@@ -49,6 +50,7 @@ function mockAuthenticatedUser() {
describe('API security boundaries', () => {
beforeEach(() => {
query.mockReset();
pool.connect.mockReset();
getCertificateExpiry.mockReset();
});
@@ -143,6 +145,348 @@ describe('API security boundaries', () => {
expect(query).toHaveBeenCalledTimes(1);
});
it('does not expose registered push subscription lists', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(sql).toContain("notification_type = 'webhook'");
expect(params).toEqual([USER_ID]);
return {
rows: [
{
notification_method_id: WEBHOOK_ID,
alias: 'Deploy hook',
url: 'https://hooks.example.com/',
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
}).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM user_notification_settings');
expect(params).toEqual([USER_ID]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods', {
headers: {
Cookie: authCookie(),
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.webhooks).toHaveLength(1);
expect(data).not.toHaveProperty('pushSubscriptions');
expect(data.webhookMessageSettings).toMatchObject({
timezone: 'Asia/Tokyo',
usesDefault: true,
});
});
it('requires a CSRF token when saving webhook message settings', async () => {
const app = createApp();
const response = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
webhookMessageTemplate: '{{siteDomain}}',
}),
});
expect(response.status).toBe(403);
expect(query).not.toHaveBeenCalled();
});
it('rejects invalid webhook message templates before persistence', async () => {
mockAuthenticatedUser();
const app = createApp();
const unsupportedVariable = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
webhookMessageTemplate: '{{unknownValue}}',
}),
});
expect(unsupportedVariable.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('rejects invalid account timezone before persistence', async () => {
mockAuthenticatedUser();
const app = createApp();
const invalidTimezone = await app.request('/api/account/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
displayName: 'Alice',
timezone: 'Mars/Base',
}),
});
expect(invalidTimezone.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
expect(pool.connect).not.toHaveBeenCalled();
});
it('saves account timezone for the session user only', async () => {
const client = {
query: vi.fn(async (sql, params) => {
if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] };
expect(sql).toContain('INSERT INTO user_notification_settings');
expect(params).toEqual([USER_ID, 'Alice', 'America/New_York']);
return {
rows: [
{
user_id: USER_ID,
username: 'alice',
display_name: 'Alice',
otp_secret: null,
timezone: 'America/New_York',
},
],
};
}),
release: vi.fn(),
};
pool.connect.mockResolvedValue(client);
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/account/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
displayName: 'Alice',
timezone: 'America/New_York',
}),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
account: { timezone: 'America/New_York' },
});
expect(client.query).toHaveBeenCalledWith('COMMIT');
expect(client.release).toHaveBeenCalledOnce();
});
it('saves and deletes webhook message settings for the session user only', async () => {
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('INSERT INTO user_notification_settings');
expect(params).toEqual([USER_ID, '{{siteDomain}}']);
return {
rows: [
{
webhook_message_template: '{{siteDomain}}',
timezone: 'Asia/Tokyo',
},
],
};
});
const app = createApp();
const saveResponse = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
webhookMessageTemplate: '{{siteDomain}}',
}),
});
expect(saveResponse.status).toBe(200);
query.mockReset();
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('UPDATE user_notification_settings');
expect(sql).toContain('webhook_message_template = NULL');
expect(params).toEqual([USER_ID]);
return { rows: [{ webhook_message_template: null, timezone: 'Asia/Tokyo' }] };
});
const deleteResponse = await app.request('/api/notification-methods/webhook-message-settings', {
method: 'DELETE',
headers: {
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
});
expect(deleteResponse.status).toBe(200);
});
it('reports the current browser push subscription as registered for the session user', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(sql).toContain("notification_type = 'push'");
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ registered: true });
});
it('reports push subscriptions for other users or unknown endpoints as unregistered', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ registered: false });
});
it('rejects non-HTTPS push subscription status endpoints', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscription-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('requires a CSRF token when deleting push subscriptions', async () => {
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(403);
expect(query).not.toHaveBeenCalled();
});
it('deletes the current user push subscription by endpoint', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('DELETE FROM notification_methods');
expect(sql).toContain("notification_type = 'push'");
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
});
it('does not delete push subscriptions owned by other users or unknown endpoints', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('DELETE FROM notification_methods');
expect(params).toEqual([USER_ID, PUSH_ENDPOINT]);
return { rows: [] };
});
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: PUSH_ENDPOINT }),
});
expect(response.status).toBe(404);
});
it('rejects non-HTTPS push subscription delete endpoints', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('stores the initial certificate metadata when creating a site', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runCertificateMonitoring } from '../src/server/modules/monitoring/monitor.js';
import { pool } from '../src/server/db/pool.js';
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
@@ -30,16 +30,24 @@ vi.mock('../src/server/modules/monitoring/notifications.js', () => ({
const SITE_ID = '22222222-2222-4222-8222-222222222222';
const USER_ID = '11111111-1111-4111-8111-111111111111';
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
const CONDITION_ID = '55555555-5555-4555-8555-555555555555';
const NOW = new Date('2026-05-25T00:00:00.000Z');
describe('certificate monitoring', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('stores the latest certificate expiry and creates an alert when a threshold matches', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 12 * 60 * 60 * 1000);
const expiresAt = new Date(Date.now() + 11.5 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
@@ -63,8 +71,8 @@ describe('certificate monitoring', () => {
alias: 'Example',
conditions: [
{
site_alert_condition_id: '55555555-5555-4555-8555-555555555555',
threshold_hours: 24,
site_alert_condition_id: CONDITION_ID,
threshold_hours: 12,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: true,
},
@@ -94,6 +102,11 @@ describe('certificate monitoring', () => {
};
}
if (sql.includes('FROM user_notification_settings')) {
expect(params).toEqual([USER_ID]);
return { rows: [] };
}
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
expect(params).toEqual([USER_ID]);
return {
@@ -116,6 +129,13 @@ describe('certificate monitoring', () => {
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
if (sql.includes('UPDATE site_alert_conditions')) {
expect(sql).toContain('last_notified_certificate_expires_at');
expect(sql).toContain('last_notified_at = now()');
expect(params).toEqual([CONDITION_ID, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
@@ -123,7 +143,12 @@ describe('certificate monitoring', () => {
expect(pool.connect).toHaveBeenCalledOnce();
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
expect(deliverNotifications).toHaveBeenCalledOnce();
expect(deliverNotifications).toHaveBeenCalledWith(
expect.objectContaining({
webhookMessageBody:
'example.com の有効期限が 12時間前 になりました。\n2026/05/25 20:30:00 に期限切れになります。',
}),
);
expect(mocks.client.release).toHaveBeenCalledOnce();
expect(result).toMatchObject({
checkedSites: 1,
@@ -222,4 +247,348 @@ describe('certificate monitoring', () => {
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
});
});
it('does not notify again when the same certificate expiry was already handled', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 11.5 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 12,
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [
{
site_alert_condition_id: CONDITION_ID,
threshold_hours: 12,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: true,
last_notified_certificate_expires_at: expiresAt,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(deliverNotifications).not.toHaveBeenCalled();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 0,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
});
});
it('marks a condition as handled without notifying when it is more than an hour late', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 23 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 23,
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [
{
site_alert_condition_id: CONDITION_ID,
threshold_hours: 24,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: true,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
if (sql.includes('UPDATE site_alert_conditions')) {
expect(sql).toContain('last_notification_skipped_at = now()');
expect(params).toEqual([CONDITION_ID, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(deliverNotifications).not.toHaveBeenCalled();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 0,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
});
});
it('notifies and records handled state when a matching condition is less than an hour late', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 23.5 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 23,
});
deliverNotifications.mockResolvedValue({
webhook: [],
push: { ok: true },
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [
{
site_alert_condition_id: CONDITION_ID,
threshold_hours: 24,
webhook_method_ids: [],
push_enabled: true,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
expect(params).toEqual([USER_ID]);
return { rows: [] };
}
if (sql.includes('INSERT INTO alert_history')) {
expect(params[2]).toBe('certificate_expiring');
expect(params[4]).toEqual(['app', 'push']);
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
if (sql.includes('UPDATE site_alert_conditions')) {
expect(sql).toContain('last_notified_at = now()');
expect(params).toEqual([CONDITION_ID, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(deliverNotifications).toHaveBeenCalledOnce();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 1,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
});
});
it('notifies again when a new certificate expiry is observed', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const previousExpiresAt = new Date('2026-05-30T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 11.5 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 12,
});
deliverNotifications.mockResolvedValue({
webhook: [],
push: { ok: false, skipped: true },
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [
{
site_alert_condition_id: CONDITION_ID,
threshold_hours: 12,
webhook_method_ids: [],
push_enabled: false,
last_notified_certificate_expires_at: previousExpiresAt,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
if (sql.includes('INSERT INTO alert_history')) {
expect(params[2]).toBe('certificate_expiring');
expect(params[4]).toEqual(['app']);
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
if (sql.includes('UPDATE site_alert_conditions')) {
expect(params).toEqual([CONDITION_ID, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(deliverNotifications).toHaveBeenCalledOnce();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 1,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
});
});
it('renders a custom webhook message with the configured timezone and timing labels', async () => {
vi.setSystemTime(new Date('2026-12-24T05:36:07.000Z'));
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date('2026-12-31T05:06:07.000Z');
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 168,
});
deliverNotifications.mockResolvedValue({
webhook: [],
push: { ok: false, skipped: true },
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example Site',
conditions: [
{
site_alert_condition_id: CONDITION_ID,
threshold_hours: 168,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: false,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'webhook'")) {
return {
rows: [
{
notification_method_id: WEBHOOK_ID,
alias: 'Deploy hook',
url: 'https://hooks.example.com/',
},
],
};
}
if (sql.includes('FROM user_notification_settings')) {
expect(params).toEqual([USER_ID]);
return {
rows: [
{
webhook_message_template:
'{{siteName}}/{{siteDomain}}/{{condTiming}}/{{expiryDate}}/{{expiryTime}}/{{expiryDateTime}}',
timezone: 'America/New_York',
},
],
};
}
if (sql.includes('INSERT INTO alert_history')) {
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
if (sql.includes('UPDATE site_alert_conditions')) {
expect(params).toEqual([CONDITION_ID, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
await runCertificateMonitoring({ concurrency: 1 });
expect(deliverNotifications).toHaveBeenCalledWith(
expect.objectContaining({
webhookMessageBody:
'Example Site/example.com/1週間前/2026/12/31/00:06:07/2026/12/31 00:06:07',
}),
);
});
});