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:
parent
1bce661648
commit
47cab788fc
3 changed files with 174 additions and 35 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue