mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
793
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal file
793
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal 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;
|
||||
}
|
||||
|
||||
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal file
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal file
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
213
apps/web/src/components/ui/calendar.tsx
Normal file
213
apps/web/src/components/ui/calendar.tsx
Normal 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 }
|
||||
121
apps/web/src/lib/auth/abac.ts
Normal file
121
apps/web/src/lib/auth/abac.ts
Normal 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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})[]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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
34
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user