feat: moderation features and ABAC permission system

mostly generated by claude code, but of course i have made some of my
edits.
This commit is contained in:
2026-01-01 16:18:00 +01:00
parent 593baa6505
commit 689c410828
24 changed files with 1683 additions and 32 deletions

View File

@@ -47,6 +47,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"hls-video-element": "^1.5.0",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
@@ -58,6 +59,7 @@
"pg": "^8.14.1",
"pg-boss": "^10.1.6",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.54.2",
"rehype-raw": "^7.0.0",

View File

@@ -5,11 +5,34 @@ export default async function Page({ params }: { params: Promise<{ username: str
const { username } = await params;
const streamInfo = await prisma.streamInfo.findUnique({
where: { username },
include: { channel: true },
include: {
channel: {
include: {
restriction: true,
},
},
},
});
if (!streamInfo) {
return <div>Stream not found</div>;
}
if (streamInfo.channel.restriction) {
const isExpired = streamInfo.channel.restriction.expiresAt &&
new Date(streamInfo.channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
<p className="text-muted-foreground text-center max-w-md">
This channel has been restricted by a moderator and is not currently available for viewing.
</p>
</div>
);
}
}
return (
<LiveStream username={username} streamInfo={streamInfo} />
);

View File

@@ -0,0 +1,793 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Users,
Tv,
Ban,
ShieldOff,
Search,
AlertTriangle,
CalendarIcon,
ShieldCheck,
ShieldMinus,
X,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import type { User } from '@hctv/db';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) {
const [userSearch, setUserSearch] = useState('');
const [channelSearch, setChannelSearch] = useState('');
const [users, setUsers] = useState<UserWithBan[]>([]);
const [channels, setChannels] = useState<ChannelWithRestriction[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [channelsLoading, setChannelsLoading] = useState(false);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [restrictDialogOpen, setRestrictDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserWithBan | null>(null);
const [selectedChannel, setSelectedChannel] = useState<ChannelWithRestriction | null>(null);
const [reason, setReason] = useState('');
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
const fetchUsers = useCallback(async (search: string) => {
setUsersLoading(true);
try {
const res = await fetch(`/api/admin/users?search=${encodeURIComponent(search)}`);
if (res.ok) {
setUsers(await res.json());
}
} catch (e) {
toast.error('Failed to fetch users');
} finally {
setUsersLoading(false);
}
}, []);
const fetchChannels = useCallback(async (search: string) => {
setChannelsLoading(true);
try {
const res = await fetch(`/api/admin/channels?search=${encodeURIComponent(search)}`);
if (res.ok) {
setChannels(await res.json());
}
} catch (e) {
toast.error('Failed to fetch channels');
} finally {
setChannelsLoading(false);
}
}, []);
useEffect(() => {
fetchUsers('');
fetchChannels('');
}, [fetchUsers, fetchChannels]);
useEffect(() => {
const timer = setTimeout(() => {
fetchUsers(userSearch);
}, 300);
return () => clearTimeout(timer);
}, [userSearch, fetchUsers]);
useEffect(() => {
const timer = setTimeout(() => {
fetchChannels(channelSearch);
}, 300);
return () => clearTimeout(timer);
}, [channelSearch, fetchChannels]);
const resetDialogState = () => {
setReason('');
setExpiresAt(undefined);
setSelectedUser(null);
setSelectedChannel(null);
};
const handleBanUser = async () => {
if (!selectedUser || !reason.trim()) {
toast.error('Please provide a reason');
return;
}
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: selectedUser.id,
action: 'ban',
reason,
expiresAt: expiresAt?.toISOString(),
}),
});
if (res.ok) {
toast.success('User banned successfully');
fetchUsers(userSearch);
setBanDialogOpen(false);
resetDialogState();
} else {
const err = await res.text();
toast.error(err || 'Failed to ban user');
}
} catch (e) {
toast.error('Failed to ban user');
}
};
const handleUnbanUser = async (userId: string) => {
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
action: 'unban',
}),
});
if (res.ok) {
toast.success('User unbanned successfully');
fetchUsers(userSearch);
} else {
toast.error('Failed to unban user');
}
} catch (e) {
toast.error('Failed to unban user');
}
};
const handleRestrictChannel = async () => {
if (!selectedChannel || !reason.trim()) {
toast.error('Please provide a reason');
return;
}
try {
const res = await fetch('/api/admin/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId: selectedChannel.id,
action: 'restrict',
reason,
expiresAt: expiresAt?.toISOString(),
}),
});
if (res.ok) {
toast.success('Channel restricted successfully');
fetchChannels(channelSearch);
setRestrictDialogOpen(false);
resetDialogState();
} else {
const err = await res.text();
toast.error(err || 'Failed to restrict channel');
}
} catch (e) {
toast.error('Failed to restrict channel');
}
};
const handleUnrestrictChannel = async (channelId: string) => {
try {
const res = await fetch('/api/admin/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId,
action: 'unrestrict',
}),
});
if (res.ok) {
toast.success('Channel unrestricted successfully');
fetchChannels(channelSearch);
} else {
toast.error('Failed to unrestrict channel');
}
} catch (e) {
toast.error('Failed to unrestrict channel');
}
};
const handlePromoteUser = async (userId: string) => {
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
action: 'promote',
}),
});
if (res.ok) {
toast.success('User promoted to admin');
fetchUsers(userSearch);
} else {
const err = await res.text();
toast.error(err || 'Failed to promote user');
}
} catch (e) {
toast.error('Failed to promote user');
}
};
const handleDemoteUser = async (userId: string) => {
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
action: 'demote',
}),
});
if (res.ok) {
toast.success('User demoted from admin');
fetchUsers(userSearch);
} else {
const err = await res.text();
toast.error(err || 'Failed to demote user');
}
} catch (e) {
toast.error('Failed to demote user');
}
};
return (
<div className="container max-w-6xl mx-auto py-6 px-4">
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
Admin Panel
</h1>
<p className="text-muted-foreground">Manage users and channels on the platform</p>
</div>
<Tabs defaultValue="users" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Users
</TabsTrigger>
<TabsTrigger value="channels" className="flex items-center gap-2">
<Tv className="h-4 w-4" />
Channels
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle>User Management</CardTitle>
<CardDescription>
Search and manage user accounts. Ban users to prevent them from using the platform.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by username or email..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersLoading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={user.pfpUrl} />
<AvatarFallback>
{user.personalChannel?.name?.[0]?.toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{user.personalChannel?.name}
</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{user.isAdmin && (
<Badge variant="default">Admin</Badge>
)}
{user.ban ? (
<Badge variant="destructive" className="flex items-center gap-1">
<Ban className="h-3 w-3" />
Banned
</Badge>
) : (
<Badge variant="secondary">Active</Badge>
)}
</div>
{user.ban && (
<div className="text-xs text-muted-foreground mt-1">
<p>Reason: {user.ban.reason}</p>
{user.ban.expiresAt && (
<p>Expires: {format(new Date(user.ban.expiresAt), 'PPP')}</p>
)}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{user.isAdmin ? (
user.id !== currentUser.id && (
<Button
variant="outline"
size="sm"
onClick={() => handleDemoteUser(user.id)}
>
<ShieldMinus className="h-4 w-4 mr-1" />
Demote
</Button>
)
) : (
<>
{user.ban ? (
<Button
variant="outline"
size="sm"
onClick={() => handleUnbanUser(user.id)}
>
<ShieldOff className="h-4 w-4 mr-1" />
Unban
</Button>
) : (
<Button
variant="destructive"
size="sm"
onClick={() => {
setSelectedUser(user);
setBanDialogOpen(true);
}}
>
<Ban className="h-4 w-4 mr-1" />
Ban
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handlePromoteUser(user.id)}
>
<ShieldCheck className="h-4 w-4 mr-1" />
Promote
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="channels">
<Card>
<CardHeader>
<CardTitle>Channel Management</CardTitle>
<CardDescription>
Search and manage channels. Restrict channels to prevent streams from being viewed.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by channel name..."
value={channelSearch}
onChange={(e) => setChannelSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Channel</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channelsLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : channels.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
No channels found
</TableCell>
</TableRow>
) : (
channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={channel.pfpUrl} />
<AvatarFallback>
{channel.name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{channel.name}</p>
{channel.personalFor && (
<Badge variant="outline" className="text-xs">Personal</Badge>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={channel.owner.pfpUrl} />
<AvatarFallback>O</AvatarFallback>
</Avatar>
<span className="text-sm">{channel.owner.personalChannel.name}</span>
</div>
</TableCell>
<TableCell>
{channel.restriction ? (
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
<Ban className="h-3 w-3" />
Restricted
</Badge>
) : (
<Badge variant="secondary">Active</Badge>
)}
{channel.restriction && (
<div className="text-xs text-muted-foreground mt-1">
<p>Reason: {channel.restriction.reason}</p>
{channel.restriction.expiresAt && (
<p>Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}</p>
)}
</div>
)}
</TableCell>
<TableCell>
{channel.restriction ? (
<Button
variant="outline"
size="sm"
onClick={() => handleUnrestrictChannel(channel.id)}
>
<ShieldOff className="h-4 w-4 mr-1" />
Unrestrict
</Button>
) : (
<Button
variant="destructive"
size="sm"
onClick={() => {
setSelectedChannel(channel);
setRestrictDialogOpen(true);
}}
>
<Ban className="h-4 w-4 mr-1" />
Restrict
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Dialog open={banDialogOpen} onOpenChange={(open) => {
setBanDialogOpen(open);
if (!open) resetDialogState();
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
<DialogDescription>
Ban {selectedUser?.personalChannel?.name || selectedUser?.slack_id} from the platform.
They will not be able to stream or use the platform while banned.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
placeholder="Enter the reason for banning this user..."
value={reason}
onChange={(e) => setReason(e.target.value)}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Expires (optional)</label>
<p className="text-xs text-muted-foreground mb-2">
Leave empty for a permanent ban
</p>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'flex-1 justify-start text-left font-normal',
!expiresAt && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={expiresAt}
onSelect={(date) => {
if (date) {
const newDate = expiresAt ? new Date(expiresAt) : new Date();
date.setHours(newDate.getHours(), newDate.getMinutes());
setExpiresAt(date);
} else {
setExpiresAt(undefined);
}
}}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
<div className="border-t p-3">
<label className="text-sm font-medium">Time</label>
<Input
type="time"
className="mt-1"
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
onChange={(e) => {
if (e.target.value) {
const [hours, minutes] = e.target.value.split(':').map(Number);
const newDate = expiresAt ? new Date(expiresAt) : new Date();
newDate.setHours(hours, minutes);
setExpiresAt(newDate);
}
}}
/>
</div>
</PopoverContent>
</Popover>
{expiresAt && (
<Button
variant="ghost"
size="icon"
onClick={() => setExpiresAt(undefined)}
>
<X className='w-4 h-4' />
</Button>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setBanDialogOpen(false);
resetDialogState();
}}>
Cancel
</Button>
<Button variant="destructive" onClick={handleBanUser}>
Ban User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={restrictDialogOpen} onOpenChange={(open) => {
setRestrictDialogOpen(open);
if (!open) resetDialogState();
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Restrict Channel</DialogTitle>
<DialogDescription>
Restrict {selectedChannel?.name} from streaming. Viewers will not be able to watch
streams from this channel.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
placeholder="Enter the reason for restricting this channel..."
value={reason}
onChange={(e) => setReason(e.target.value)}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Expires (optional)</label>
<p className="text-xs text-muted-foreground mb-2">
Leave empty for a permanent restriction
</p>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'flex-1 justify-start text-left font-normal',
!expiresAt && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={expiresAt}
onSelect={(date) => {
if (date) {
const newDate = expiresAt ? new Date(expiresAt) : new Date();
date.setHours(newDate.getHours(), newDate.getMinutes());
setExpiresAt(date);
} else {
setExpiresAt(undefined);
}
}}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
<div className="border-t p-3">
<label className="text-sm font-medium">Time</label>
<Input
type="time"
className="mt-1"
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
onChange={(e) => {
if (e.target.value) {
const [hours, minutes] = e.target.value.split(':').map(Number);
const newDate = expiresAt ? new Date(expiresAt) : new Date();
newDate.setHours(hours, minutes);
setExpiresAt(newDate);
}
}}
/>
</div>
</PopoverContent>
</Popover>
{expiresAt && (
<Button
variant="ghost"
size="icon"
onClick={() => setExpiresAt(undefined)}
>
<X className='w-4 h-4' />
</Button>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setRestrictDialogOpen(false);
resetDialogState();
}}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRestrictChannel}>
Restrict Channel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface UserWithBan {
id: string;
slack_id: string;
email: string | null;
pfpUrl: string;
isAdmin: boolean;
ban: {
id: string;
reason: string;
bannedBy: string;
createdAt: string;
expiresAt: string | null;
} | null;
personalChannel: { name: string } | null;
}
interface ChannelWithRestriction {
id: string;
name: string;
description: string;
pfpUrl: string;
owner: { id: string; slack_id: string; pfpUrl: string; personalChannel: { name: string } };
personalFor: { id: string } | null;
restriction: {
id: string;
reason: string;
restrictedBy: string;
createdAt: string;
expiresAt: string | null;
} | null;
}
interface AdminPanelClientProps {
currentUser: User;
}

View File

@@ -0,0 +1,17 @@
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from 'next/navigation';
import AdminPanelClient from './page.client';
export default async function AdminPage() {
const { user } = await validateRequest();
if (!user) {
redirect('/auth/hackclub');
}
if (!user.isAdmin) {
redirect('/');
}
return <AdminPanelClient currentUser={user} />;
}

View File

@@ -0,0 +1,92 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search') || '';
const channels = await prisma.channel.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
include: {
restriction: true,
owner: {
select: { id: true, slack_id: true, pfpUrl: true, personalChannel: { select: { name: true } } },
},
personalFor: {
select: {
id: true,
},
},
},
take: 50,
});
console.log(channels)
return Response.json(channels);
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const body = await request.json();
const { channelId, action, reason, expiresAt } = body as {
channelId: string;
action: 'restrict' | 'unrestrict';
reason?: string;
expiresAt?: string;
};
if (!channelId || !action) {
return new Response('Missing required fields', { status: 400 });
}
const channel = await prisma.channel.findUnique({ where: { id: channelId } });
if (!channel) {
return new Response('Channel not found', { status: 404 });
}
if (action === 'restrict') {
if (!reason) {
return new Response('Reason is required for restricting', { status: 400 });
}
await prisma.channelRestriction.upsert({
where: { channelId },
update: {
reason,
restrictedBy: user.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
create: {
channelId,
reason,
restrictedBy: user.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
return Response.json({ success: true, message: 'Channel restricted' });
}
if (action === 'unrestrict') {
await prisma.channelRestriction.delete({ where: { channelId } }).catch(() => { });
return Response.json({ success: true, message: 'Channel unrestricted' });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -0,0 +1,119 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search') || '';
const users = await prisma.user.findMany({
where: search
? {
OR: [
{ slack_id: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ personalChannel: { name: { contains: search, mode: 'insensitive' } } },
],
}
: undefined,
include: {
ban: true,
personalChannel: { select: { name: true } },
},
take: 50,
});
return Response.json(users);
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const body = await request.json();
const { userId, action, reason, expiresAt } = body as {
userId: string;
action: 'ban' | 'unban' | 'promote' | 'demote';
reason?: string;
expiresAt?: string;
};
if (!userId || !action) {
return new Response('Missing required fields', { status: 400 });
}
const targetUser = await prisma.user.findUnique({ where: { id: userId } });
if (!targetUser) {
return new Response('User not found', { status: 404 });
}
if (action === 'ban') {
if (targetUser.isAdmin) {
return new Response('Cannot ban an admin', { status: 400 });
}
if (!reason) {
return new Response('Reason is required for banning', { status: 400 });
}
await prisma.userBan.upsert({
where: { userId },
update: {
reason,
bannedBy: user.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
create: {
userId,
reason,
bannedBy: user.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
return Response.json({ success: true, message: 'User banned' });
}
if (action === 'unban') {
await prisma.userBan.delete({ where: { userId } }).catch(() => { });
return Response.json({ success: true, message: 'User unbanned' });
}
if (action === 'promote') {
if (targetUser.isAdmin) {
return new Response('User is already an admin', { status: 400 });
}
await prisma.user.update({
where: { id: userId },
data: { isAdmin: true },
});
return Response.json({ success: true, message: 'User promoted to admin' });
}
if (action === 'demote') {
if (!targetUser.isAdmin) {
return new Response('User is not an admin', { status: 400 });
}
if (targetUser.id === user.id) {
return new Response('Cannot demote yourself', { status: 400 });
}
await prisma.user.update({
where: { id: userId },
data: { isAdmin: false },
});
return Response.json({ success: true, message: 'User demoted from admin' });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -20,6 +20,33 @@ export async function POST(request: NextRequest) {
if (channelKey !== password) {
return new Response('invalid stream key', { status: 403 });
}
const channel = await prisma.channel.findUnique({
where: { name: path },
include: {
restriction: true,
owner: {
include: { ban: true },
},
},
});
if (channel?.restriction) {
const isExpired = channel.restriction.expiresAt &&
new Date(channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return new Response('channel restricted', { status: 403 });
}
}
if (channel?.owner?.ban) {
const isExpired = channel.owner.ban.expiresAt &&
new Date(channel.owner.ban.expiresAt) < new Date();
if (!isExpired) {
return new Response('user banned', { status: 403 });
}
}
return new Response('youre in yay', { status: 200 });
}
} else if (action === 'read' && protocol === 'hls') {

View File

@@ -15,7 +15,14 @@ export async function POST(request: NextRequest) {
key: streamKey,
},
include: {
channel: true,
channel: {
include: {
restriction: true,
owner: {
include: { ban: true },
},
},
},
},
});
@@ -24,6 +31,23 @@ export async function POST(request: NextRequest) {
status: 403,
});
}
if (key.channel.restriction) {
const isExpired = key.channel.restriction.expiresAt &&
new Date(key.channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return new Response('channel restricted', { status: 403 });
}
}
if (key.channel.owner?.ban) {
const isExpired = key.channel.owner.ban.expiresAt &&
new Date(key.channel.owner.ban.expiresAt) < new Date();
if (!isExpired) {
return new Response('user banned', { status: 403 });
}
}
return new Response('', {
status: 302,
headers: {

View File

@@ -46,6 +46,12 @@ export async function GET(request: NextRequest) {
channel: {
include: {
personalFor: true,
restriction: {
select: {
id: true,
expiresAt: true,
},
},
}
},
},
@@ -58,6 +64,22 @@ export async function GET(request: NextRequest) {
}
// @ts-ignore
delete obj.channel.obsChatGrantToken;
if (obj.channel.restriction) {
const isExpired = obj.channel.restriction.expiresAt &&
new Date(obj.channel.restriction.expiresAt) < new Date();
if (isExpired) {
// @ts-ignore
obj.channel.restriction = null;
} else {
// @ts-ignore
obj.channel.isRestricted = true;
// @ts-ignore
obj.channel.restrictionExpiresAt = obj.channel.restriction.expiresAt;
// @ts-ignore
delete obj.channel.restriction;
}
}
});
return Response.json(db);

View File

@@ -1,5 +1,6 @@
import { validateRequest } from '@/lib/auth/validate';
import { redirect, RedirectType } from 'next/navigation';
import { prisma } from '@hctv/db';
export default async function Layout({ children }: { children: React.ReactNode }) {
const { user } = await validateRequest();
@@ -9,5 +10,33 @@ export default async function Layout({ children }: { children: React.ReactNode }
if (!user.hasOnboarded) {
return redirect(`/onboarding`, RedirectType.push);
}
const ban = await prisma.userBan.findUnique({
where: { userId: user.id },
});
if (ban) {
const isExpired = ban.expiresAt && new Date(ban.expiresAt) < new Date();
if (!isExpired) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-3xl font-bold text-destructive mb-4">Account Suspended</h1>
<p className="text-muted-foreground text-center max-w-md mb-4">
Your account has been suspended from hackclub.tv.
</p>
<div className="bg-muted p-4 rounded-lg max-w-md">
<p className="text-sm font-medium">Reason:</p>
<p className="text-sm text-muted-foreground">{ban.reason}</p>
</div>
{ban.expiresAt && (
<p className="text-sm text-muted-foreground mt-4">
Expires: {new Date(ban.expiresAt).toLocaleDateString()}
</p>
)}
</div>
);
}
}
return children;
}

View File

@@ -1,5 +1,6 @@
import { getBotBySlug } from '@/lib/db/resolve';
import { validateRequest } from '@/lib/auth/validate';
import { can } from '@/lib/auth/abac';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
@@ -12,7 +13,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const { slug } = await params;
const bot = await getBotBySlug(slug);
if (!bot || bot.ownerId !== user?.id) {
if (!user || !bot || !can(user, 'update', 'bot', { bot })) {
redirect('/settings/bot');
}

View File

@@ -3,6 +3,7 @@ import { prisma } from '@hctv/db';
import { redirect } from 'next/navigation';
import ChannelSettingsClient from './page.client';
import { resolvePersonalChannel } from '@/lib/auth/resolve';
import { can } from '@/lib/auth/abac';
export default async function ChannelSettingsPage({
params,
@@ -42,9 +43,8 @@ export default async function ChannelSettingsPage({
}
const isOwner = channel.ownerId === user.id;
const isManager = channel.managers.some((manager) => manager.id === user.id);
if (!isOwner && !isManager) {
if (!can(user, 'update', 'channel', { channel })) {
redirect('/');
}

View File

@@ -1,13 +1,66 @@
'use client';
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import { Button } from '@/components/ui/button';
import type { StreamInfo, Channel } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
import { useAllChannels } from '@/lib/hooks/useUserList';
import { RefreshCw } from 'lucide-react';
export default function LiveStream(props: Props) {
const isMobile = useIsMobile();
const { channels, refresh } = useAllChannels(5000);
const [isRestricted, setIsRestricted] = useState(false);
const [restrictionExpiresAt, setRestrictionExpiresAt] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
const currentStream = channels.find(s => s.username === props.username);
if (currentStream?.channel?.isRestricted) {
setIsRestricted(true);
setRestrictionExpiresAt(currentStream.channel.restrictionExpiresAt || null);
} else if (isRestricted && currentStream && !currentStream.channel?.isRestricted) {
setIsRestricted(false);
setRestrictionExpiresAt(null);
}
}, [channels, props.username, isRestricted]);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refresh();
} finally {
setIsRefreshing(false);
}
};
if (isRestricted) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
<p className="text-muted-foreground text-center max-w-md mb-4">
This channel has been restricted by a moderator and is no longer available for viewing.
</p>
{restrictionExpiresAt && (
<p className="text-sm text-muted-foreground mb-4">
Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
</p>
)}
<Button
variant="outline"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'Check again'}
</Button>
</div>
);
}
return (
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>

View File

@@ -15,7 +15,7 @@ import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
import { IdCard, Slack } from 'lucide-react';
import { IdCard, Shield } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
export default function Navbar(props: Props) {
@@ -57,6 +57,17 @@ export default function Navbar(props: Props) {
<Link href={`/settings/bot`}>
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
</Link>
{user.isAdmin && (
<>
<DropdownMenuSeparator />
<Link href={`/admin`}>
<DropdownMenuItem className="cursor-pointer text-primary">
<Shield className="w-4 h-4 mr-2" />
Admin Panel
</DropdownMenuItem>
</Link>
</>
)}
<DropdownMenuSeparator />
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,121 @@
import { prisma } from '@hctv/db';
export type Resource = 'channel' | 'bot' | 'streamInfo';
export type Action = 'read' | 'update' | 'delete' | 'manage';
type User = { id: string };
type ChannelWithRelations = {
ownerId: string;
managers?: { id: string }[];
personalFor?: { id: string } | null;
};
type BotWithRelations = {
ownerId: string;
};
type PolicyContext = {
channel?: ChannelWithRelations;
bot?: BotWithRelations;
};
const policies: Record<Resource, Record<Action, (user: User, ctx: PolicyContext) => boolean>> = {
channel: {
read: () => true,
update: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
},
delete: (user, { channel }) => {
if (!channel) return false;
if (channel.personalFor) return false;
return channel.ownerId === user.id;
},
manage: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id;
},
},
bot: {
read: () => true,
update: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
delete: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
manage: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
},
streamInfo: {
read: () => true,
update: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
},
delete: () => false,
manage: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id;
},
},
};
export function can(user: User, action: Action, resource: Resource, context: PolicyContext): boolean {
const policy = policies[resource]?.[action];
if (!policy) return false;
return policy(user, context);
}
export async function canAccessChannel(
user: User,
action: Action,
channelId: string
): Promise<boolean> {
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
});
if (!channel) return false;
return can(user, action, 'channel', { channel });
}
export async function canAccessChannelByName(
user: User,
action: Action,
channelName: string
): Promise<boolean> {
const channel = await prisma.channel.findUnique({
where: { name: channelName },
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
});
if (!channel) return false;
return can(user, action, 'channel', { channel });
}
export async function canAccessBot(user: User, action: Action, botId: string): Promise<boolean> {
const bot = await prisma.botAccount.findUnique({
where: { id: botId },
select: { ownerId: true },
});
if (!bot) return false;
return can(user, action, 'bot', { bot });
}
export async function canAccessBotBySlug(
user: User,
action: Action,
slug: string
): Promise<boolean> {
const bot = await prisma.botAccount.findUnique({
where: { slug },
select: { ownerId: true },
});
if (!bot) return false;
return can(user, action, 'bot', { bot });
}

View File

@@ -18,6 +18,7 @@ import {
resolveStreamInfo,
resolveUserFromPersonalChannelName,
} from '../auth/resolve';
import { can, canAccessBot } from '../auth/abac';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
import { generateStreamKey } from '../db/streamKey';
@@ -42,9 +43,7 @@ export async function editStreamInfo(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
const isBroadcaster =
channelInfo.ownerId === user.id || channelInfo.managers.some((m) => m.id === user.id);
if (!isBroadcaster) {
if (!can(user, 'update', 'streamInfo', { channel: channelInfo })) {
return { success: false, error: 'Unauthorized' };
}
@@ -202,10 +201,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
const isOwner = channel.ownerId === user.id;
const isManager = channel.managers.some((manager) => manager.id === user.id);
if (!isOwner && !isManager) {
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
@@ -242,7 +238,7 @@ export async function addChannelManager(channelId: string, userChannel: string)
return { success: false, error: 'Channel not found OR is personal.' };
}
if (channel.ownerId !== user.id) {
if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can add managers' };
}
@@ -286,7 +282,7 @@ export async function removeChannelManager(channelId: string, userId: string) {
return { success: false, error: 'Channel not found' };
}
if (channel.ownerId !== user.id) {
if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can remove managers' };
}
@@ -355,12 +351,8 @@ export async function deleteChannel(channelId: string) {
return { success: false, error: 'Channel not found' };
}
if (channel.ownerId !== user.id) {
return { success: false, error: 'Only channel owners can delete channels' };
}
if (channel.personalFor) {
return { success: false, error: 'Cannot delete personal channels' };
if (!can(user, 'delete', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can delete channels (personal channels cannot be deleted)' };
}
await prisma.channel.delete({
@@ -416,7 +408,7 @@ export async function editBot(prev: any, formData: FormData) {
if (!bot) {
return { success: false, error: 'Bot not found' };
}
if (bot.ownerId !== user.id) {
if (!can(user, 'update', 'bot', { bot })) {
return { success: false, error: 'Unauthorized' };
}
if (bot.slug !== zod.data.slug) {

View File

@@ -37,4 +37,9 @@ export function useStreams() {
return context
}
export type StreamInfoResponse = (StreamInfo & { channel: Channel })[]
export type StreamInfoResponse = (StreamInfo & {
channel: Channel & {
isRestricted?: boolean;
restrictionExpiresAt?: string | null;
};
})[]

View File

@@ -31,6 +31,7 @@ export const lucia = new Lucia(adapter, {
pfpUrl: attributes.pfpUrl,
hasOnboarded: attributes.hasOnboarded,
personalChannelId: attributes.personalChannelId,
isAdmin: attributes.isAdmin,
};
},
});
@@ -48,4 +49,5 @@ interface DatabaseUserAttributes {
pfpUrl: string;
hasOnboarded: boolean;
personalChannelId: string | null;
isAdmin: boolean;
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,41 @@
-- CreateTable
CREATE TABLE "UserBan" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"bannedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "UserBan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChannelRestriction" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"restrictedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "ChannelRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserBan_userId_key" ON "UserBan"("userId");
-- CreateIndex
CREATE INDEX "UserBan_userId_idx" ON "UserBan"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ChannelRestriction_channelId_key" ON "ChannelRestriction"("channelId");
-- CreateIndex
CREATE INDEX "ChannelRestriction_channelId_idx" ON "ChannelRestriction"("channelId");
-- AddForeignKey
ALTER TABLE "UserBan" ADD CONSTRAINT "UserBan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChannelRestriction" ADD CONSTRAINT "ChannelRestriction_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -17,11 +17,13 @@ datasource db {
}
model User {
id String @id @default(cuid())
slack_id String
email String?
pfpUrl String
id String @id @default(cuid())
slack_id String
email String?
pfpUrl String
hasOnboarded Boolean @default(false)
isAdmin Boolean @default(false)
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
personalChannelId String? @unique
@@ -32,6 +34,7 @@ model User {
streams StreamInfo[]
followers Follow[] @relation("UserFollows")
botAccounts BotAccount[]
ban UserBan?
@@index([personalChannelId])
}
@@ -55,6 +58,7 @@ model Channel {
streamKey StreamKey?
obsChatGrantToken String @unique @default(cuid())
is247 Boolean @default(false)
restriction ChannelRestriction?
@@index([ownerId])
}
@@ -113,12 +117,12 @@ model StreamKey {
}
model BotAccount {
id String @id @default(cuid())
id String @id @default(cuid())
displayName String
slug String @unique
description String @default("A hctv bot account")
slug String @unique
description String @default("A hctv bot account")
pfpUrl String
owner User @relation(fields: [ownerId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
ownerId String
apiKeys BotApiKey[]
@@ -139,3 +143,27 @@ model BotApiKey {
@@index([botAccountId])
}
model UserBan {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
reason String
bannedBy String
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([userId])
}
model ChannelRestriction {
id String @id @default(cuid())
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String @unique
reason String
restrictedBy String
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([channelId])
}

34
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ importers:
cmdk:
specifier: 1.0.0
version: 1.0.0(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
date-fns:
specifier: ^4.1.0
version: 4.1.0
hls-video-element:
specifier: ^1.5.0
version: 1.5.10
@@ -204,6 +207,9 @@ importers:
react:
specifier: ^19.2.3
version: 19.2.3
react-day-picker:
specifier: ^9.13.0
version: 9.13.0(react@19.2.3)
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
@@ -615,6 +621,9 @@ packages:
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@effect/platform@0.90.3':
resolution: {integrity: sha512-XvQ37yzWQKih4Du2CYladd1i/MzqtgkTPNCaN6Ku6No4CK83hDtXIV/rP03nEoBg2R3Pqgz6gGWmE2id2G81HA==}
peerDependencies:
@@ -4355,6 +4364,12 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
@@ -6609,6 +6624,12 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react-day-picker@9.13.0:
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
@@ -8469,6 +8490,8 @@ snapshots:
'@ctrl/tinycolor@4.2.0': {}
'@date-fns/tz@1.4.1': {}
'@effect/platform@0.90.3(effect@3.17.7)':
dependencies:
'@opentelemetry/semantic-conventions': 1.38.0
@@ -12689,6 +12712,10 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
dayjs@1.11.19: {}
debug@3.2.7:
@@ -15637,6 +15664,13 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
react-day-picker@9.13.0(react@19.2.3):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.3
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3