379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { ArrowLeft, BellOff, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
|
|
import { request } from '../api/client.js';
|
|
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
|
import { Field } from '../components/Field.jsx';
|
|
import { useToast } from '../components/Toast.jsx';
|
|
|
|
function validateWebhookForm(form) {
|
|
if (!form.alias.trim()) {
|
|
throw new Error('エイリアス名を入力してください');
|
|
}
|
|
if (form.alias.trim().length > 120) {
|
|
throw new Error('エイリアス名は120文字以内で入力してください');
|
|
}
|
|
if (!form.url.trim()) {
|
|
throw new Error('Webhook URL を入力してください');
|
|
}
|
|
const url = new URL(form.url.trim());
|
|
if (url.protocol !== 'https:') {
|
|
throw new Error('Webhook URL は HTTPS で入力してください');
|
|
}
|
|
}
|
|
|
|
function urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
|
|
const rawData = window.atob(base64);
|
|
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
|
}
|
|
|
|
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 [vapidPublicKey, setVapidPublicKey] = useState('');
|
|
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
|
|
const [form, setForm] = useState({ alias: '', url: '' });
|
|
const [editingId, setEditingId] = useState('');
|
|
const [permission, setPermission] = useState(
|
|
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
const { showToast } = useToast();
|
|
|
|
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);
|
|
setVapidPublicKey(data.vapidPublicKey);
|
|
await refreshCurrentPushStatus(data.vapidPublicKey);
|
|
}, [refreshCurrentPushStatus]);
|
|
|
|
useEffect(() => {
|
|
loadMethods().catch((err) => showToast({ type: 'error', message: err.message }));
|
|
}, [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',
|
|
body: JSON.stringify({ alias: form.alias.trim(), url: form.url.trim() }),
|
|
});
|
|
setForm({ alias: '', url: '' });
|
|
setEditingId('');
|
|
showToast({
|
|
type: 'success',
|
|
message: editingId ? 'Webhookを更新しました' : 'Webhookを登録しました',
|
|
});
|
|
await loadMethods();
|
|
} catch (err) {
|
|
showToast({ type: 'error', message: err.message });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function startEdit(webhook) {
|
|
setEditingId(webhook.notificationMethodId);
|
|
setForm({ alias: webhook.alias, url: webhook.url });
|
|
}
|
|
|
|
async function deleteWebhook(methodId) {
|
|
setBusy(true);
|
|
try {
|
|
await request(`/api/notification-methods/webhooks/${methodId}`, { method: 'DELETE' });
|
|
showToast({ type: 'success', message: 'Webhookを削除しました' });
|
|
await loadMethods();
|
|
} catch (err) {
|
|
showToast({ type: 'error', message: err.message });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function subscribePush() {
|
|
setBusy(true);
|
|
try {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
throw new Error('このブラウザはプッシュ通知に対応していません');
|
|
}
|
|
if (!vapidPublicKey) {
|
|
throw new Error('VAPID public key が設定されていません');
|
|
}
|
|
if (typeof Notification === 'undefined') {
|
|
throw new Error('このブラウザはプッシュ通知に対応していません');
|
|
}
|
|
|
|
const nextPermission = await Notification.requestPermission();
|
|
setPermission(nextPermission);
|
|
if (nextPermission !== 'granted') {
|
|
throw new Error('ブラウザ通知が許可されませんでした');
|
|
}
|
|
|
|
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 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 {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="app-shell">
|
|
<header className="topbar">
|
|
<button className="back-button" onClick={onBack}>
|
|
<ArrowLeft aria-hidden="true" size={18} />
|
|
サイト一覧
|
|
</button>
|
|
<div className="settings-title">
|
|
<span className="eyebrow">通知方法</span>
|
|
<h1>通知方法の管理</h1>
|
|
</div>
|
|
</header>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div className="webhook-form">
|
|
<Field label="エイリアス名">
|
|
<input
|
|
value={form.alias}
|
|
onChange={(event) => setForm({ ...form, alias: event.target.value })}
|
|
placeholder="Slack 通知"
|
|
maxLength="120"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="URL">
|
|
<input
|
|
value={form.url}
|
|
onChange={(event) => setForm({ ...form, url: event.target.value })}
|
|
placeholder="https://hooks.slack.com/services/..."
|
|
maxLength="2048"
|
|
required
|
|
/>
|
|
</Field>
|
|
<button className="primary" disabled={busy}>
|
|
<Plus aria-hidden="true" size={18} />
|
|
{editingId ? '更新' : '登録'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<section className="panel">
|
|
<h2>登録済みWebhook</h2>
|
|
<div className="method-list">
|
|
{webhooks.length === 0 ? (
|
|
<div className="empty">Webhookはまだ登録されていません。</div>
|
|
) : (
|
|
webhooks.map((webhook) => (
|
|
<article className="method-row" key={webhook.notificationMethodId}>
|
|
<div>
|
|
<strong>{webhook.alias}</strong>
|
|
<span>{webhook.url}</span>
|
|
</div>
|
|
<div className="site-actions">
|
|
<button
|
|
className="icon-button"
|
|
type="button"
|
|
onClick={() => startEdit(webhook)}
|
|
aria-label={`${webhook.alias}を編集`}
|
|
title="編集"
|
|
>
|
|
<Pencil aria-hidden="true" size={18} />
|
|
</button>
|
|
<ConfirmDialog
|
|
title="Webhookを削除"
|
|
description={`${webhook.alias}を通知方法から削除します。`}
|
|
onConfirm={() => deleteWebhook(webhook.notificationMethodId)}
|
|
disabled={busy}
|
|
trigger={
|
|
<button
|
|
className="icon-button danger"
|
|
type="button"
|
|
aria-label={`${webhook.alias}を削除`}
|
|
title="削除"
|
|
>
|
|
<Trash2 aria-hidden="true" size={18} />
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<BellRing aria-hidden="true" size={20} />
|
|
<div>
|
|
<h2>プッシュ通知</h2>
|
|
<p>ブラウザ許可状態: {permissionText(permission)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="method-list">
|
|
<article className="method-row">
|
|
<div>
|
|
<strong>現在のブラウザ</strong>
|
|
<span>{pushStatusText(currentPushStatus)}</span>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div className="push-actions">
|
|
{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>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|