diff --git a/src/backend/app/models/user.py b/src/backend/app/models/user.py index 96d447e1..032c6a50 100644 --- a/src/backend/app/models/user.py +++ b/src/backend/app/models/user.py @@ -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, @@ -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() diff --git a/src/backend/app/routes/http/admin/user.py b/src/backend/app/routes/http/admin/user.py index 02bf5de3..9c3c1c12 100644 --- a/src/backend/app/routes/http/admin/user.py +++ b/src/backend/app/routes/http/admin/user.py @@ -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, @@ -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 diff --git a/src/frontend/src/lib/consts/backend.ts b/src/frontend/src/lib/consts/backend.ts index e6ffb623..e0870b99 100644 --- a/src/frontend/src/lib/consts/backend.ts +++ b/src/frontend/src/lib/consts/backend.ts @@ -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}`) }; diff --git a/src/frontend/src/lib/queries/admin_users.ts b/src/frontend/src/lib/queries/admin_users.ts new file mode 100644 index 00000000..8ea25679 --- /dev/null +++ b/src/frontend/src/lib/queries/admin_users.ts @@ -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 }; +}; diff --git a/src/frontend/src/lib/queries/files.ts b/src/frontend/src/lib/queries/files.ts index 0293a40d..bc0bcd20 100644 --- a/src/frontend/src/lib/queries/files.ts +++ b/src/frontend/src/lib/queries/files.ts @@ -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()); @@ -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'); } diff --git a/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/app-sidebar.svelte b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/app-sidebar.svelte index 30e7edee..f1c5ac34 100644 --- a/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/app-sidebar.svelte +++ b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/app-sidebar.svelte @@ -1,6 +1,6 @@ diff --git a/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/+page.svelte b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/+page.svelte new file mode 100644 index 00000000..d745079e --- /dev/null +++ b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/+page.svelte @@ -0,0 +1,131 @@ + + +
+
+

Users

+

Manage system users.

+
+ +
+ +
+
+ + + + + + + Username + Email + Actions + + + + {#if users.isLoading} + + + Loading users... + + + {:else if users.error} + + + Failed to load users: {users.error.message} + + + {:else if !users.data?.items || users.data.items.length === 0} + + + No users found. + + + {:else} + {#each users.data.items as u} + + {u.username} + {u.email || '-'} + + + + + {/each} + {/if} + + + + + +
+ + {#snippet children({ pages, currentPage })} + + + + + {#each pages as page (page.key)} + {#if page.type === 'ellipsis'} + + + + {:else} + + + {page.value} + + + {/if} + {/each} + + + + + {/snippet} + +
+ + + + diff --git a/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/create_user_dialog.svelte b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/create_user_dialog.svelte new file mode 100644 index 00000000..e88e5509 --- /dev/null +++ b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/create_user_dialog.svelte @@ -0,0 +1,189 @@ + + + + + + + +
+ +
+
+ Create User + + Add a new user to the system. Provide an email if you want. + +
+
+ +
+ + + {#snippet children({ props })} + Username +
+
+ +
+ +
+ {/snippet} +
+ +
+ + + + {#snippet children({ props })} + Email +
+
+ +
+ +
+ {/snippet} +
+ +
+ + + + {#snippet children({ props })} + Password +
+
+ +
+ + + +
+ {/snippet} +
+ +
+ + + + + {#if $submitting} + + Creating... + {:else} + Create User + {/if} + + +
+
+
diff --git a/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/delete_user_dialog.svelte b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/delete_user_dialog.svelte new file mode 100644 index 00000000..4f1a4a25 --- /dev/null +++ b/src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/users/delete_user_dialog.svelte @@ -0,0 +1,49 @@ + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the user account. + + + + + + + +