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