Skip to content

Commit b45efec

Browse files
feat: add support for adding users in the admin panel (#802)
* add * add * add
1 parent fbcba37 commit b45efec

9 files changed

Lines changed: 495 additions & 11 deletions

File tree

src/backend/app/models/user.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@ class UserCreate(SQLModel):
1616

1717

1818
class UserOut(SQLModel):
19-
username: str = Field(index=True, unique=True)
20-
email: str | None = Field(default=None, index=True, unique=True)
21-
22-
23-
class User(UserOut, table=True):
2419
id: UUID = Field(
2520
default=None,
2621
primary_key=True,
@@ -30,5 +25,9 @@ class User(UserOut, table=True):
3025
)
3126
},
3227
)
28+
username: str = Field(index=True, unique=True)
29+
email: str | None = Field(default=None, index=True, unique=True)
3330

31+
32+
class User(UserOut, table=True):
3433
password_hash: str = Field()

src/backend/app/routes/http/admin/user.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1-
from fastapi import APIRouter
1+
from uuid import UUID
22

3-
from app.deps import CurrentUser, SessionDep
4-
from app.models.user import UserOut, UserUpdate
3+
from fastapi import APIRouter, HTTPException
4+
from sqlmodel import select
5+
6+
from app.deps import CurrentUser, PaginationDep, SessionDep
7+
from app.models.user import User, UserCreate, UserOut, UserUpdate
8+
from app.pagination import Page, paginate
9+
from app.security import get_password_hash
510

611
router = APIRouter()
712

813

14+
@router.get("/users", response_model=Page[UserOut])
15+
async def get_users(
16+
session: SessionDep,
17+
_: CurrentUser,
18+
pagination: PaginationDep,
19+
):
20+
return await paginate(select(User), session, pagination)
21+
22+
923
@router.patch("/user", response_model=UserOut)
1024
async def change_user(
1125
session: SessionDep,
@@ -18,3 +32,34 @@ async def change_user(
1832
await session.commit()
1933
await session.refresh(user)
2034
return user
35+
36+
37+
@router.post("/user", response_model=UserOut)
38+
async def create_user(
39+
session: SessionDep,
40+
user_in: UserCreate,
41+
_: CurrentUser,
42+
):
43+
user = User(
44+
username=user_in.username,
45+
email=user_in.email,
46+
password_hash=get_password_hash(user_in.password),
47+
)
48+
session.add(user)
49+
await session.commit()
50+
await session.refresh(user)
51+
return user
52+
53+
54+
@router.delete("/user/{user_id}", response_model=UserOut)
55+
async def delete_user(
56+
session: SessionDep,
57+
user_id: UUID,
58+
_: CurrentUser,
59+
):
60+
user = await session.get(User, user_id)
61+
if not user:
62+
raise HTTPException(status_code=404, detail="User not found")
63+
await session.delete(user)
64+
await session.commit()
65+
return user

src/frontend/src/lib/consts/backend.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export class Api {
6565
return {
6666
CONFIG: this.#url('admin/config'),
6767
USER_UPDATE: this.#url('admin/user'),
68+
USERS: this.#url('admin/users'),
69+
USER_CREATE: this.#url('admin/user'),
70+
USER_DELETE: (id: string) => this.#url(`admin/user/${id}`),
6871
FILES: this.#url('admin/files'),
6972
FILE_REVOKE: (id: string) => this.#url(`admin/files/${id}`)
7073
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Api } from '#consts/backend';
2+
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
3+
4+
export const usersQueryKey = ['admin-users'];
5+
6+
export const useUsersQuery = (page: () => number, size: number) => {
7+
const queryClient = useQueryClient();
8+
9+
const users = createQuery(() => ({
10+
queryKey: [...usersQueryKey, page()],
11+
queryFn: async () => {
12+
const res = await fetch(`${Api.ADMIN.USERS}?page=${page()}&size=${size}`, {
13+
credentials: 'include'
14+
});
15+
16+
if (!res.ok) {
17+
throw new Error('Failed to fetch users');
18+
}
19+
20+
return res.json();
21+
}
22+
}));
23+
24+
const createUser = async (user_in: any) => {
25+
const res = await fetch(Api.ADMIN.USER_CREATE, {
26+
method: 'POST',
27+
headers: {
28+
'Content-Type': 'application/json'
29+
},
30+
body: JSON.stringify(user_in),
31+
credentials: 'include'
32+
});
33+
34+
if (!res.ok) {
35+
const error = await res.json().catch(() => ({}));
36+
throw new Error(error.detail || 'Failed to create user');
37+
}
38+
39+
const data = await res.json();
40+
queryClient.invalidateQueries({ queryKey: usersQueryKey });
41+
return data;
42+
};
43+
44+
const deleteUser = async (user_id: string) => {
45+
const res = await fetch(Api.ADMIN.USER_DELETE(user_id), {
46+
method: 'DELETE',
47+
credentials: 'include'
48+
});
49+
50+
if (!res.ok) {
51+
const error = await res.json().catch(() => ({}));
52+
throw new Error(error.detail || 'Failed to delete user');
53+
}
54+
55+
const data = await res.json();
56+
queryClient.invalidateQueries({ queryKey: usersQueryKey });
57+
return data;
58+
};
59+
60+
return { users, createUser, deleteUser };
61+
};

src/frontend/src/lib/queries/files.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ export type PaginatedFiles = {
2929
};
3030
};
3131

32+
const queryKey = ['admin-files'];
33+
3234
export const useFilesQuery = (page: () => number = () => 1, pageSize: number = 20) => {
3335
const queryClient = useQueryClient();
3436
const query = createQuery(() => ({
35-
queryKey: ['admin-files', page(), pageSize],
37+
queryKey: [...queryKey, page(), pageSize],
3638
queryFn: async () => {
3739
const url = new URL(Api.ADMIN.FILES, window.location.origin);
3840
url.searchParams.set('page', page().toString());
@@ -61,7 +63,7 @@ export const useFilesQuery = (page: () => number = () => 1, pageSize: number = 2
6163
});
6264

6365
if (res.ok) {
64-
await queryClient.invalidateQueries({ queryKey: ['admin-files'] });
66+
await queryClient.invalidateQueries({ queryKey: [...queryKey] });
6567
} else {
6668
throw new Error('Failed to revoke file');
6769
}

src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/app-sidebar.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import * as Sidebar from '$lib/components/ui/sidebar/index';
3-
import { Settings, UserPen, Link } from '@lucide/svelte';
3+
import { Settings, UserPen, Link, Users } from '@lucide/svelte';
44
import favicon from '$lib/assets/logo.svg';
55
const items = [
66
{
@@ -17,6 +17,11 @@
1717
title: 'Outstanding Urls',
1818
url: '/admin/urls',
1919
icon: Link
20+
},
21+
{
22+
title: 'Users',
23+
url: '/admin/users',
24+
icon: Users
2025
}
2126
];
2227
</script>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script lang="ts">
2+
import { useAuth } from '#queries/auth';
3+
import { useUsersQuery } from '#queries/admin_users';
4+
import * as Table from '$lib/components/ui/table';
5+
import * as Card from '$lib/components/ui/card';
6+
import * as Pagination from '$lib/components/ui/pagination';
7+
import { Button } from '$lib/components/ui/button';
8+
import { Trash2, UserPlus } from 'lucide-svelte';
9+
import { toast } from 'svelte-sonner';
10+
import CreateUserDialog from './create_user_dialog.svelte';
11+
import DeleteUserDialog from './delete_user_dialog.svelte';
12+
13+
let currentPage = $state(1);
14+
const pageSize = 20;
15+
16+
const { user } = useAuth();
17+
const { users } = useUsersQuery(() => currentPage, pageSize);
18+
19+
let isCreateDialogOpen = $state(false);
20+
let isDeleteDialogOpen = $state(false);
21+
let userToDelete = $state<string | null>(null);
22+
23+
let totalItems = $derived(users.data?.total_items ?? 0);
24+
25+
function requestDelete(userId: string) {
26+
if (userId === user.data?.id) {
27+
toast.error('Cannot delete yourself.');
28+
return;
29+
}
30+
userToDelete = userId;
31+
isDeleteDialogOpen = true;
32+
}
33+
</script>
34+
35+
<div class="flex items-center justify-between space-y-2 pb-6">
36+
<div>
37+
<h2 class="text-3xl font-bold tracking-tight">Users</h2>
38+
<p class="text-muted-foreground">Manage system users.</p>
39+
</div>
40+
41+
<div>
42+
<Button onclick={() => (isCreateDialogOpen = true)}>
43+
<UserPlus class="mr-2 h-4 w-4" />
44+
Create User
45+
</Button>
46+
</div>
47+
</div>
48+
49+
<Card.Root>
50+
<Card.Content class="p-0">
51+
<Table.Root>
52+
<Table.Header>
53+
<Table.Row>
54+
<Table.Head>Username</Table.Head>
55+
<Table.Head>Email</Table.Head>
56+
<Table.Head class="text-right">Actions</Table.Head>
57+
</Table.Row>
58+
</Table.Header>
59+
<Table.Body>
60+
{#if users.isLoading}
61+
<Table.Row>
62+
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
63+
Loading users...
64+
</Table.Cell>
65+
</Table.Row>
66+
{:else if users.error}
67+
<Table.Row>
68+
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
69+
Failed to load users: {users.error.message}
70+
</Table.Cell>
71+
</Table.Row>
72+
{:else if !users.data?.items || users.data.items.length === 0}
73+
<Table.Row>
74+
<Table.Cell colspan={3} class="py-8 text-center text-muted-foreground">
75+
No users found.
76+
</Table.Cell>
77+
</Table.Row>
78+
{:else}
79+
{#each users.data.items as u}
80+
<Table.Row>
81+
<Table.Cell>{u.username}</Table.Cell>
82+
<Table.Cell>{u.email || '-'}</Table.Cell>
83+
<Table.Cell class="text-right">
84+
<Button
85+
variant="ghost"
86+
size="icon"
87+
disabled={u.id === user.data?.id}
88+
onclick={() => requestDelete(u.id)}
89+
>
90+
<Trash2 class="h-4 w-4 cursor-pointer text-destructive" />
91+
</Button>
92+
</Table.Cell>
93+
</Table.Row>
94+
{/each}
95+
{/if}
96+
</Table.Body>
97+
</Table.Root>
98+
</Card.Content>
99+
</Card.Root>
100+
101+
<div class="flex items-center justify-end py-4">
102+
<Pagination.Root count={totalItems} perPage={pageSize} bind:page={currentPage}>
103+
{#snippet children({ pages, currentPage })}
104+
<Pagination.Content>
105+
<Pagination.Item>
106+
<Pagination.PrevButton />
107+
</Pagination.Item>
108+
{#each pages as page (page.key)}
109+
{#if page.type === 'ellipsis'}
110+
<Pagination.Item>
111+
<Pagination.Ellipsis />
112+
</Pagination.Item>
113+
{:else}
114+
<Pagination.Item>
115+
<Pagination.Link {page} isActive={currentPage === page.value}>
116+
{page.value}
117+
</Pagination.Link>
118+
</Pagination.Item>
119+
{/if}
120+
{/each}
121+
<Pagination.Item>
122+
<Pagination.NextButton />
123+
</Pagination.Item>
124+
</Pagination.Content>
125+
{/snippet}
126+
</Pagination.Root>
127+
</div>
128+
129+
<CreateUserDialog bind:open={isCreateDialogOpen} />
130+
131+
<DeleteUserDialog bind:open={isDeleteDialogOpen} bind:userId={userToDelete} />

0 commit comments

Comments
 (0)