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>

View file

@ -1,31 +1,77 @@
/**
* Single Notification Channel API get, update, delete.
* Each handler requires auth. Global channels require admin; personal channels
* allow access by owner OR admin (CHAN-06).
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
async function getChannelRow(id: string) {
const result = await postgresClient.query(
`SELECT * FROM notification_channels WHERE id = $1`, [id]
);
return result.rows[0] ?? null;
}
function checkAccess(
row: { owner_user_id: string | null },
userId: string,
userRole: string
): boolean {
const isAdmin = userRole === 'admin' || userRole === 'super-admin';
const isOwner = row.owner_user_id === userId;
if (row.owner_user_id === null) {
// Global channel — admin only
return isAdmin;
}
// Personal channel — owner or admin
return isOwner || isAdmin;
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, error } = await requireAuth();
if (error) return error;
try {
const { id } = await params;
const result = await postgresClient.query(
`SELECT * FROM notification_channels WHERE id = $1`, [id]
);
const row = await getChannelRow(id);
if (result.rows.length === 0) {
if (!row) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const userRole = (session!.user as { role?: string }).role ?? 'user';
if (!checkAccess(row, session!.user.id, userRole)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(row);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, error } = await requireAuth();
if (error) return error;
try {
const { id } = await params;
const row = await getChannelRow(id);
if (!row) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
const userRole = (session!.user as { role?: string }).role ?? 'user';
if (!checkAccess(row, session!.user.id, userRole)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { name, channel_type, config, is_active } = body;
@ -46,15 +92,29 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
return NextResponse.json(result.rows[0]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, error } = await requireAuth();
if (error) return error;
try {
const { id } = await params;
const row = await getChannelRow(id);
if (!row) {
return NextResponse.json({ error: 'Channel not found' }, { status: 404 });
}
const userRole = (session!.user as { role?: string }).role ?? 'user';
if (!checkAccess(row, session!.user.id, userRole)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const result = await postgresClient.query(
`DELETE FROM notification_channels WHERE id = $1 RETURNING id`, [id]
);
@ -64,8 +124,8 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
}
return NextResponse.json({ deleted: true, id: Number(id) });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -1,46 +1,84 @@
/**
* Notification Channels API list and create channels.
* GET: requires auth; admins see all rows (with owner_email); non-admins see global-only.
* POST: requires admin.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET() {
export async function GET(request: NextRequest) {
const { session, error } = await requireAuth();
if (error) return error;
const userRole = (session!.user as { role?: string }).role ?? 'user';
const isAdmin = userRole === 'admin' || userRole === 'super-admin';
try {
const result = await postgresClient.query(
`SELECT * FROM notification_channels ORDER BY name`
);
return NextResponse.json({ data: result.rows, total: result.rows.length });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (isAdmin) {
const ownerFilter = request.nextUrl.searchParams.get('owner') ?? 'all';
let whereSql = '';
if (ownerFilter === 'global') {
whereSql = 'WHERE nc.owner_user_id IS NULL';
} else if (ownerFilter === 'personal') {
whereSql = 'WHERE nc.owner_user_id IS NOT NULL';
}
const result = await postgresClient.query(
`SELECT nc.*, u.email AS owner_email
FROM notification_channels nc
LEFT JOIN "user" u ON u.id = nc.owner_user_id
${whereSql}
ORDER BY nc.owner_user_id NULLS FIRST, nc.name`
);
return NextResponse.json({ data: result.rows, total: result.rows.length });
} else {
// Non-admins: only global channels
const result = await postgresClient.query(
`SELECT * FROM notification_channels
WHERE owner_user_id IS NULL
ORDER BY name`
);
return NextResponse.json({ data: result.rows, total: result.rows.length });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('GET /api/notification-channels failed:', err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const { error } = await requireAdmin();
if (error) return error;
try {
const body = await request.json();
const { name, channel_type, config, is_active } = body;
const { name, channel_type, config, is_active, owner_user_id } = body;
if (!name || !channel_type) {
return NextResponse.json({ error: 'name and channel_type are required' }, { status: 400 });
}
// LOW 12 explicit acceptance: all four channel_type values remain accepted
const validTypes = ['teams', 'telegram', 'ntfy', 'webhook'];
if (!validTypes.includes(channel_type)) {
return NextResponse.json({ error: `channel_type must be one of: ${validTypes.join(', ')}` }, { status: 400 });
}
const result = await postgresClient.query(
`INSERT INTO notification_channels (name, channel_type, config, is_active)
VALUES ($1, $2, $3, $4)
`INSERT INTO notification_channels (name, channel_type, config, is_active, owner_user_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[name, channel_type, JSON.stringify(config || {}), is_active ?? true]
[name, channel_type, JSON.stringify(config || {}), is_active ?? true, owner_user_id ?? null]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('POST /api/notification-channels failed:', err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}