feat(09-06): owner column + role-scoped reads on notification channels

- GET /api/notification-channels: requireAuth(), admin sees all rows with owner_email JOIN, non-admin sees global-only
- GET accepts ?owner=global|personal|all filter parameter
- POST /api/notification-channels: requireAdmin(); preserves all four channel_type values (teams/telegram/ntfy/webhook); adds owner_user_id column
- [id] routes: requireAuth() + per-row authorization (isAdmin || isOwner); global rows require admin
- Admin channels page: Owner badge (Global vs Personal: email), Show filter select, disclaimer text for personal channels
This commit is contained in:
lorentz 2026-05-10 07:39:53 -04:00
parent 1bce661648
commit 47cab788fc
3 changed files with 174 additions and 35 deletions

View file

@ -6,6 +6,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
Plus,
@ -24,12 +31,16 @@ interface Channel {
id: number;
name: string;
channel_type: string;
config: Record<string, any>;
config: Record<string, unknown>;
is_active: boolean;
owner_user_id: string | null;
owner_email?: string | null;
created_at: string;
updated_at: string;
}
type OwnerFilter = 'all' | 'global' | 'personal';
const CHANNEL_TYPES = [
{ value: 'teams', label: 'Microsoft Teams', icon: MessageSquare, color: 'bg-indigo-100 text-indigo-700', fields: [
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', placeholder: 'https://...webhook.office.com/...' },
@ -54,19 +65,23 @@ const CHANNEL_TYPES = [
export default function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [ownerFilter, setOwnerFilter] = useState<OwnerFilter>('all');
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formType, setFormType] = useState('teams');
const [formName, setFormName] = useState('');
const [formConfig, setFormConfig] = useState<Record<string, any>>({});
const [formConfig, setFormConfig] = useState<Record<string, unknown>>({});
const [testStatus, setTestStatus] = useState<Record<number, 'idle' | 'testing' | 'success' | 'error'>>({});
useEffect(() => { loadChannels(); }, []);
useEffect(() => { loadChannels(); }, [ownerFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const loadChannels = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/notification-channels');
const url = ownerFilter === 'all'
? '/api/notification-channels'
: `/api/notification-channels?owner=${ownerFilter}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
setChannels(data.data || []);
@ -176,6 +191,27 @@ export default function ChannelsPage() {
</Button>
</div>
{/* Owner filter */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">Show:</span>
<Select value={ownerFilter} onValueChange={(v) => setOwnerFilter(v as OwnerFilter)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="global">Global only</SelectItem>
<SelectItem value="personal">Personal only</SelectItem>
</SelectContent>
</Select>
</div>
{ownerFilter !== 'global' && (
<p className="text-xs text-muted-foreground">
Personal channels contain user-supplied webhook URLs handle with care.
</p>
)}
{showCreate && (
<Card>
<CardHeader>
@ -215,7 +251,7 @@ export default function ChannelsPage() {
{field.type === 'select' ? (
<select
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
value={formConfig[field.key] || (field.options?.[0] || '')}
value={(formConfig[field.key] as string) || (field.options?.[0] || '')}
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
>
{field.options?.map((opt: string) => (
@ -227,7 +263,7 @@ export default function ChannelsPage() {
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
type={field.type}
placeholder={field.placeholder}
value={formConfig[field.key] || ''}
value={(formConfig[field.key] as string) || ''}
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
/>
)}
@ -271,15 +307,20 @@ export default function ChannelsPage() {
/>
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{channel.name}</span>
{/* Owner badge — rendered before channel-type badge */}
{channel.owner_user_id == null
? <Badge variant="secondary">Global</Badge>
: <Badge>Personal: {channel.owner_email ?? channel.owner_user_id}</Badge>
}
<Badge className={cType?.color || ''}>{cType?.label || channel.channel_type}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{channel.channel_type === 'teams' && channel.config.webhook_url && `URL: ${channel.config.webhook_url.substring(0, 50)}...`}
{channel.channel_type === 'telegram' && `Chat: ${channel.config.chat_id || 'not set'}`}
{channel.channel_type === 'ntfy' && `Topic: ${channel.config.topic || 'not set'} @ ${channel.config.server_url || 'ntfy.sh'}`}
{channel.channel_type === 'webhook' && `${channel.config.method || 'POST'} ${channel.config.url || 'not set'}`}
{channel.channel_type === 'teams' && channel.config.webhook_url ? `URL: ${String(channel.config.webhook_url).substring(0, 50)}...` : null}
{channel.channel_type === 'telegram' ? `Chat: ${channel.config.chat_id || 'not set'}` : null}
{channel.channel_type === 'ntfy' ? `Topic: ${channel.config.topic || 'not set'} @ ${channel.config.server_url || 'ntfy.sh'}` : null}
{channel.channel_type === 'webhook' ? `${channel.config.method || 'POST'} ${channel.config.url || 'not set'}` : null}
</p>
</div>
</div>