Skip to content

Commit

Permalink
feat: add settings page
Browse files Browse the repository at this point in the history
  • Loading branch information
seelengxd committed Sep 25, 2024
1 parent ac13795 commit 7f2fcca
Show file tree
Hide file tree
Showing 11 changed files with 1,088 additions and 788 deletions.
6 changes: 5 additions & 1 deletion backend/src/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from src.common.dependencies import get_session
from .schemas import (
PasswordResetCompleteData,
PasswordResetMoreCompleteData,
PasswordResetRequestData,
SignUpData,
UserPublic,
Expand All @@ -27,6 +28,7 @@
authenticate_user,
get_current_user,
get_password_hash,
verify_password,
)
from .models import AccountType, PasswordReset, User

Expand Down Expand Up @@ -189,9 +191,11 @@ def complete_password_reset(
@router.put("/change-password")
def change_password(
user: Annotated[User, Depends(get_current_user)],
data: PasswordResetCompleteData,
data: PasswordResetMoreCompleteData,
session=Depends(get_session),
):
if not verify_password(data.old_password, user.hashed_password):
raise HTTPException(HTTPStatus.UNAUTHORIZED)
user.hashed_password = get_password_hash(data.password)
session.add(user)
session.commit()
4 changes: 4 additions & 0 deletions backend/src/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ def check_passwords_match(self):
if pw1 is not None and pw2 is not None and pw1 != pw2:
raise ValueError("passwords do not match")
return self


class PasswordResetMoreCompleteData(PasswordResetCompleteData):
old_password: str
74 changes: 74 additions & 0 deletions frontend/app/(authenticated)/user/profile/category-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";

import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getCategories } from "@/queries/category";
import { useUpdateProfile } from "@/queries/user";
import { getCategoryFor } from "@/types/categories";

interface Props {
initialCategoryIds: number[];
}

export default function CategoryForm({ initialCategoryIds }: Props) {
const { data: categories, isLoading } = useQuery(getCategories());
const [categoryIds, setCategoryIds] = useState<number[]>(initialCategoryIds);

const toggleCategory = (id: number) => {
if (!categoryIds.includes(id)) {
setCategoryIds([...categoryIds, id]);
} else {
setCategoryIds(categoryIds.filter((item) => item !== id));
}
};

const updateProfileMutation = useUpdateProfile();

const handleSubmit = () => {
updateProfileMutation.mutate({ categoryIds });
};

if (isLoading) {
return (
<div className="flex justify-center items-center w-full">
<LoadingSpinner className="w-24 h-24" />
</div>
);
}

return (
<div className="flex flex-col w-auto mx-4 md:mx-16 xl:mx-56 pb-4">
<div className="">
<h2 className="text-2xl 2xl:text-3xl font-bold">Categories</h2>
<p className="text-sm text-muted-foreground mt-2">
Select the General Paper categories you are interested in.
</p>
<div className="flex flex-wrap gap-4 justify-center max-w-2xl">
{categories!.map((category) => {
const isActive = categoryIds.includes(category.id);
return (
<div
className={clsx(
"font-medium p-3 px-4 rounded-3xl cursor-pointer shadow-md mt-4",
{
"bg-emerald-600 text-white": isActive,
"bg-slate-100": !isActive,
},
)}
key={category.id}
onClick={() => toggleCategory(category.id)}
>
{getCategoryFor(category.name)}
</div>
);
})}
</div>
<Button onClick={handleSubmit}>Save</Button>
</div>
</div>
);
}
179 changes: 179 additions & 0 deletions frontend/app/(authenticated)/user/profile/change-password-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, CircleAlert } from "lucide-react";
import { z } from "zod";

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Box } from "@/components/ui/box";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useChangePassword } from "@/queries/user";

const changePasswordCompleteFormSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
oldPassword: z.string(),
})
.refine(({ password, confirmPassword }) => password === confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});

type ChangePasswordRequestForm = z.infer<
typeof changePasswordCompleteFormSchema
>;

const changePasswordFormDefault = {
password: "",
confirmPassword: "",
oldPassword: "",
};

export default function ChangePasswordForm() {
const [isError, setIsError] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);
const [isPasswordVisible, setIsPasswordVisible] = useState<boolean>(false);

const form = useForm<ChangePasswordRequestForm>({
resolver: zodResolver(changePasswordCompleteFormSchema),
defaultValues: changePasswordFormDefault,
});
const changePasswordMutation = useChangePassword();

const onSubmit: SubmitHandler<ChangePasswordRequestForm> = (data) => {
changePasswordMutation.mutate(data, {
onSuccess: async (data) => {
if (data.error) {
setIsError(true);
setSuccess(false);
} else {
setIsError(false);
setSuccess(true);
}
},
});
};

return (
<div className="flex flex-col w-auto mx-4 md:mx-16 xl:mx-56 py-8">
<div className="">
<h2 className="text-2xl 2xl:text-3xl font-bold">Change password</h2>
<Box className="space-y-6 mt-4">
{isError && (
<Alert variant="destructive">
<CircleAlert className="h-5 w-5" />
<AlertDescription>Wrong password. Try again?</AlertDescription>
</Alert>
)}
{success && (
<Alert variant="teal">
<Check className="h-5 w-5" />
<AlertDescription>
Successfully changed password.
</AlertDescription>
</Alert>
)}
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-4">
<Box className="flex flex-col space-y-2.5">
<FormField
control={form.control}
name={"oldPassword"}
render={({ field }) => (
<FormItem>
<FormLabel className="!text-current">
Old password
</FormLabel>
<FormControl>
<Input
{...field}
type={isPasswordVisible ? "text" : "password"}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Box>
</div>
<div className="space-y-4">
<Box className="flex flex-col space-y-2.5">
<FormField
control={form.control}
name={"password"}
render={({ field }) => (
<FormItem>
<FormLabel className="!text-current">
New Password
</FormLabel>
<FormControl>
<Input
{...field}
type={isPasswordVisible ? "text" : "password"}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Box>
</div>
<div className="space-y-4">
<Box className="flex flex-col space-y-2.5">
<FormField
control={form.control}
name={"confirmPassword"}
render={({ field }) => (
<FormItem>
<FormLabel className="!text-current">
Confirm Password
</FormLabel>
<FormControl>
<Input
{...field}
type={isPasswordVisible ? "text" : "password"}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Box>
</div>
<div className="flex items-center gap-x-2">
<Checkbox
checked={isPasswordVisible}
id="password-visibility"
onCheckedChange={(checkedState) =>
setIsPasswordVisible(
checkedState === "indeterminate" ? false : checkedState,
)
}
/>
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="password-visibility"
>
Show password
</label>
</div>
<Button type="submit">Submit</Button>
</form>
</Form>
</Box>
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions frontend/app/(authenticated)/user/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useUserStore } from "@/store/user/user-store-provider";

import CategoryForm from "./category-form";
import ChangePasswordForm from "./change-password-form";

export default function Profile() {
const user = useUserStore((store) => store.user);

return (
user && (
<div className="flex flex-col w-full py-8">
<div className="flex flex-col mb-8 gap-y-2 mx-8 md:mx-16 xl:mx-56 pt-8">
<h1 className="text-3xl 2xl:text-4xl font-bold">Settings</h1>
</div>

<div className="flex flex-col gap-8">
<CategoryForm
initialCategoryIds={user.categories.map((category) => category.id)}
/>

<ChangePasswordForm />
</div>
</div>
)
);
}
6 changes: 3 additions & 3 deletions frontend/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
export { createClient } from "./core/";
export { createClient } from './core/';
export type {
Client,
Config,
Options,
RequestOptions,
RequestOptionsBase,
RequestResult,
} from "./core/types";
} from './core/types';
export {
createConfig,
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "./core/utils";
} from './core/utils';
Loading

0 comments on commit 7f2fcca

Please sign in to comment.