feat: banning
This commit is contained in:
parent
bc1687e40a
commit
d9f2836fb9
11 changed files with 576 additions and 21 deletions
15
website/src/app.d.ts
vendored
15
website/src/app.d.ts
vendored
|
|
@ -1,12 +1,13 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
import type { User } from '$lib/stores/user-data';
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Locals {
|
||||||
// interface Locals {}
|
userSession: User;
|
||||||
// interface PageData {}
|
}
|
||||||
// interface PageState {}
|
interface PageData {
|
||||||
// interface Platform {}
|
userSession: User;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { resolveExpiredQuestions, processAccountDeletions } from "$lib/server/jo
|
||||||
import { svelteKitHandler } from "better-auth/svelte-kit";
|
import { svelteKitHandler } from "better-auth/svelte-kit";
|
||||||
import { redis } from "$lib/server/redis";
|
import { redis } from "$lib/server/redis";
|
||||||
import { building } from '$app/environment';
|
import { building } from '$app/environment';
|
||||||
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
async function initializeScheduler() {
|
async function initializeScheduler() {
|
||||||
if (building) return;
|
if (building) return;
|
||||||
|
|
@ -68,14 +72,109 @@ async function initializeScheduler() {
|
||||||
|
|
||||||
initializeScheduler();
|
initializeScheduler();
|
||||||
|
|
||||||
export async function handle({ event, resolve }) {
|
const sessionCache = new Map<string, {
|
||||||
// event.setHeaders({
|
userData: any;
|
||||||
// 'Cache-Control': 'private, no-cache, no-store, must-revalidate'
|
timestamp: number;
|
||||||
// });
|
ttl: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const CACHE_CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of sessionCache.entries()) {
|
||||||
|
if (now - value.timestamp > value.ttl) {
|
||||||
|
sessionCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CACHE_CLEANUP_INTERVAL);
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.devtools')) {
|
if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.devtools')) {
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth });
|
// Get session from auth
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
let userData = null;
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
const userId = session.user.id;
|
||||||
|
const cacheKey = `user:${userId}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const cached = sessionCache.get(cacheKey);
|
||||||
|
if (cached && (now - cached.timestamp) < cached.ttl) {
|
||||||
|
userData = cached.userData;
|
||||||
|
} else {
|
||||||
|
const [userRecord] = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
image: user.image,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
banReason: user.banReason,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||||
|
bio: user.bio,
|
||||||
|
volumeMaster: user.volumeMaster,
|
||||||
|
volumeMuted: user.volumeMuted
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, Number(userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userRecord?.isBanned) {
|
||||||
|
try {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sign out banned user:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.url.pathname !== '/banned') {
|
||||||
|
const banReason = encodeURIComponent(userRecord.banReason || 'Account suspended');
|
||||||
|
throw redirect(302, `/banned?reason=${banReason}`);
|
||||||
|
}
|
||||||
|
} else if (userRecord) {
|
||||||
|
userData = {
|
||||||
|
id: userRecord.id.toString(),
|
||||||
|
name: userRecord.name,
|
||||||
|
username: userRecord.username,
|
||||||
|
email: userRecord.email,
|
||||||
|
isAdmin: userRecord.isAdmin || false,
|
||||||
|
image: userRecord.image || '',
|
||||||
|
isBanned: userRecord.isBanned || false,
|
||||||
|
banReason: userRecord.banReason,
|
||||||
|
avatarUrl: userRecord.image,
|
||||||
|
baseCurrencyBalance: parseFloat(userRecord.baseCurrencyBalance || '0'),
|
||||||
|
bio: userRecord.bio || '',
|
||||||
|
volumeMaster: parseFloat(userRecord.volumeMaster || '0.7'),
|
||||||
|
volumeMuted: userRecord.volumeMuted || false
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheTTL = userRecord.isAdmin ? CACHE_TTL * 2 : CACHE_TTL;
|
||||||
|
sessionCache.set(cacheKey, {
|
||||||
|
userData,
|
||||||
|
timestamp: now,
|
||||||
|
ttl: cacheTTL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.locals.userSession = userData;
|
||||||
|
|
||||||
|
return svelteKitHandler({ event, resolve, auth });
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clearUserCache(userId: string) {
|
||||||
|
sessionCache.delete(`user:${userId}`);
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +27,10 @@
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
TrendingUpDown,
|
TrendingUpDown,
|
||||||
Scale,
|
Scale,
|
||||||
ShieldCheck
|
ShieldCheck,
|
||||||
|
|
||||||
|
Hammer
|
||||||
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
@ -113,6 +116,11 @@
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUserManagementClick() {
|
||||||
|
goto('/admin/users');
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
|
||||||
function handlePromoCodesClick() {
|
function handlePromoCodesClick() {
|
||||||
goto('/admin/promo');
|
goto('/admin/promo');
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
|
|
@ -402,6 +410,13 @@
|
||||||
<Shield class="text-primary" />
|
<Shield class="text-primary" />
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={handleUserManagementClick}
|
||||||
|
class="text-primary hover:text-primary!"
|
||||||
|
>
|
||||||
|
<Hammer class="text-primary" />
|
||||||
|
User Management
|
||||||
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={handlePromoCodesClick}
|
onclick={handlePromoCodesClick}
|
||||||
class="text-primary hover:text-primary!"
|
class="text-primary hover:text-primary!"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { auth } from '$lib/auth';
|
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
|
|
@ -9,12 +8,9 @@ export const load: LayoutServerLoad = async (event) => {
|
||||||
: 'private, max-age=30'
|
: 'private, max-age=30'
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionResponse = await auth.api.getSession({
|
// Use the user data already fetched and processed in hooks
|
||||||
headers: event.request.headers
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userSession: sessionResponse?.user || null,
|
userSession: event.locals.userSession,
|
||||||
url: event.url.pathname,
|
url: event.url.pathname,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import AppSidebar from '$lib/components/self/AppSidebar.svelte';
|
import AppSidebar from '$lib/components/self/AppSidebar.svelte';
|
||||||
|
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
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 { invalidateAll } from '$app/navigation';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
|
||||||
201
website/src/routes/admin/users/+page.svelte
Normal file
201
website/src/routes/admin/users/+page.svelte
Normal 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>
|
||||||
45
website/src/routes/api/admin/users/+server.ts
Normal file
45
website/src/routes/api/admin/users/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
74
website/src/routes/api/admin/users/ban/+server.ts
Normal file
74
website/src/routes/api/admin/users/ban/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
47
website/src/routes/api/admin/users/banned-list/+server.ts
Normal file
47
website/src/routes/api/admin/users/banned-list/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
46
website/src/routes/api/admin/users/unban/+server.ts
Normal file
46
website/src/routes/api/admin/users/unban/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
31
website/src/routes/banned/+page.svelte
Normal file
31
website/src/routes/banned/+page.svelte
Normal 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>
|
||||||
Reference in a new issue