From c3b9827c1687a14d62fe6ff47140569c21eb8364 Mon Sep 17 00:00:00 2001 From: kodename-kian Date: Sat, 15 Mar 2025 03:15:05 -0700 Subject: [PATCH 1/4] Profile view and edit page --- src/app/users/[username]/edit/page.tsx | 53 ++++++++++++++++++++++ src/app/users/[username]/page.tsx | 48 ++++++++++++++++++++ src/client/components/navbar/index.tsx | 8 ++-- src/client/components/user/user_editor.tsx | 22 +++++++++ src/client/components/user/user_viewer.tsx | 28 ++++++++++++ src/client/paths.ts | 10 +++- src/common/types/users.ts | 2 + src/server/authorization.ts | 6 +++ 8 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 src/app/users/[username]/edit/page.tsx create mode 100644 src/app/users/[username]/page.tsx create mode 100644 src/client/components/user/user_editor.tsx create mode 100644 src/client/components/user/user_viewer.tsx diff --git a/src/app/users/[username]/edit/page.tsx b/src/app/users/[username]/edit/page.tsx new file mode 100644 index 00000000..48043332 --- /dev/null +++ b/src/app/users/[username]/edit/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; +import { db } from "db"; +import { UserLookupDTO } from "common/types"; +import { DefaultLayout } from "client/components/layouts/default_layout"; +import { UserEditor } from "client/components/user/user_editor"; +import { ForbiddenPage } from "server/errors/forbidden"; +import { getSession } from "server/sessions"; +import { canManageUser } from "server/authorization"; + +async function getUser(username: string): Promise { + return db.transaction().execute(async (trx) => { + const user = await trx + .selectFrom("users") + .select((["id", "username", "name", "school", "role"])) + .where("username", "=", username) + .executeTakeFirst(); + + if (user == null) { return null; } + + return { + id: user.id, + username: user.username, + name: user.name, + school: user.school, + role: user.role, + }; + }); +} + +type ProfileEditPageProps = { + params: { username: string; } +} + +async function Page(props: ProfileEditPageProps) { + const user = await getUser(props.params.username); + + if (user == null) { return notFound(); } + + const session = await getSession(); + const canEdit = canManageUser(session, user.id); + + if (!canEdit) { return ; } + + return ( + + <> + + + + ); +} + +export default Page; diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx new file mode 100644 index 00000000..c6b7aebe --- /dev/null +++ b/src/app/users/[username]/page.tsx @@ -0,0 +1,48 @@ +import { notFound } from "next/navigation"; +import { db } from "db"; +import { UserLookupDTO } from "common/types"; +import { DefaultLayout } from "client/components/layouts/default_layout"; +import { UserViewer } from "client/components/user/user_viewer"; +import { getSession } from "server/sessions"; +import { canManageUser } from "server/authorization"; + +async function getUser(username: string): Promise { + return db.transaction().execute(async (trx) => { + const user = await trx + .selectFrom("users") + .select((["id", "username", "name", "school", "role"])) + .where("username", "=", username) + .executeTakeFirst(); + + if (user == null) { return null; } + + return { + id: user.id, + username: user.username, + name: user.name, + school: user.school, + role: user.role, + }; + }); +} + +type ProfilePageProps = { + params: { username: string; } +} + +async function Page(props: ProfilePageProps) { + const user = await getUser(props.params.username); + + if (user == null) { return notFound(); } + + const session = await getSession(); + const canEdit = canManageUser(session, user.id); + + return ( + + + + ); +} + +export default Page; diff --git a/src/client/components/navbar/index.tsx b/src/client/components/navbar/index.tsx index 50184ea4..87263013 100644 --- a/src/client/components/navbar/index.tsx +++ b/src/client/components/navbar/index.tsx @@ -89,8 +89,8 @@ export const NavbarAccount = memo(() => { } else if (session.user.role === "admin") { return ( <> - - {session.user.name || "Anonymous"} + + {session.user.username} Logout @@ -98,7 +98,9 @@ export const NavbarAccount = memo(() => { } else { return ( <> -
{session.user.name || "Anonymous"}
+ + {session.user.username} + Logout ); diff --git a/src/client/components/user/user_editor.tsx b/src/client/components/user/user_editor.tsx new file mode 100644 index 00000000..39101c91 --- /dev/null +++ b/src/client/components/user/user_editor.tsx @@ -0,0 +1,22 @@ +"use client"; +import { UserLookupDTO } from "common/types"; +// import BoxIcon from "client/components/box_icon"; +// import { getPath, Path } from "client/paths"; + +type UserEditorProps = { + user: UserLookupDTO; +}; + +export const UserEditor = ({user}: UserEditorProps) => { + return ( + <> +

+ {user.username} +

+ {/*

+ { user.name ? user.name : "Anonymous User" }

+ { user.school ? user.school : "No Affiliation" } +

*/} + + ); +}; diff --git a/src/client/components/user/user_viewer.tsx b/src/client/components/user/user_viewer.tsx new file mode 100644 index 00000000..8581500b --- /dev/null +++ b/src/client/components/user/user_viewer.tsx @@ -0,0 +1,28 @@ +"use client"; +import { UserLookupDTO } from "common/types"; +import BoxIcon from "client/components/box_icon"; +import { getPath, Path } from "client/paths"; + +type UserViewerProps = { + user: UserLookupDTO; + canEdit: boolean; +}; + +export const UserViewer = ({user, canEdit}: UserViewerProps) => { + return ( + <> +

+ {user.username} + { canEdit ? + + + + : "" } +

+

+ { user.name ? user.name : "Anonymous User" }

+ { user.school ? user.school : "No Affiliation" } +

+ + ); +}; diff --git a/src/client/paths.ts b/src/client/paths.ts index a3aad3ee..ba412025 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -25,6 +25,8 @@ export enum Path { AdminProblemSetList = "AdminProblemSetList", AdminContestList = "AdminContestList", AdminFileDownload = "AdminFileDownload", + UserView = "UserView", + UserEdit = "UserEdit", } export type PathArguments = @@ -50,7 +52,9 @@ export type PathArguments = | { kind: Path.AdminTaskList } | { kind: Path.AdminProblemSetList } | { kind: Path.AdminContestList } - | { kind: Path.AdminFileDownload; hash: string; filename: string }; + | { kind: Path.AdminFileDownload; hash: string; filename: string } + | { kind: Path.UserView; username: string } + | { kind: Path.UserEdit; username: string }; export function getPath(args: PathArguments) { switch (args.kind) { @@ -100,6 +104,10 @@ export function getPath(args: PathArguments) { return "/admin/contests"; case Path.AdminFileDownload: return `/admin/files/${args.hash}/${args.filename}`; + case Path.UserView: + return `/users/${args.username}`; + case Path.UserEdit: + return `/users/${args.username}/edit` default: throw new UnreachableError(args); } diff --git a/src/common/types/users.ts b/src/common/types/users.ts index 046b6578..d7214856 100644 --- a/src/common/types/users.ts +++ b/src/common/types/users.ts @@ -18,3 +18,5 @@ export type UserTable = { export const USER_PUBLIC_FIELDS = ["id", "email", "username", "name", "role"] as const; export type User = Selectable; export type UserPublic = Pick; + +export type UserLookupDTO = Pick; \ No newline at end of file diff --git a/src/server/authorization.ts b/src/server/authorization.ts index 706a84be..2410965f 100644 --- a/src/server/authorization.ts +++ b/src/server/authorization.ts @@ -27,3 +27,9 @@ export function canManageContests(session: SessionData | null): boolean { } return true; } + +export function canManageUser(session: SessionData | null, id: string): boolean { + if (session == null) { return false; } + else if (session.user.role == 'admin') { return true; } + else { return (session.user.id == id); } +} From b9e425ddc89757d33ff6e89facee3bdc5574d5e6 Mon Sep 17 00:00:00 2001 From: kodename-kian Date: Thu, 20 Mar 2025 02:03:08 -0700 Subject: [PATCH 2/4] Lint fix --- src/server/authorization.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/server/authorization.ts b/src/server/authorization.ts index 2410965f..d7b90e1f 100644 --- a/src/server/authorization.ts +++ b/src/server/authorization.ts @@ -29,7 +29,11 @@ export function canManageContests(session: SessionData | null): boolean { } export function canManageUser(session: SessionData | null, id: string): boolean { - if (session == null) { return false; } - else if (session.user.role == 'admin') { return true; } - else { return (session.user.id == id); } + if (session == null) { + return false; + } else if (session.user.role == 'admin') { + return true; + } else { + return (session.user.id == id); + } } From 2065617179dd8dceffe353a873336a5890ff9e2d Mon Sep 17 00:00:00 2001 From: kodename-kian Date: Fri, 21 Mar 2025 23:25:16 -0700 Subject: [PATCH 3/4] Edit user functionality --- src/app/api/v1/user/[id]/route.ts | 30 ++++++++++ src/client/components/user/user_editor.tsx | 69 ++++++++++++++++++++-- src/client/components/user/user_viewer.tsx | 2 +- src/client/paths.ts | 6 +- src/common/validation/user_validation.ts | 5 ++ src/server/logic/users.ts | 20 +++++++ 6 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 src/app/api/v1/user/[id]/route.ts diff --git a/src/app/api/v1/user/[id]/route.ts b/src/app/api/v1/user/[id]/route.ts new file mode 100644 index 00000000..9eb01180 --- /dev/null +++ b/src/app/api/v1/user/[id]/route.ts @@ -0,0 +1,30 @@ +import { zUserEdit } from "common/validation/user_validation"; +import { NextRequest, NextResponse } from "next/server"; +import { canManageUser } from "server/authorization"; +import { updateUser } from "server/logic/users"; +import { getSession } from "server/sessions"; +import { NextContext } from "types/nextjs"; + +type RouteParams = { + id: string, +} + +export async function PUT(request: NextRequest, context: NextContext) { + const session = await getSession(request); + if (!canManageUser(session, context.params.id)) { + return NextResponse.json({}, { status: 403 }); + } + + const data = await request.json(); + const parsed = zUserEdit.safeParse(data); + if (parsed.success) { + const result = await updateUser(context.params.id, parsed.data); + if (result.numUpdatedRows) { + return NextResponse.json({}, { status: 200 }); + } else { + return NextResponse.json({}, { status: 404 }); + } + } else { + return NextResponse.json({ error: parsed.error.format() }, { status: 400}); + } +} \ No newline at end of file diff --git a/src/client/components/user/user_editor.tsx b/src/client/components/user/user_editor.tsx index 39101c91..112b676d 100644 --- a/src/client/components/user/user_editor.tsx +++ b/src/client/components/user/user_editor.tsx @@ -1,22 +1,79 @@ "use client"; import { UserLookupDTO } from "common/types"; -// import BoxIcon from "client/components/box_icon"; -// import { getPath, Path } from "client/paths"; +import { FormButton, FormError, FormInput, FormLabel } from '../form'; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { zUserEdit } from "common/validation/user_validation"; +import { useRouter } from "next/navigation"; +import { APIPath, getAPIPath, getPath, Path } from 'client/paths'; +import http from "client/http"; +import { toast } from "react-toastify"; + +type UserEditForm = { + name: string; + school: string; +}; type UserEditorProps = { user: UserLookupDTO; }; export const UserEditor = ({user}: UserEditorProps) => { + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(zUserEdit), + }); + + const onSubmit = async (data: UserEditForm) => { + try { + const response = await http.put(getAPIPath({ kind: APIPath.UserEdit, id: user.id }), data); + + if (response.status != 200) { + toast.error(`Error. Status code: ${response.status}`); + } + + router.refresh(); + router.push(getPath({ kind: Path.UserView, username: user.username })); + + } catch (e) { + toast.error('An unexpected error occured. Please try again.'); + } + }; + + const onCancel = async () => { + router.refresh(); + router.push(getPath({kind: Path.UserView, username: user.username})) + }; + return ( <>

{user.username}

- {/*

- { user.name ? user.name : "Anonymous User" }

- { user.school ? user.school : "No Affiliation" } -

*/} +
+ Name + + + + School + + + +
+ + Save + + + + Cancel + +
+
); }; diff --git a/src/client/components/user/user_viewer.tsx b/src/client/components/user/user_viewer.tsx index 8581500b..8676a271 100644 --- a/src/client/components/user/user_viewer.tsx +++ b/src/client/components/user/user_viewer.tsx @@ -19,7 +19,7 @@ export const UserViewer = ({user, canEdit}: UserViewerProps) => { : "" }

-

+

{ user.name ? user.name : "Anonymous User" }

{ user.school ? user.school : "No Affiliation" }

diff --git a/src/client/paths.ts b/src/client/paths.ts index ba412025..e6b55137 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -134,6 +134,7 @@ export enum APIPath { ProblemSetUpdate = "ProblemSetUpdate", ContestCreate = "ContestCreate", ContestUpdate = "ContestUpdate", + UserEdit = "UserEdit", } export type APIPathArguments = @@ -155,7 +156,8 @@ export type APIPathArguments = | { kind: APIPath.ProblemSetCreate } | { kind: APIPath.ProblemSetUpdate; id: string } | { kind: APIPath.ContestCreate } - | { kind: APIPath.ContestUpdate; id: string }; + | { kind: APIPath.ContestUpdate; id: string } + | { kind: APIPath.UserEdit; id: string }; export function getAPIPath(args: APIPathArguments) { switch (args.kind) { @@ -203,6 +205,8 @@ export function getAPIPath(args: APIPathArguments) { return "/api/v1/tasks/files"; case APIPath.FileHashes: return "/api/v1/tasks/files/hashes"; + case APIPath.UserEdit: + return `/api/v1/user/${args.id}`; default: throw new UnreachableError(args); } diff --git a/src/common/validation/user_validation.ts b/src/common/validation/user_validation.ts index 8a240fb2..6467d3bd 100644 --- a/src/common/validation/user_validation.ts +++ b/src/common/validation/user_validation.ts @@ -70,4 +70,9 @@ export const zUserResetPasswordServer = z.object({ password: z.string().min(8), }); +export const zUserEdit = z.object({ + name: z.string(), + school: z.string(), +}); + export type UserDTO = z.infer; diff --git a/src/server/logic/users.ts b/src/server/logic/users.ts index 463a6ee2..34bb88f4 100644 --- a/src/server/logic/users.ts +++ b/src/server/logic/users.ts @@ -14,6 +14,11 @@ type CreateUserData = { role?: "admin" | "user"; }; +type UpdateUserData = { + name: string; + school: string; +} + export async function createUser( trx: Transaction, data: CreateUserData @@ -95,3 +100,18 @@ function generatePasswordResetToken(): string { export function hashPassword(password: string): string { return hashSync(password, 10); } + +export async function updateUser(id: string, data: UpdateUserData) { + return await db.transaction().execute(async (trx) => { + const user = await trx + .updateTable("users") + .where("id", "=", id) + .set({ + name: (data.name == "" ? null : data.name), + school: (data.school == "" ? null: data.school) + }) + .executeTakeFirst(); + + return user; + }); +} From 838f21ee3758b7a220bb6f9fb42dbd34346e0200 Mon Sep 17 00:00:00 2001 From: kodename-kian Date: Sat, 12 Apr 2025 01:00:23 -0700 Subject: [PATCH 4/4] Typo fix --- src/app/api/v1/user/[id]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/v1/user/[id]/route.ts b/src/app/api/v1/user/[id]/route.ts index 9eb01180..15698103 100644 --- a/src/app/api/v1/user/[id]/route.ts +++ b/src/app/api/v1/user/[id]/route.ts @@ -25,6 +25,6 @@ export async function PUT(request: NextRequest, context: NextContext