Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ class UserCreate(SQLModel):


class UserOut(SQLModel):
username: str = Field(index=True, unique=True)
email: str | None = Field(default=None, index=True, unique=True)


class User(UserOut, table=True):
id: UUID = Field(
default=None,
primary_key=True,
Expand All @@ -30,5 +25,9 @@ class User(UserOut, table=True):
)
},
)
username: str = Field(index=True, unique=True)
email: str | None = Field(default=None, index=True, unique=True)


class User(UserOut, table=True):
password_hash: str = Field()
51 changes: 48 additions & 3 deletions src/backend/app/routes/http/admin/user.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
from fastapi import APIRouter
from uuid import UUID

from app.deps import CurrentUser, SessionDep
from app.models.user import UserOut, UserUpdate
from fastapi import APIRouter, HTTPException
from sqlmodel import select

from app.deps import CurrentUser, PaginationDep, SessionDep
from app.models.user import User, UserCreate, UserOut, UserUpdate
from app.pagination import Page, paginate
from app.security import get_password_hash

router = APIRouter()


@router.get("/users", response_model=Page[UserOut])
async def get_users(
session: SessionDep,
_: CurrentUser,
pagination: PaginationDep,
):
return await paginate(select(User), session, pagination)


@router.patch("/user", response_model=UserOut)
async def change_user(
session: SessionDep,
Expand All @@ -18,3 +32,34 @@ async def change_user(
await session.commit()
await session.refresh(user)
return user


@router.post("/user", response_model=UserOut)
async def create_user(
session: SessionDep,
user_in: UserCreate,
_: CurrentUser,
):
user = User(
username=user_in.username,
email=user_in.email,
password_hash=get_password_hash(user_in.password),
)
session.add(user)
await session.commit()
await session.refresh(user)
return user


@router.delete("/user/{user_id}", response_model=UserOut)
async def delete_user(
session: SessionDep,
user_id: UUID,
_: CurrentUser,
):
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
await session.delete(user)
await session.commit()
return user
3 changes: 3 additions & 0 deletions src/frontend/src/lib/consts/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export class Api {
return {
CONFIG: this.#url('admin/config'),
USER_UPDATE: this.#url('admin/user'),
USERS: this.#url('admin/users'),
USER_CREATE: this.#url('admin/user'),
USER_DELETE: (id: string) => this.#url(`admin/user/${id}`),
FILES: this.#url('admin/files'),
FILE_REVOKE: (id: string) => this.#url(`admin/files/${id}`)
};
Expand Down
61 changes: 61 additions & 0 deletions src/frontend/src/lib/queries/admin_users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Api } from '#consts/backend';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';

export const usersQueryKey = ['admin-users'];

export const useUsersQuery = (page: () => number, size: number) => {
const queryClient = useQueryClient();

const users = createQuery(() => ({
queryKey: [...usersQueryKey, page()],
queryFn: async () => {
const res = await fetch(`${Api.ADMIN.USERS}?page=${page()}&size=${size}`, {
credentials: 'include'
});

if (!res.ok) {
throw new Error('Failed to fetch users');
}

return res.json();
}
}));

const createUser = async (user_in: any) => {
const res = await fetch(Api.ADMIN.USER_CREATE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(user_in),
credentials: 'include'
});

if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || 'Failed to create user');
}

const data = await res.json();
queryClient.invalidateQueries({ queryKey: usersQueryKey });
return data;
};

const deleteUser = async (user_id: string) => {
const res = await fetch(Api.ADMIN.USER_DELETE(user_id), {
method: 'DELETE',
credentials: 'include'
});

if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || 'Failed to delete user');
}

const data = await res.json();
queryClient.invalidateQueries({ queryKey: usersQueryKey });
return data;
};

return { users, createUser, deleteUser };
};
6 changes: 4 additions & 2 deletions src/frontend/src/lib/queries/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ export type PaginatedFiles = {
};
};

const queryKey = ['admin-files'];

export const useFilesQuery = (page: () => number = () => 1, pageSize: number = 20) => {
const queryClient = useQueryClient();
const query = createQuery(() => ({
queryKey: ['admin-files', page(), pageSize],
queryKey: [...queryKey, page(), pageSize],
queryFn: async () => {
const url = new URL(Api.ADMIN.FILES, window.location.origin);
url.searchParams.set('page', page().toString());
Expand Down Expand Up @@ -61,7 +63,7 @@ export const useFilesQuery = (page: () => number = () => 1, pageSize: number = 2
});

if (res.ok) {
await queryClient.invalidateQueries({ queryKey: ['admin-files'] });
await queryClient.invalidateQueries({ queryKey: [...queryKey] });
} else {
throw new Error('Failed to revoke file');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index';
import { Settings, UserPen, Link } from '@lucide/svelte';
import { Settings, UserPen, Link, Users } from '@lucide/svelte';
import favicon from '$lib/assets/logo.svg';
const items = [
{
Expand All @@ -17,6 +17,11 @@
title: 'Outstanding Urls',
url: '/admin/urls',
icon: Link
},
{
title: 'Users',
url: '/admin/users',
icon: Users
}
];
</script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script lang="ts">
import { useAuth } from '#queries/auth';
import { useUsersQuery } from '#queries/admin_users';
import * as Table from '$lib/components/ui/table';
import * as Card from '$lib/components/ui/card';
import * as Pagination from '$lib/components/ui/pagination';
import { Button } from '$lib/components/ui/button';
import { Trash2, UserPlus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import CreateUserDialog from './create_user_dialog.svelte';
import DeleteUserDialog from './delete_user_dialog.svelte';

let currentPage = $state(1);
const pageSize = 20;

const { user } = useAuth();
const { users } = useUsersQuery(() => currentPage, pageSize);

let isCreateDialogOpen = $state(false);
let isDeleteDialogOpen = $state(false);
let userToDelete = $state<string | null>(null);

let totalItems = $derived(users.data?.total_items ?? 0);

function requestDelete(userId: string) {
if (userId === user.data?.id) {
toast.error('Cannot delete yourself.');
return;
}
userToDelete = userId;
isDeleteDialogOpen = true;
}
</script>

<div class="flex items-center justify-between space-y-2 pb-6">
<div>
<h2 class="text-3xl font-bold tracking-tight">Users</h2>
<p class="text-muted-foreground">Manage system users.</p>
</div>

<div>
<Button onclick={() => (isCreateDialogOpen = true)}>
<UserPlus class="mr-2 h-4 w-4" />
Create User
</Button>
</div>
</div>

<Card.Root>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Username</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.isLoading}
<Table.Row>
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
Loading users...
</Table.Cell>
</Table.Row>
{:else if users.error}
<Table.Row>
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
Failed to load users: {users.error.message}
</Table.Cell>
</Table.Row>
{:else if !users.data?.items || users.data.items.length === 0}
<Table.Row>
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
No users found.
</Table.Cell>
</Table.Row>
{:else}
{#each users.data.items as u}
<Table.Row>
<Table.Cell>{u.username}</Table.Cell>
<Table.Cell>{u.email || '-'}</Table.Cell>
<Table.Cell class="text-right">
<Button
variant="ghost"
size="icon"
disabled={u.id === user.data?.id}
onclick={() => requestDelete(u.id)}
>
<Trash2 class="h-4 w-4 cursor-pointer text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>

<div class="flex items-center justify-end py-4">
<Pagination.Root count={totalItems} perPage={pageSize} bind:page={currentPage}>
{#snippet children({ pages, currentPage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>

<CreateUserDialog bind:open={isCreateDialogOpen} />

<DeleteUserDialog bind:open={isDeleteDialogOpen} bind:userId={userToDelete} />
Loading