- Add NTFY_BASE const (NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud') at module scope - Subscribe link href and rendered text use NTFY_BASE instead of ntfy.sh - QRCodeSVG value uses NTFY_BASE — ntfy.sh no longer referenced in file - Add muted help line 'Topic must start with pulse-me-' between custom-topic Input and error paragraph
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* ProfileChannelsSection — Channels Card (CHAN-02..CHAN-05, CHAN-07).
|
|
*
|
|
* Two sub-sections separated by a Separator:
|
|
* 1. Teams — webhook URL input + Save / Clear buttons + inline test result
|
|
* 2. ntfy — mint-on-first-save + QR code + advanced topic override
|
|
*
|
|
* Each sub-section saves independently (no global Save).
|
|
* Inline errors display the server response body's `message` / `error` string
|
|
* on non-2xx, cleared on next successful save.
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { CheckCircle, XCircle } from 'lucide-react';
|
|
import { QRCodeSVG } from 'qrcode.react';
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
} from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Separator } from '@/components/ui/separator';
|
|
|
|
// Personal ntfy channels target the company ntfy instance. The fallback
|
|
// matches the server-side default in personal-channels.ts / notify.ts so
|
|
// the UI and the publish path stay aligned even when the env var is unset
|
|
// in a dev shell (UAT-FIX-01).
|
|
const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface Channel {
|
|
id: string;
|
|
name: string;
|
|
channelType: string;
|
|
config: Record<string, string>;
|
|
isActive: boolean;
|
|
ownerUserId: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface ChannelTestResult {
|
|
ok: boolean;
|
|
status?: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface ChannelsState {
|
|
teams?: Channel;
|
|
ntfy?: Channel;
|
|
}
|
|
|
|
// ── Inline test result row ────────────────────────────────────────────────────
|
|
|
|
function TestResultRow({ test }: { test: ChannelTestResult }) {
|
|
if (test.ok) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
|
|
<span className="text-green-600">Channel verified</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<XCircle className="w-3.5 h-3.5 text-destructive" />
|
|
<span className="text-destructive">
|
|
Test failed — {test.status ?? test.error ?? 'unknown'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
export function ProfileChannelsSection() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [channels, setChannels] = useState<ChannelsState>({});
|
|
|
|
// Teams state
|
|
const [teamsInput, setTeamsInput] = useState('');
|
|
const [teamsTest, setTeamsTest] = useState<ChannelTestResult | null>(null);
|
|
const [teamsError, setTeamsError] = useState<string | null>(null);
|
|
|
|
// ntfy state
|
|
const [ntfyTest, setNtfyTest] = useState<ChannelTestResult | null>(null);
|
|
const [customTopicInput, setCustomTopicInput] = useState('');
|
|
const [customTopicError, setCustomTopicError] = useState<string | null>(null);
|
|
|
|
// ── Load channels on mount ────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
fetch('/api/me/channels')
|
|
.then((r) => r.json())
|
|
.then((data: { channels: Channel[] }) => {
|
|
const teams = data.channels.find((c) => c.channelType === 'teams');
|
|
const ntfy = data.channels.find((c) => c.channelType === 'ntfy');
|
|
setChannels({ teams, ntfy });
|
|
if (teams?.config?.webhook_url) {
|
|
setTeamsInput(teams.config.webhook_url);
|
|
}
|
|
if (ntfy?.config?.topic) {
|
|
setCustomTopicInput(ntfy.config.topic);
|
|
}
|
|
})
|
|
.catch(() => toast.error('Failed to load channels'))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
// ── Teams handlers ────────────────────────────────────────────────────────
|
|
|
|
const saveTeams = async () => {
|
|
setTeamsError(null);
|
|
const resp = await fetch('/api/me/channels/teams', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ webhook_url: teamsInput }),
|
|
});
|
|
const body = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
setTeamsError(body.message || body.error || `Save failed (${resp.status})`);
|
|
toast.error('Failed to save channel');
|
|
return;
|
|
}
|
|
setChannels((c) => ({ ...c, teams: body.channel }));
|
|
setTeamsTest(body.test);
|
|
toast.success('Channel saved');
|
|
};
|
|
|
|
const clearTeams = async () => {
|
|
await fetch('/api/me/channels/teams', { method: 'DELETE' });
|
|
setChannels((c) => ({ ...c, teams: undefined }));
|
|
setTeamsInput('');
|
|
setTeamsTest(null);
|
|
setTeamsError(null);
|
|
toast.success('Channel removed');
|
|
};
|
|
|
|
// ── ntfy handlers ─────────────────────────────────────────────────────────
|
|
|
|
const enableNtfy = async () => {
|
|
const resp = await fetch('/api/me/channels/ntfy', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
toast.error('Failed to save channel');
|
|
return;
|
|
}
|
|
setChannels((c) => ({ ...c, ntfy: data.channel }));
|
|
setNtfyTest(data.test);
|
|
if (data.channel?.config?.topic) {
|
|
setCustomTopicInput(data.channel.config.topic);
|
|
}
|
|
toast.success('Channel saved');
|
|
};
|
|
|
|
const saveCustomTopic = async () => {
|
|
setCustomTopicError(null);
|
|
const resp = await fetch('/api/me/channels/ntfy', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ topic: customTopicInput }),
|
|
});
|
|
const body = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
setCustomTopicError(body.message || body.error || `Save failed (${resp.status})`);
|
|
toast.error('Failed to save channel');
|
|
return;
|
|
}
|
|
setChannels((c) => ({ ...c, ntfy: body.channel }));
|
|
setNtfyTest(body.test);
|
|
setCustomTopicError(null);
|
|
toast.success('Channel saved');
|
|
};
|
|
|
|
const testNtfy = async () => {
|
|
const resp = await fetch('/api/me/channels/ntfy/test', { method: 'POST' });
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (data.test) {
|
|
setNtfyTest(data.test);
|
|
}
|
|
};
|
|
|
|
const removeNtfy = async () => {
|
|
await fetch('/api/me/channels/ntfy', { method: 'DELETE' });
|
|
setChannels((c) => ({ ...c, ntfy: undefined }));
|
|
setNtfyTest(null);
|
|
setCustomTopicInput('');
|
|
setCustomTopicError(null);
|
|
toast.success('Channel removed');
|
|
};
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="px-4 pt-4 pb-0">
|
|
<CardTitle>Personal Channels</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 py-4">
|
|
<div className="space-y-3">
|
|
<div className="h-10 w-full rounded bg-muted animate-pulse" />
|
|
<div className="h-10 w-full rounded bg-muted animate-pulse" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const ntfyTopic = channels.ntfy?.config?.topic;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="px-4 pt-4 pb-0">
|
|
<CardTitle>Personal Channels</CardTitle>
|
|
<CardDescription>Receive notifications directly on your devices.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="px-4 py-4 space-y-4">
|
|
|
|
{/* ── Teams sub-section ──────────────────────────────────────────── */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="teams-webhook-url">Microsoft Teams webhook URL</Label>
|
|
<Input
|
|
id="teams-webhook-url"
|
|
placeholder="https://yourorg.webhook.office.com/..."
|
|
value={teamsInput}
|
|
onChange={(e) => {
|
|
setTeamsInput(e.target.value);
|
|
setTeamsError(null);
|
|
}}
|
|
/>
|
|
{teamsError && (
|
|
<p className="text-xs text-destructive">{teamsError}</p>
|
|
)}
|
|
{teamsTest && <TestResultRow test={teamsTest} />}
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
onClick={saveTeams}
|
|
className="min-h-[44px]"
|
|
>
|
|
Save Teams URL
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={clearTeams}
|
|
className="text-destructive min-h-[44px]"
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
|
|
{/* ── ntfy sub-section ───────────────────────────────────────────── */}
|
|
<div className="space-y-2">
|
|
<Label>Mobile push (ntfy)</Label>
|
|
|
|
{!ntfyTopic ? (
|
|
/* State A — no topic yet */
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
Pulse will generate a private topic for you.
|
|
</p>
|
|
<Button
|
|
className="w-full min-h-[44px]"
|
|
onClick={enableNtfy}
|
|
>
|
|
Enable mobile push
|
|
</Button>
|
|
</>
|
|
) : (
|
|
/* State B — topic minted */
|
|
<div className="space-y-3">
|
|
<a
|
|
href={`${NTFY_BASE}/${ntfyTopic}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary text-sm underline break-all"
|
|
>
|
|
{`${NTFY_BASE}/${ntfyTopic}`}
|
|
</a>
|
|
|
|
<div
|
|
role="img"
|
|
aria-label={`Subscribe to ${ntfyTopic} on ntfy`}
|
|
className="flex justify-center"
|
|
>
|
|
<QRCodeSVG value={`${NTFY_BASE}/${ntfyTopic}`} size={200} />
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Scan with the ntfy app to subscribe.
|
|
</p>
|
|
|
|
{ntfyTest && <TestResultRow test={ntfyTest} />}
|
|
|
|
<details>
|
|
<summary className="text-sm text-primary cursor-pointer">
|
|
Edit advanced
|
|
</summary>
|
|
<div className="space-y-2 pt-2">
|
|
<Label htmlFor="custom-ntfy-topic">Custom ntfy topic</Label>
|
|
<Input
|
|
id="custom-ntfy-topic"
|
|
value={customTopicInput}
|
|
onChange={(e) => {
|
|
setCustomTopicInput(e.target.value);
|
|
setCustomTopicError(null);
|
|
}}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Topic must start with <code>pulse-me-</code>.
|
|
</p>
|
|
{customTopicError && (
|
|
<p className="text-xs text-destructive">{customTopicError}</p>
|
|
)}
|
|
<Button
|
|
onClick={saveCustomTopic}
|
|
className="min-h-[44px]"
|
|
>
|
|
Save custom topic
|
|
</Button>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
onClick={testNtfy}
|
|
className="min-h-[44px]"
|
|
>
|
|
Test now
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={removeNtfy}
|
|
className="text-destructive min-h-[44px]"
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|