added admin system

This commit is contained in:
averel10
2026-03-08 10:27:23 +01:00
parent 4d8040a674
commit 445a429be7
9 changed files with 297 additions and 16 deletions

View File

@@ -0,0 +1,36 @@
"use server";
import db from "@/lib/db";
import { user } from "@/lib/model/auth-schema";
import { eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/auth";
export async function deleteUser(userId: string) {
const result = await requireAdmin();
if (!result.authenticated || !result.admin) {
throw new Error("Unauthorized");
}
await db.delete(user).where(eq(user.id, userId));
}
export async function setUserAdmin(userId: string, isAdmin: boolean) {
const result = await requireAdmin();
if (!result.authenticated || !result.admin) {
throw new Error("Unauthorized");
}
await db.update(user).set({ admin: isAdmin }).where(eq(user.id, userId));
}
export async function getAllUsers() {
const result = await requireAdmin();
if (!result.authenticated || !result.admin) {
throw new Error("Unauthorized");
}
return db.select().from(user);
}

View File

@@ -3,6 +3,8 @@ import db from '@/lib/db';
import { dataset_entry } from '@/lib/model/dataset_entry';
import { eq } from 'drizzle-orm';
import AudioPlayer from '@/components/AudioPlayer';
import { requireAdmin } from '@/lib/auth';
import { redirect } from 'next/navigation';
interface DatasetEntryPageProps {
params: {
@@ -12,6 +14,16 @@ interface DatasetEntryPageProps {
}
export default async function DatasetEntryPage({ params }: DatasetEntryPageProps) {
const result = await requireAdmin();
if (!result.authenticated) {
redirect("/user/sign-in");
}
if (!result.admin) {
redirect("/");
}
const { id: datasetId, entryId } = await params;
const datasetIdNum = parseInt(datasetId, 10);
const entryIdNum = parseInt(entryId, 10);

View File

@@ -6,6 +6,8 @@ import EditableDatasetHeader from '@/components/EditableDatasetHeader';
import DatasetEntriesList from '@/components/DatasetEntriesList';
import UploadDatasetEntriesModal from '@/components/UploadDatasetEntriesModal';
import RemoveAllEntriesButton from '@/components/RemoveAllEntriesButton';
import { requireAdmin } from '@/lib/auth';
import { redirect } from 'next/navigation';
interface DatasetPageProps {
params: {
@@ -14,6 +16,16 @@ interface DatasetPageProps {
}
export default async function DatasetPage({ params }: DatasetPageProps) {
const result = await requireAdmin();
if (!result.authenticated) {
redirect("/user/sign-in");
}
if (!result.admin) {
redirect("/");
}
const {id} = await params;
const datasetId = parseInt(id, 10);

View File

@@ -1,22 +1,39 @@
import DatasetsList from "@/components/DatasetsList";
import CreateDatasetModal from "@/components/CreateDatasetModal";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { requireAdmin } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
export default async function AdminPage() {
const session = await auth.api.getSession({
headers: await headers()
})
const result = await requireAdmin();
if(!session) {
redirect("/user/sign-in")
if (!result.authenticated) {
redirect("/user/sign-in");
}
if (!result.admin) {
redirect("/");
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-4">Admin Panel</h1>
<div className="flex gap-4">
<Link
href="/admin/users"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Manage Users
</Link>
</div>
</div>
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">Datasets</h2>
<CreateDatasetModal />
<DatasetsList />
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import UsersList from "@/components/UsersList";
import { requireAdmin } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getAllUsers } from "@/app/actions/manage-users";
export default async function UsersPage() {
const result = await requireAdmin();
if (!result.authenticated) {
redirect("/user/sign-in");
}
if (!result.admin) {
redirect("/");
}
const users = await getAllUsers();
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6">
<Link
href="/admin"
className="text-blue-500 hover:text-blue-600 mb-4 inline-block"
>
Back to Admin
</Link>
<h1 className="text-3xl font-bold">User Management</h1>
</div>
<div className="bg-white rounded-lg shadow">
<div className="p-6">
<UsersList users={users} />
</div>
</div>
</div>
);
}

View File

@@ -31,6 +31,14 @@ export function Header() {
<p className="text-xs text-blue-100 uppercase tracking-wide">Willkommen zurück</p>
<p className="text-sm font-semibold">{session.user?.email}</p>
</div>
{(session.user as any)?.admin && (
<Link
href="/admin"
className="px-4 py-2 bg-yellow-500 hover:bg-yellow-600 active:bg-yellow-700 text-white font-semibold rounded-lg transition-all duration-200 transform hover:scale-105"
>
Admin
</Link>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-red-600 hover:bg-red-700 active:bg-red-800 text-white font-semibold rounded-lg transition-all duration-200 transform hover:scale-105"

View File

@@ -0,0 +1,140 @@
"use client";
import { deleteUser, setUserAdmin } from "@/app/actions/manage-users";
import { useState, useMemo } from "react";
interface User {
id: string;
name: string;
email: string;
admin: boolean;
createdAt: Date;
}
interface UsersListProps {
users: User[];
}
export default function UsersList({ users: initialUsers }: UsersListProps) {
const [users, setUsers] = useState(initialUsers);
const [loading, setLoading] = useState<string | null>(null);
const [search, setSearch] = useState("");
const filteredUsers = useMemo(() => {
const query = search.toLowerCase();
return users.filter(
(u) =>
u.email.toLowerCase().includes(query) ||
u.name.toLowerCase().includes(query)
);
}, [users, search]);
const handleDeleteUser = async (userId: string) => {
if (!confirm("Are you sure you want to delete this user?")) {
return;
}
setLoading(userId);
try {
await deleteUser(userId);
setUsers(users.filter((u) => u.id !== userId));
} catch (error) {
alert("Failed to delete user");
} finally {
setLoading(null);
}
};
const handleToggleAdmin = async (userId: string, currentAdmin: boolean) => {
setLoading(userId);
try {
await setUserAdmin(userId, !currentAdmin);
setUsers(
users.map((u) =>
u.id === userId ? { ...u, admin: !currentAdmin } : u
)
);
} catch (error) {
alert("Failed to update user");
} finally {
setLoading(null);
}
};
return (
<div>
<div className="mb-4">
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200 rounded-lg">
<thead className="bg-gray-100 border-b">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold">Email</th>
<th className="px-6 py-3 text-left text-sm font-semibold">Admin</th>
<th className="px-6 py-3 text-left text-sm font-semibold">
Created
</th>
<th className="px-6 py-3 text-left text-sm font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr
key={user.id}
className="border-b hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">{user.email}</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 rounded text-sm font-medium ${
user.admin
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{user.admin ? "Yes" : "No"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 space-x-2">
<button
onClick={() => handleToggleAdmin(user.id, user.admin)}
disabled={loading === user.id}
className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{loading === user.id
? "..."
: user.admin
? "Remove Admin"
: "Make Admin"}
</button>
<button
onClick={() => handleDeleteUser(user.id)}
disabled={loading === user.id}
className="px-3 py-1 text-sm bg-red-500 text-white rounded hover:bg-red-600 disabled:opacity-50"
>
{loading === user.id ? "..." : "Delete"}
</button>
</td>
</tr>
))}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="text-center py-8 text-gray-500">
{search ? "No users found matching your search" : "No users found"}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,20 +1,24 @@
import { betterAuth } from "better-auth";
import { betterAuth, Session } from "better-auth";
import { localization } from "better-auth-localization";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { headers } from "next/headers";
import db from "./db";
import { user, session, account, verification } from "./model";
import * as schema from "./model/auth-schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite",
schema: {
user,
session,
account,
verification,
},
schema: schema
}),
user:{
additionalFields: {
admin: {
type: "boolean",
default: false,
}
}
},
emailAndPassword: {
enabled: true,
},
@@ -26,3 +30,15 @@ export const auth = betterAuth({
})
]
});
export async function requireAdmin() {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session) {
return { authenticated: false, admin: false };
}
return { authenticated: true, admin: session.user.admin, session };
}

View File

@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
.default(false)
.notNull(),
image: text("image"),
admin: integer("admin", { mode: "boolean" }).default(false).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),