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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue