feat: banning

This commit is contained in:
Face 2025-05-31 19:29:20 +03:00
parent bc1687e40a
commit d9f2836fb9
11 changed files with 576 additions and 21 deletions

View file

@ -1,4 +1,3 @@
import { auth } from '$lib/auth';
import type { LayoutServerLoad } from './$types';
import { dev } from '$app/environment';
@ -9,12 +8,9 @@ export const load: LayoutServerLoad = async (event) => {
: 'private, max-age=30'
});
const sessionResponse = await auth.api.getSession({
headers: event.request.headers
});
// Use the user data already fetched and processed in hooks
return {
userSession: sessionResponse?.user || null,
userSession: event.locals.userSession,
url: event.url.pathname,
};
};

View file

@ -7,7 +7,7 @@
import AppSidebar from '$lib/components/self/AppSidebar.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { onMount, onDestroy } from 'svelte'; // onDestroy is already imported
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { ModeWatcher } from 'mode-watcher';
import { page } from '$app/state';

View file

@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Hammer, UserCheck, Ban } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
interface BannedUser {
id: number;
name: string;
username: string;
banReason: string;
}
let bannedUsers = $state<BannedUser[]>([]);
let loading = $state(true);
let actionLoading = $state(false);
let banDialogOpen = $state(false);
let usernameToAction = $state('');
let banReason = $state('');
async function loadBannedUsers() {
loading = true;
try {
const response = await fetch('/api/admin/users/banned-list');
if (response.ok) {
bannedUsers = await response.json();
}
} catch (e) {
toast.error('Failed to load banned users');
} finally {
loading = false;
}
}
async function banUser() {
if (!usernameToAction.trim() || !banReason.trim()) return;
actionLoading = true;
try {
const response = await fetch('/api/admin/users/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: usernameToAction.trim(), reason: banReason.trim() })
});
if (response.ok) {
toast.success('User banned successfully');
await loadBannedUsers();
banDialogOpen = false;
usernameToAction = '';
banReason = '';
} else {
const error = await response.json();
toast.error(error.message || 'Failed to ban user');
}
} catch (e) {
toast.error('Failed to ban user');
} finally {
actionLoading = false;
}
}
async function unbanUser(userId: number) {
actionLoading = true;
try {
const response = await fetch('/api/admin/users/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (response.ok) {
toast.success('User unbanned successfully');
await loadBannedUsers();
} else {
const error = await response.json();
toast.error(error.message || 'Failed to unban user');
}
} catch (e) {
toast.error('Failed to unban user');
} finally {
actionLoading = false;
}
}
function openBanDialog() {
usernameToAction = '';
banReason = '';
banDialogOpen = true;
}
onMount(() => {
loadBannedUsers();
});
</script>
<div class="container mx-auto max-w-4xl py-6">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2">
<Hammer class="h-5 w-5" />
Banned Users ({bannedUsers.length})
</Card.Title>
<Button onclick={openBanDialog}>
<Ban class="h-4 w-4" />
Ban User
</Button>
</Card.Header>
<Card.Content>
{#if loading}
<div class="space-y-4">
{#each Array(5) as _}
<div class="flex items-center justify-between p-4 border rounded">
<div class="space-y-2 flex-1">
<Skeleton class="h-4 w-48" />
<Skeleton class="h-3 w-32" />
<Skeleton class="h-3 w-64" />
</div>
<Skeleton class="h-8 w-16" />
</div>
{/each}
</div>
{:else if bannedUsers.length === 0}
<div class="text-center py-8">
<p class="text-muted-foreground">No banned users found.</p>
</div>
{:else}
<div class="space-y-4">
{#each bannedUsers as user}
<div class="flex items-center justify-between p-4 border rounded">
<div class="space-y-1 flex-1">
<div class="font-medium">{user.name}</div>
<div class="text-sm text-muted-foreground">@{user.username}</div>
<div class="text-sm">
<span class="font-medium">Reason:</span> {user.banReason}
</div>
</div>
<Button
size="sm"
variant="outline"
onclick={() => unbanUser(user.id)}
disabled={actionLoading}
>
<UserCheck class="h-4 w-4" />
Unban
</Button>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<Dialog.Root bind:open={banDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Ban User</Dialog.Title>
<Dialog.Description>
Enter the username and reason to ban a user.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-2">Username</label>
<Input
id="username"
bind:value={usernameToAction}
placeholder="Enter username (without @)"
required
/>
</div>
<div>
<label for="reason" class="block text-sm font-medium mb-2">Reason for ban</label>
<Textarea
id="reason"
bind:value={banReason}
placeholder="Enter the reason for banning this user..."
required
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => banDialogOpen = false}>Cancel</Button>
<Button
variant="destructive"
onclick={banUser}
disabled={!usernameToAction.trim() || !banReason.trim() || actionLoading}
>
Ban User
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,45 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const [currentUser] = await db
.select({ isAdmin: user.isAdmin })
.from(user)
.where(eq(user.id, Number(session.user.id)))
.limit(1);
if (!currentUser?.isAdmin) {
throw error(403, 'Admin access required');
}
try {
const users = await db
.select({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
banReason: user.banReason,
createdAt: user.createdAt
})
.from(user)
.orderBy(desc(user.createdAt));
return json(users);
} catch (e) {
console.error('Failed to fetch users:', e);
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,74 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, session } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const authSession = await auth.api.getSession({ headers: request.headers });
if (!authSession?.user) {
throw error(401, 'Not authenticated');
}
const [currentUser] = await db
.select({ isAdmin: user.isAdmin })
.from(user)
.where(eq(user.id, Number(authSession.user.id)))
.limit(1);
if (!currentUser?.isAdmin) {
throw error(403, 'Admin access required');
}
const { username, reason } = await request.json();
if (!username?.trim() || !reason?.trim()) {
throw error(400, 'Username and reason are required');
}
try {
const [targetUser] = await db
.select({ id: user.id, username: user.username, isAdmin: user.isAdmin })
.from(user)
.where(eq(user.username, username.trim()))
.limit(1);
if (!targetUser) {
throw error(404, 'User not found');
}
if (targetUser.isAdmin) {
throw error(400, 'Cannot ban admin users');
}
await db.transaction(async (tx) => {
await tx
.update(user)
.set({
isBanned: true,
banReason: reason.trim(),
updatedAt: new Date()
})
.where(eq(user.id, targetUser.id));
await tx
.delete(session)
.where(eq(session.userId, targetUser.id));
});
try {
const { clearUserCache } = await import('$lib/../hooks.server.js');
clearUserCache(targetUser.id.toString());
} catch (e) {
console.warn('Failed to clear user cache:', e);
}
return json({ success: true });
} catch (e: any) {
if (e.status) throw e;
console.error('Failed to ban user:', e);
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,47 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { desc, eq, and, not, like } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const [currentUser] = await db
.select({ isAdmin: user.isAdmin })
.from(user)
.where(eq(user.id, Number(session.user.id)))
.limit(1);
if (!currentUser?.isAdmin) {
throw error(403, 'Admin access required');
}
try {
const bannedUsers = await db
.select({
id: user.id,
name: user.name,
username: user.username,
banReason: user.banReason
})
.from(user)
.where(
and(
eq(user.isBanned, true),
not(like(user.banReason, '%Account deletion requested%'))
)
)
.orderBy(desc(user.updatedAt));
return json(bannedUsers);
} catch (e) {
console.error('Failed to fetch banned users:', e);
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,46 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const authSession = await auth.api.getSession({ headers: request.headers });
if (!authSession?.user) {
throw error(401, 'Not authenticated');
}
const [currentUser] = await db
.select({ isAdmin: user.isAdmin })
.from(user)
.where(eq(user.id, Number(authSession.user.id)))
.limit(1);
if (!currentUser?.isAdmin) {
throw error(403, 'Admin access required');
}
const { userId } = await request.json();
if (!userId) {
throw error(400, 'User ID is required');
}
try {
await db
.update(user)
.set({
isBanned: false,
banReason: null,
updatedAt: new Date()
})
.where(eq(user.id, userId));
return json({ success: true });
} catch (e) {
console.error('Failed to unban user:', e);
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Card from '$lib/components/ui/card';
import { AlertTriangle } from 'lucide-svelte';
let reason = $derived($page.url.searchParams.get('reason') || 'none');
</script>
<div class="container mx-auto max-w-2xl py-20">
<Card.Root class="border-primary">
<Card.Header class="text-center">
<div
class="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
>
<AlertTriangle class="text-primary h-8 w-8" />
</div>
<Card.Title class="text-primary text-2xl">Account Suspended</Card.Title>
</Card.Header>
<Card.Content class="space-y-4 text-center">
<p class="text-muted-foreground">
REASON PROVIDED BY ADMINS: {reason}
</p>
<p class="text-muted-foreground text-sm">
If you believe this is an error, <a
href="https://discord.gg/cKWNV2uZUP"
class="text-primary underline">please contact support</a
>.
</p>
</Card.Content>
</Card.Root>
</div>