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
30 changes: 30 additions & 0 deletions src/app/api/v1/user/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<RouteParams>) {
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 });
}
}
53 changes: 53 additions & 0 deletions src/app/users/[username]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -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<UserLookupDTO | null> {
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 <ForbiddenPage/>; }

return (
<DefaultLayout>
<>
<UserEditor user={user} />
</>
</DefaultLayout>
);
}

export default Page;
48 changes: 48 additions & 0 deletions src/app/users/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<UserLookupDTO | null> {
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 (
<DefaultLayout>
<UserViewer user={user} canEdit={canEdit} />
</DefaultLayout>
);
}

export default Page;
8 changes: 5 additions & 3 deletions src/client/components/navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,18 @@ export const NavbarAccount = memo(() => {
} else if (session.user.role === "admin") {
return (
<>
<NavbarLink href={getPath({ kind: Path.AdminHome })} className="lg:ml-auto">
{session.user.name || "Anonymous"}
<NavbarLink href={getPath({ kind: Path.AdminHome })} className="lg:ml-auto mr-4">
{session.user.username}
</NavbarLink>
<NavbarLink href={getPath({ kind: Path.AccountLogout })}>Logout</NavbarLink>
</>
);
} else {
return (
<>
<div className="text-2xl px-1 lg:ml-auto">{session.user.name || "Anonymous"}</div>
<NavbarLink href={getPath({ kind: Path.UserView, username: session.user.username })} className="lg:ml-auto mr-4">
{session.user.username}
</NavbarLink>
<NavbarLink href={getPath({ kind: Path.AccountLogout })}>Logout</NavbarLink>
</>
);
Expand Down
79 changes: 79 additions & 0 deletions src/client/components/user/user_editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";
import { UserLookupDTO } from "common/types";
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<UserEditForm>({
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 (
<>
<p className="font-bold text-6xl mt-8 mb-2 text-blue-450">
{user.username}
</p>
<div className="mt-4 lg:w-1/2">
<FormLabel> Name </FormLabel>
<FormInput type='text' defaultValue={user.name ? user.name : ""} {...register('name')} />
<FormError error={errors.name} className='mb-4'/>

<FormLabel> School </FormLabel>
<FormInput type='text' defaultValue={user.school ? user.school : ""} {...register('school')} />
<FormError error={errors.school} className='mb-4'/>

<div className="mt-6 flex-row">
<FormButton onClick={handleSubmit(onSubmit)} disabled={isSubmitting} className="mr-4">
Save
</FormButton>

<FormButton onClick={handleSubmit(onCancel)} disabled={isSubmitting} className='bg-gray-400 hover:bg-gray-500'>
Cancel
</FormButton>
</div>
</div>
</>
);
};
28 changes: 28 additions & 0 deletions src/client/components/user/user_viewer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p className="font-bold text-6xl mt-8 mb-2 text-blue-450">
{user.username}
{ canEdit ?
<a href={getPath({kind: Path.UserEdit, username: user.username })}>
<BoxIcon name="bxs-edit" className="bx-md text-blue-400 hover:text-blue-450 ml-4 align-top" />
</a>
: "" }
</p>
<p className="font-sans font-bold text-xl text-gray-500 mt-4">
{ user.name ? user.name : "Anonymous User" } <br></br>
{ user.school ? user.school : "No Affiliation" }
</p>
</>
);
};
16 changes: 14 additions & 2 deletions src/client/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export enum Path {
AdminProblemSetList = "AdminProblemSetList",
AdminContestList = "AdminContestList",
AdminFileDownload = "AdminFileDownload",
UserView = "UserView",
UserEdit = "UserEdit",
}

export type PathArguments =
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -126,6 +134,7 @@ export enum APIPath {
ProblemSetUpdate = "ProblemSetUpdate",
ContestCreate = "ContestCreate",
ContestUpdate = "ContestUpdate",
UserEdit = "UserEdit",
}

export type APIPathArguments =
Expand All @@ -147,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) {
Expand Down Expand Up @@ -195,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);
}
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export type UserTable = {
export const USER_PUBLIC_FIELDS = ["id", "email", "username", "name", "role"] as const;
export type User = Selectable<UserTable>;
export type UserPublic = Pick<User, (typeof USER_PUBLIC_FIELDS)[number]>;

export type UserLookupDTO = Pick<User, "id" | "username" | "name" | "school" | "role">;
5 changes: 5 additions & 0 deletions src/common/validation/user_validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof zUserRegister>;
10 changes: 10 additions & 0 deletions src/server/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ 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);
}
}
20 changes: 20 additions & 0 deletions src/server/logic/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type CreateUserData = {
role?: "admin" | "user";
};

type UpdateUserData = {
name: string;
school: string;
}

export async function createUser(
trx: Transaction<Models>,
data: CreateUserData
Expand Down Expand Up @@ -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;
});
}