Browse Source

🛂 Migrate to Chakra UI v3 (#1496)

Co-authored-by: github-actions <github-actions@github.com>
pull/13907/head
Alejandra 6 months ago
committed by GitHub
parent
commit
55df823739
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      frontend/openapi-ts.config.ts
  2. 8793
      frontend/package-lock.json
  3. 10
      frontend/package.json
  4. 292
      frontend/src/components/Admin/AddUser.tsx
  5. 103
      frontend/src/components/Admin/DeleteUser.tsx
  6. 287
      frontend/src/components/Admin/EditUser.tsx
  7. 75
      frontend/src/components/Common/ActionsMenu.tsx
  8. 113
      frontend/src/components/Common/DeleteAlert.tsx
  9. 27
      frontend/src/components/Common/ItemActionsMenu.tsx
  10. 54
      frontend/src/components/Common/Navbar.tsx
  11. 68
      frontend/src/components/Common/NotFound.tsx
  12. 36
      frontend/src/components/Common/PaginationFooter.tsx
  13. 123
      frontend/src/components/Common/Sidebar.tsx
  14. 53
      frontend/src/components/Common/SidebarItems.tsx
  15. 28
      frontend/src/components/Common/UserActionsMenu.tsx
  16. 77
      frontend/src/components/Common/UserMenu.tsx
  17. 167
      frontend/src/components/Items/AddItem.tsx
  18. 103
      frontend/src/components/Items/DeleteItem.tsx
  19. 192
      frontend/src/components/Items/EditItem.tsx
  20. 34
      frontend/src/components/Pending/PendingItems.tsx
  21. 38
      frontend/src/components/Pending/PendingUsers.tsx
  22. 34
      frontend/src/components/UserSettings/Appearance.tsx
  23. 88
      frontend/src/components/UserSettings/ChangePassword.tsx
  24. 38
      frontend/src/components/UserSettings/DeleteAccount.tsx
  25. 124
      frontend/src/components/UserSettings/DeleteConfirmation.tsx
  26. 57
      frontend/src/components/UserSettings/UserInformation.tsx
  27. 40
      frontend/src/components/ui/button.tsx
  28. 25
      frontend/src/components/ui/checkbox.tsx
  29. 17
      frontend/src/components/ui/close-button.tsx
  30. 107
      frontend/src/components/ui/color-mode.tsx
  31. 62
      frontend/src/components/ui/dialog.tsx
  32. 52
      frontend/src/components/ui/drawer.tsx
  33. 33
      frontend/src/components/ui/field.tsx
  34. 53
      frontend/src/components/ui/input-group.tsx
  35. 12
      frontend/src/components/ui/link-button.tsx
  36. 112
      frontend/src/components/ui/menu.tsx
  37. 211
      frontend/src/components/ui/pagination.tsx
  38. 162
      frontend/src/components/ui/password-input.tsx
  39. 18
      frontend/src/components/ui/provider.tsx
  40. 24
      frontend/src/components/ui/radio.tsx
  41. 47
      frontend/src/components/ui/skeleton.tsx
  42. 43
      frontend/src/components/ui/toaster.tsx
  43. 32
      frontend/src/hooks/useAuth.ts
  44. 34
      frontend/src/hooks/useCustomToast.ts
  45. 10
      frontend/src/main.tsx
  46. 26
      frontend/src/routes/_layout.tsx
  47. 192
      frontend/src/routes/_layout/admin.tsx
  48. 181
      frontend/src/routes/_layout/items.tsx
  49. 55
      frontend/src/routes/_layout/settings.tsx
  50. 95
      frontend/src/routes/login.tsx
  51. 55
      frontend/src/routes/recover-password.tsx
  52. 60
      frontend/src/routes/reset-password.tsx
  53. 130
      frontend/src/routes/signup.tsx
  54. 74
      frontend/src/theme.tsx
  55. 21
      frontend/src/theme/button.recipe.ts
  56. 6
      frontend/src/utils.ts
  57. 12
      frontend/tests/reset-password.spec.ts
  58. 4
      frontend/tests/sign-up.spec.ts
  59. 115
      frontend/tests/user-settings.spec.ts
  60. 5
      frontend/tests/utils/user.ts

2
frontend/openapi-ts.config.ts

@ -15,7 +15,7 @@ export default defineConfig({
// @ts-ignore
let name: string = operation.name
// @ts-ignore
let service: string = operation.service
const service: string = operation.service
if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
name = name.slice(service.length)

8793
frontend/package-lock.json

File diff suppressed because it is too large

10
frontend/package.json

@ -11,21 +11,19 @@
"generate-client": "openapi-ts"
},
"dependencies": {
"@chakra-ui/icons": "2.1.1",
"@chakra-ui/react": "2.8.2",
"@emotion/react": "11.11.3",
"@emotion/styled": "11.11.0",
"@chakra-ui/react": "^3.8.0",
"@emotion/react": "^11.14.0",
"@tanstack/react-query": "^5.28.14",
"@tanstack/react-query-devtools": "^5.28.14",
"@tanstack/react-router": "1.19.1",
"axios": "1.7.4",
"form-data": "4.0.0",
"framer-motion": "10.16.16",
"next-themes": "^0.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
"react-hook-form": "7.49.3",
"react-icons": "5.0.1"
"react-icons": "^5.4.0"
},
"devDependencies": {
"@biomejs/biome": "1.6.1",

292
frontend/src/components/Admin/AddUser.tsx

@ -1,45 +1,48 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import {
Button,
Checkbox,
DialogActionTrigger,
DialogTitle,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import { useState } from "react"
import { FaPlus } from "react-icons/fa"
import { type UserCreate, UsersService } from "../../client"
import type { ApiError } from "../../client/core/ApiError"
import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils"
interface AddUserProps {
isOpen: boolean
onClose: () => void
}
import { Checkbox } from "../ui/checkbox"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface UserCreateForm extends UserCreate {
confirm_password: string
}
const AddUser = ({ isOpen, onClose }: AddUserProps) => {
const AddUser = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const {
control,
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
formState: { errors, isValid, isSubmitting },
} = useForm<UserCreateForm>({
mode: "onBlur",
criteriaMode: "all",
@ -57,12 +60,12 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
mutationFn: (data: UserCreate) =>
UsersService.createUser({ requestBody: data }),
onSuccess: () => {
showToast("Success!", "User created successfully.", "success")
showSuccessToast("User created successfully.")
reset()
onClose()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
@ -74,108 +77,153 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: "sm", md: "md" }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Add User</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isRequired isInvalid={!!errors.email}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.full_name}>
<FormLabel htmlFor="name">Full name</FormLabel>
<Input
id="name"
{...register("full_name")}
placeholder="Full name"
type="text"
/>
{errors.full_name && (
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button value="add-user" my={4}>
<FaPlus fontSize="16px" />
Add User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
Fill in the form below to add a new user to the system.
</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</Field>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
id="name"
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<Field
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
id="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<Field
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
id="confirm_password"
{...register("confirm_password", {
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
)}
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl
mt={4}
isRequired
isInvalid={!!errors.confirm_password}
>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register("confirm_password", {
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
<Controller
control={control}
name="is_active"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
</Checkbox>
</Field>
)}
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex mt={4}>
<FormControl>
<Checkbox {...register("is_superuser")} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl>
<Checkbox {...register("is_active")} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex>
</ModalBody>
<ModalFooter gap={3}>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
</DialogBody>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
</DialogFooter>
</form>
<DialogCloseTrigger />
</DialogContent>
</DialogRoot>
)
}

103
frontend/src/components/Admin/DeleteUser.tsx

@ -0,0 +1,103 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { UsersService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../../components/ui/dialog"
import useCustomToast from "../../hooks/useCustomToast"
const DeleteUser = ({ id }: { id: string }) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteUser = async (id: string) => {
await UsersService.deleteUser({ userId: id })
}
const mutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
showSuccessToast("The user was deleted successfully")
setIsOpen(false)
},
onError: () => {
showErrorToast("An error occurred while deleting the user")
},
onSettled: () => {
queryClient.invalidateQueries()
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
role="alertdialog"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
All items associated with this user will also be{" "}
<strong>permanently deleted.</strong> Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
type="submit"
loading={isSubmitting}
>
Delete
</Button>
</DialogFooter>
<DialogCloseTrigger />
</form>
</DialogContent>
</DialogRoot>
)
}
export default DeleteUser

287
frontend/src/components/Admin/EditUser.tsx

@ -1,51 +1,52 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import {
Button,
Checkbox,
DialogActionTrigger,
DialogRoot,
DialogTrigger,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import {
type ApiError,
type UserPublic,
type UserUpdate,
UsersService,
} from "../../client"
import { useState } from "react"
import { FaExchangeAlt } from "react-icons/fa"
import { type UserPublic, type UserUpdate, UsersService } from "../../client"
import type { ApiError } from "../../client/core/ApiError"
import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils"
import { Checkbox } from "../ui/checkbox"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface EditUserProps {
user: UserPublic
isOpen: boolean
onClose: () => void
}
interface UserUpdateForm extends UserUpdate {
confirm_password: string
confirm_password?: string
}
const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
const EditUser = ({ user }: EditUserProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const {
control,
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting, isDirty },
formState: { errors, isSubmitting },
} = useForm<UserUpdateForm>({
mode: "onBlur",
criteriaMode: "all",
@ -56,11 +57,12 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
mutationFn: (data: UserUpdateForm) =>
UsersService.updateUser({ userId: user.id, requestBody: data }),
onSuccess: () => {
showToast("Success!", "User updated successfully.", "success")
onClose()
showSuccessToast("User updated successfully.")
reset()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
@ -74,106 +76,145 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
mutation.mutate(data)
}
const onCancel = () => {
reset()
onClose()
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: "sm", md: "md" }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Edit User</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isInvalid={!!errors.email}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="name">Full name</FormLabel>
<Input id="name" {...register("full_name")} type="text" />
</FormControl>
<FormControl mt={4} isInvalid={!!errors.password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<FaExchangeAlt fontSize="16px" />
Edit User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the user details below.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</Field>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
id="name"
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<Field
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
id="password"
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<Field
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
id="confirm_password"
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
)}
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
<Controller
control={control}
name="is_active"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
</Checkbox>
</Field>
)}
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex>
<FormControl mt={4}>
<Checkbox {...register("is_superuser")} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl mt={4}>
<Checkbox {...register("is_active")} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex>
</ModalBody>
</DialogBody>
<ModalFooter gap={3}>
<Button
variant="primary"
type="submit"
isLoading={isSubmitting}
isDisabled={!isDirty}
>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
</Button>
<Button onClick={onCancel}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
</DialogFooter>
<DialogCloseTrigger />
</form>
</DialogContent>
</DialogRoot>
)
}

75
frontend/src/components/Common/ActionsMenu.tsx

@ -1,75 +0,0 @@
import {
Button,
Menu,
MenuButton,
MenuItem,
MenuList,
useDisclosure,
} from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { FiEdit, FiTrash } from "react-icons/fi"
import type { ItemPublic, UserPublic } from "../../client"
import EditUser from "../Admin/EditUser"
import EditItem from "../Items/EditItem"
import Delete from "./DeleteAlert"
interface ActionsMenuProps {
type: string
value: ItemPublic | UserPublic
disabled?: boolean
}
const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
const editUserModal = useDisclosure()
const deleteModal = useDisclosure()
return (
<>
<Menu>
<MenuButton
isDisabled={disabled}
as={Button}
rightIcon={<BsThreeDotsVertical />}
variant="unstyled"
/>
<MenuList>
<MenuItem
onClick={editUserModal.onOpen}
icon={<FiEdit fontSize="16px" />}
>
Edit {type}
</MenuItem>
<MenuItem
onClick={deleteModal.onOpen}
icon={<FiTrash fontSize="16px" />}
color="ui.danger"
>
Delete {type}
</MenuItem>
</MenuList>
{type === "User" ? (
<EditUser
user={value as UserPublic}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
) : (
<EditItem
item={value as ItemPublic}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
)}
<Delete
type={type}
id={value.id}
isOpen={deleteModal.isOpen}
onClose={deleteModal.onClose}
/>
</Menu>
</>
)
}
export default ActionsMenu

113
frontend/src/components/Common/DeleteAlert.tsx

@ -1,113 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import React from "react"
import { useForm } from "react-hook-form"
import { ItemsService, UsersService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast"
interface DeleteProps {
type: string
id: string
isOpen: boolean
onClose: () => void
}
const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteEntity = async (id: string) => {
if (type === "Item") {
await ItemsService.deleteItem({ id: id })
} else if (type === "User") {
await UsersService.deleteUser({ userId: id })
} else {
throw new Error(`Unexpected type: ${type}`)
}
}
const mutation = useMutation({
mutationFn: deleteEntity,
onSuccess: () => {
showToast(
"Success",
`The ${type.toLowerCase()} was deleted successfully.`,
"success",
)
onClose()
},
onError: () => {
showToast(
"An error occurred.",
`An error occurred while deleting the ${type.toLowerCase()}.`,
"error",
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: [type === "Item" ? "items" : "users"],
})
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<>
<AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef}
size={{ base: "sm", md: "md" }}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
<AlertDialogBody>
{type === "User" && (
<span>
All items associated with this user will also be{" "}
<strong>permanently deleted. </strong>
</span>
)}
Are you sure? You will not be able to undo this action.
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<Button variant="danger" type="submit" isLoading={isSubmitting}>
Delete
</Button>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isSubmitting}
>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
)
}
export default Delete

27
frontend/src/components/Common/ItemActionsMenu.tsx

@ -0,0 +1,27 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
import type { ItemPublic } from "../../client"
import DeleteItem from "../Items/DeleteItem"
import EditItem from "../Items/EditItem"
interface ItemActionsMenuProps {
item: ItemPublic
}
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit">
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditItem item={item} />
<DeleteItem id={item.id} />
</MenuContent>
</MenuRoot>
)
}

54
frontend/src/components/Common/Navbar.tsx

@ -1,38 +1,30 @@
import type { ComponentType, ElementType } from "react"
import { Flex, Image, useBreakpointValue } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
import Logo from "/assets/images/fastapi-logo.svg"
import UserMenu from "./UserMenu"
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
import { FaPlus } from "react-icons/fa"
function Navbar() {
const display = useBreakpointValue({ base: "none", md: "flex" })
interface NavbarProps {
type: string
addModalAs: ComponentType | ElementType
}
const Navbar = ({ type, addModalAs }: NavbarProps) => {
const addModal = useDisclosure()
const AddModal = addModalAs
return (
<>
<Flex py={8} gap={4}>
{/* TODO: Complete search functionality */}
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
<InputLeftElement pointerEvents='none'>
<Icon as={FaSearch} color='ui.dim' />
</InputLeftElement>
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
</InputGroup> */}
<Button
variant="primary"
gap={1}
fontSize={{ base: "sm", md: "inherit" }}
onClick={addModal.onOpen}
>
<Icon as={FaPlus} /> Add {type}
</Button>
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
<Flex
display={display}
justify="space-between"
position="sticky"
color="white"
align="center"
bg="bg.muted"
w="100%"
top={0}
p={4}
>
<Link to="/">
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" px={2} />
</Link>
<Flex gap={2} alignItems="center">
<UserMenu />
</Flex>
</>
</Flex>
)
}

68
frontend/src/components/Common/NotFound.tsx

@ -1,39 +1,55 @@
import { Button, Container, Text } from "@chakra-ui/react"
import { Button, Center, Flex, Text } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
const NotFound = () => {
return (
<>
<Container
h="100vh"
alignItems="stretch"
justifyContent="center"
textAlign="center"
maxW="sm"
centerContent
<Flex
height="100vh"
align="center"
justify="center"
flexDir="column"
data-testid="not-found"
p={4}
>
<Flex alignItems="center" zIndex={1}>
<Flex flexDir="column" ml={4} align="center" justify="center" p={4}>
<Text
fontSize={{ base: "6xl", md: "8xl" }}
fontWeight="bold"
lineHeight="1"
mb={4}
>
404
</Text>
<Text fontSize="2xl" fontWeight="bold" mb={2}>
Oops!
</Text>
</Flex>
</Flex>
<Text
fontSize="8xl"
color="ui.main"
fontWeight="bold"
lineHeight="1"
fontSize="lg"
color="gray.600"
mb={4}
textAlign="center"
zIndex={1}
>
404
The page you are looking for was not found.
</Text>
<Text fontSize="md">Oops!</Text>
<Text fontSize="md">Page not found.</Text>
<Button
as={Link}
to="/"
color="ui.main"
borderColor="ui.main"
variant="outline"
mt={4}
>
Go back
</Button>
</Container>
<Center zIndex={1}>
<Link to="/">
<Button
variant="solid"
colorScheme="teal"
mt={4}
alignSelf="center"
>
Go Back
</Button>
</Link>
</Center>
</Flex>
</>
)
}

36
frontend/src/components/Common/PaginationFooter.tsx

@ -1,36 +0,0 @@
import { Button, Flex } from "@chakra-ui/react"
type PaginationFooterProps = {
hasNextPage?: boolean
hasPreviousPage?: boolean
onChangePage: (newPage: number) => void
page: number
}
export function PaginationFooter({
hasNextPage,
hasPreviousPage,
onChangePage,
page,
}: PaginationFooterProps) {
return (
<Flex
gap={4}
alignItems="center"
mt={4}
direction="row"
justifyContent="flex-end"
>
<Button
onClick={() => onChangePage(page - 1)}
isDisabled={!hasPreviousPage || page <= 1}
>
Previous
</Button>
<span>Page {page}</span>
<Button isDisabled={!hasNextPage} onClick={() => onChangePage(page + 1)}>
Next
</Button>
</Flex>
)
}

123
frontend/src/components/Common/Sidebar.tsx

@ -1,33 +1,26 @@
import {
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerOverlay,
Flex,
IconButton,
Image,
Text,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react"
import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { FiLogOut, FiMenu } from "react-icons/fi"
import { useState } from "react"
import { FaBars } from "react-icons/fa"
import Logo from "/assets/images/fastapi-logo.svg"
import { FiLogOut } from "react-icons/fi"
import type { UserPublic } from "../../client"
import useAuth from "../../hooks/useAuth"
import {
DrawerBackdrop,
DrawerBody,
DrawerCloseTrigger,
DrawerContent,
DrawerRoot,
DrawerTrigger,
} from "../ui/drawer"
import SidebarItems from "./SidebarItems"
const Sidebar = () => {
const queryClient = useQueryClient()
const bgColor = useColorModeValue("ui.light", "ui.dark")
const textColor = useColorModeValue("ui.dark", "ui.light")
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { isOpen, onOpen, onClose } = useDisclosure()
const { logout } = useAuth()
const [open, setOpen] = useState(false)
const handleLogout = async () => {
logout()
@ -36,78 +29,68 @@ const Sidebar = () => {
return (
<>
{/* Mobile */}
<IconButton
onClick={onOpen}
display={{ base: "flex", md: "none" }}
aria-label="Open Menu"
position="absolute"
fontSize="20px"
m={4}
icon={<FiMenu />}
/>
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent maxW="250px">
<DrawerCloseButton />
<DrawerBody py={8}>
<DrawerRoot
placement="start"
open={open}
onOpenChange={(e) => setOpen(e.open)}
>
<DrawerBackdrop />
<DrawerTrigger asChild>
<IconButton
variant="ghost"
color="inherit"
display={{ base: "flex", md: "none" }}
aria-label="Open Menu"
position="absolute"
zIndex="100"
m={4}
>
<FaBars />
</IconButton>
</DrawerTrigger>
<DrawerContent maxW="280px">
<DrawerCloseTrigger />
<DrawerBody>
<Flex flexDir="column" justify="space-between">
<Box>
<Image src={Logo} alt="logo" p={6} />
<SidebarItems onClose={onClose} />
<SidebarItems />
<Flex
as="button"
onClick={handleLogout}
p={2}
color="ui.danger"
fontWeight="bold"
alignItems="center"
gap={4}
px={4}
py={2}
>
<FiLogOut />
<Text ml={2}>Log out</Text>
<Text>Log Out</Text>
</Flex>
</Box>
{currentUser?.email && (
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
<Text fontSize="sm" p={2}>
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
</DrawerBody>
<DrawerCloseTrigger />
</DrawerContent>
</Drawer>
</DrawerRoot>
{/* Desktop */}
<Box
bg={bgColor}
p={3}
h="100vh"
position="sticky"
top="0"
display={{ base: "none", md: "flex" }}
position="sticky"
bg="bg.subtle"
top={0}
minW="280px"
h="100vh"
p={4}
>
<Flex
flexDir="column"
justify="space-between"
bg={secBgColor}
p={4}
borderRadius={12}
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<SidebarItems />
</Box>
{currentUser?.email && (
<Text
color={textColor}
noOfLines={2}
fontSize="sm"
p={2}
maxW="180px"
>
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
<Box w="100%">
<SidebarItems />
</Box>
</Box>
</>
)

53
frontend/src/components/Common/SidebarItems.tsx

@ -1,8 +1,9 @@
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
import { Box, Flex, Icon, Text } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import { Link as RouterLink } from "@tanstack/react-router"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import type { IconType } from "react-icons/lib"
import type { UserPublic } from "../../client"
const items = [
@ -15,39 +16,43 @@ interface SidebarItemsProps {
onClose?: () => void
}
interface Item {
icon: IconType
title: string
path: string
}
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
const queryClient = useQueryClient()
const textColor = useColorModeValue("ui.main", "ui.light")
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const finalItems = currentUser?.is_superuser
const finalItems: Item[] = currentUser?.is_superuser
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
: items
const listItems = finalItems.map(({ icon, title, path }) => (
<Flex
as={Link}
to={path}
w="100%"
p={2}
key={title}
activeProps={{
style: {
background: bgActive,
borderRadius: "12px",
},
}}
color={textColor}
onClick={onClose}
>
<Icon as={icon} alignSelf="center" />
<Text ml={2}>{title}</Text>
</Flex>
<RouterLink key={title} to={path} onClick={onClose}>
<Flex
gap={4}
px={4}
py={2}
_hover={{
background: "gray.subtle",
}}
alignItems="center"
fontSize="sm"
>
<Icon as={icon} alignSelf="center" />
<Text ml={2}>{title}</Text>
</Flex>
</RouterLink>
))
return (
<>
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
Menu
</Text>
<Box>{listItems}</Box>
</>
)

28
frontend/src/components/Common/UserActionsMenu.tsx

@ -0,0 +1,28 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
import type { UserPublic } from "../../client"
import DeleteUser from "../Admin/DeleteUser"
import EditUser from "../Admin/EditUser"
interface UserActionsMenuProps {
user: UserPublic
disabled?: boolean
}
export const UserActionsMenu = ({ user, disabled }: UserActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit" disabled={disabled}>
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditUser user={user} />
<DeleteUser id={user.id} />
</MenuContent>
</MenuRoot>
)
}

77
frontend/src/components/Common/UserMenu.tsx

@ -1,19 +1,13 @@
import {
Box,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
} from "@chakra-ui/react"
import { Box, Button, Flex, Text } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
import { FaUserAstronaut } from "react-icons/fa"
import { FiLogOut, FiUser } from "react-icons/fi"
import { FiLogOut, FiUser } from "react-icons/fi"
import useAuth from "../../hooks/useAuth"
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"
const UserMenu = () => {
const { logout } = useAuth()
const { user, logout } = useAuth()
const handleLogout = async () => {
logout()
@ -22,36 +16,47 @@ const UserMenu = () => {
return (
<>
{/* Desktop */}
<Box
display={{ base: "none", md: "block" }}
position="fixed"
top={4}
right={4}
>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<FaUserAstronaut color="white" fontSize="18px" />}
bg="ui.main"
isRound
data-testid="user-menu"
/>
<MenuList>
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
My profile
</MenuItem>
<Flex>
<MenuRoot>
<MenuTrigger asChild p={2}>
<Button
data-testid="user-menu"
variant="solid"
maxW="150px"
truncate
>
<FaUserAstronaut fontSize="18" />
<Text>{user?.full_name || "User"}</Text>
</Button>
</MenuTrigger>
<MenuContent>
<Link to="settings">
<MenuItem
closeOnSelect
value="user-settings"
gap={2}
py={2}
style={{ cursor: "pointer" }}
>
<FiUser fontSize="18px" />
<Box flex="1">My Profile</Box>
</MenuItem>
</Link>
<MenuItem
icon={<FiLogOut fontSize="18px" />}
value="logout"
gap={2}
py={2}
onClick={handleLogout}
color="ui.danger"
fontWeight="bold"
style={{ cursor: "pointer" }}
>
Log out
<FiLogOut />
Log Out
</MenuItem>
</MenuList>
</Menu>
</Box>
</MenuContent>
</MenuRoot>
</Flex>
</>
)
}

167
frontend/src/components/Items/AddItem.tsx

@ -1,37 +1,40 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import {
Button,
FormControl,
FormErrorMessage,
FormLabel,
DialogActionTrigger,
DialogTitle,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import { type ApiError, type ItemCreate, ItemsService } from "../../client"
import { useState } from "react"
import { FaPlus } from "react-icons/fa"
import { type ItemCreate, ItemsService } from "../../client"
import type { ApiError } from "../../client/core/ApiError"
import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface AddItemProps {
isOpen: boolean
onClose: () => void
}
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
const AddItem = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
formState: { errors, isValid, isSubmitting },
} = useForm<ItemCreate>({
mode: "onBlur",
criteriaMode: "all",
@ -45,12 +48,12 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
mutationFn: (data: ItemCreate) =>
ItemsService.createItem({ requestBody: data }),
onSuccess: () => {
showToast("Success!", "Item created successfully.", "success")
showSuccessToast("Item created successfully.")
reset()
onClose()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] })
@ -62,52 +65,80 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: "sm", md: "md" }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Add Item</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isRequired isInvalid={!!errors.title}>
<FormLabel htmlFor="title">Title</FormLabel>
<Input
id="title"
{...register("title", {
required: "Title is required.",
})}
placeholder="Title"
type="text"
/>
{errors.title && (
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="description">Description</FormLabel>
<Input
id="description"
{...register("description")}
placeholder="Description"
type="text"
/>
</FormControl>
</ModalBody>
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button value="add-item" my={4}>
<FaPlus fontSize="16px" />
Add Item
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Fill in the details to add a new item.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
>
<Input
id="title"
{...register("title", {
required: "Title is required.",
})}
placeholder="Title"
type="text"
/>
</Field>
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
>
<Input
id="description"
{...register("description")}
placeholder="Description"
type="text"
/>
</Field>
</VStack>
</DialogBody>
<ModalFooter gap={3}>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save
</Button>
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
</DialogFooter>
</form>
<DialogCloseTrigger />
</DialogContent>
</DialogRoot>
)
}

103
frontend/src/components/Items/DeleteItem.tsx

@ -0,0 +1,103 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { ItemsService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../../components/ui/dialog"
import useCustomToast from "../../hooks/useCustomToast"
const DeleteItem = ({ id }: { id: string }) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteItem = async (id: string) => {
await ItemsService.deleteItem({ id: id })
}
const mutation = useMutation({
mutationFn: deleteItem,
onSuccess: () => {
showSuccessToast("The item was deleted successfully")
setIsOpen(false)
},
onError: () => {
showErrorToast("An error occurred while deleting the item")
},
onSettled: () => {
queryClient.invalidateQueries()
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
role="alertdialog"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete Item
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
This item will be permanently deleted. Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
type="submit"
loading={isSubmitting}
>
Delete
</Button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
)
}
export default DeleteItem

192
frontend/src/components/Items/EditItem.tsx

@ -1,123 +1,149 @@
import {
Button,
FormControl,
FormErrorMessage,
FormLabel,
ButtonGroup,
DialogActionTrigger,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form"
import {
type ApiError,
type ItemPublic,
type ItemUpdate,
ItemsService,
} from "../../client"
import { FaExchangeAlt } from "react-icons/fa"
import { type ApiError, type ItemPublic, ItemsService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface EditItemProps {
item: ItemPublic
isOpen: boolean
onClose: () => void
}
const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
interface ItemUpdateForm {
title: string
description?: string
}
const EditItem = ({ item }: EditItemProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { isSubmitting, errors, isDirty },
} = useForm<ItemUpdate>({
formState: { errors, isSubmitting },
} = useForm<ItemUpdateForm>({
mode: "onBlur",
criteriaMode: "all",
defaultValues: item,
defaultValues: {
...item,
description: item.description ?? undefined,
},
})
const mutation = useMutation({
mutationFn: (data: ItemUpdate) =>
mutationFn: (data: ItemUpdateForm) =>
ItemsService.updateItem({ id: item.id, requestBody: data }),
onSuccess: () => {
showToast("Success!", "Item updated successfully.", "success")
onClose()
showSuccessToast("Item updated successfully.")
reset()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] })
},
})
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => {
mutation.mutate(data)
}
const onCancel = () => {
reset()
onClose()
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size={{ base: "sm", md: "md" }}
isCentered
>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Edit Item</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormControl isInvalid={!!errors.title}>
<FormLabel htmlFor="title">Title</FormLabel>
<Input
id="title"
{...register("title", {
required: "Title is required",
})}
type="text"
/>
{errors.title && (
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4}>
<FormLabel htmlFor="description">Description</FormLabel>
<Input
id="description"
{...register("description")}
placeholder="Description"
type="text"
/>
</FormControl>
</ModalBody>
<ModalFooter gap={3}>
<Button
variant="primary"
type="submit"
isLoading={isSubmitting}
isDisabled={!isDirty}
>
Save
</Button>
<Button onClick={onCancel}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost">
<FaExchangeAlt fontSize="16px" />
Edit Item
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the item details below.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
>
<Input
id="title"
{...register("title", {
required: "Title is required",
})}
placeholder="Title"
type="text"
/>
</Field>
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
>
<Input
id="description"
{...register("description")}
placeholder="Description"
type="text"
/>
</Field>
</VStack>
</DialogBody>
<DialogFooter gap={2}>
<ButtonGroup>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
</Button>
</ButtonGroup>
</DialogFooter>
</form>
<DialogCloseTrigger />
</DialogContent>
</DialogRoot>
)
}

34
frontend/src/components/Pending/PendingItems.tsx

@ -0,0 +1,34 @@
import { Skeleton, Table } from "@chakra-ui/react"
const PendingItems = () => (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
<Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
export default PendingItems

38
frontend/src/components/Pending/PendingUsers.tsx

@ -0,0 +1,38 @@
import { Skeleton, Table } from "@chakra-ui/react"
const PendingUsers = () => (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
<Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
<Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
export default PendingUsers

34
frontend/src/components/UserSettings/Appearance.tsx

@ -1,15 +1,9 @@
import {
Badge,
Container,
Heading,
Radio,
RadioGroup,
Stack,
useColorMode,
} from "@chakra-ui/react"
import { Container, Heading, Stack } from "@chakra-ui/react"
import { useTheme } from "next-themes"
import { Radio, RadioGroup } from "../../components/ui/radio"
const Appearance = () => {
const { colorMode, toggleColorMode } = useColorMode()
const { theme, setTheme } = useTheme()
return (
<>
@ -17,18 +11,16 @@ const Appearance = () => {
<Heading size="sm" py={4}>
Appearance
</Heading>
<RadioGroup onChange={toggleColorMode} value={colorMode}>
<RadioGroup
onValueChange={(e) => setTheme(e.value)}
value={theme}
colorPalette="teal"
>
<Stack>
{/* TODO: Add system default option */}
<Radio value="light" colorScheme="teal">
Light Mode
<Badge ml="1" colorScheme="teal">
Default
</Badge>
</Radio>
<Radio value="dark" colorScheme="teal">
Dark Mode
</Radio>
<Radio value="system">System</Radio>
<Radio value="light">Light Mode</Radio>
<Radio value="dark">Dark Mode</Radio>
</Stack>
</RadioGroup>
</Container>

88
frontend/src/components/UserSettings/ChangePassword.tsx

@ -1,34 +1,25 @@
import {
Box,
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
useColorModeValue,
} from "@chakra-ui/react"
import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock } from "react-icons/fi"
import { type ApiError, type UpdatePassword, UsersService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
import { PasswordInput } from "../ui/password-input"
interface UpdatePasswordForm extends UpdatePassword {
confirm_password: string
}
const ChangePassword = () => {
const color = useColorModeValue("inherit", "ui.light")
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const {
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
formState: { errors, isValid, isSubmitting },
} = useForm<UpdatePasswordForm>({
mode: "onBlur",
criteriaMode: "all",
@ -38,11 +29,11 @@ const ChangePassword = () => {
mutationFn: (data: UpdatePassword) =>
UsersService.updatePasswordMe({ requestBody: data }),
onSuccess: () => {
showToast("Success!", "Password updated successfully.", "success")
showSuccessToast("Password updated successfully.")
reset()
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
})
@ -57,60 +48,39 @@ const ChangePassword = () => {
Change Password
</Heading>
<Box
w={{ sm: "full", md: "50%" }}
w={{ sm: "full", md: "300px" }}
as="form"
onSubmit={handleSubmit(onSubmit)}
>
<FormControl isRequired isInvalid={!!errors.current_password}>
<FormLabel color={color} htmlFor="current_password">
Current Password
</FormLabel>
<Input
id="current_password"
{...register("current_password")}
placeholder="Password"
type="password"
w="auto"
<VStack gap={4}>
<PasswordInput
type="current_password"
startElement={<FiLock />}
{...register("current_password", passwordRules())}
placeholder="Current Password"
errors={errors}
/>
{errors.current_password && (
<FormErrorMessage>
{errors.current_password.message}
</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
<PasswordInput
type="new_password"
startElement={<FiLock />}
{...register("new_password", passwordRules())}
placeholder="Password"
type="password"
w="auto"
placeholder="New Password"
errors={errors}
/>
{errors.new_password && (
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
<PasswordInput
type="confirm_password"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Password"
type="password"
w="auto"
placeholder="Confirm Password"
errors={errors}
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
</VStack>
<Button
variant="primary"
variant="solid"
mt={4}
type="submit"
isLoading={isSubmitting}
loading={isSubmitting}
disabled={!isValid}
>
Save
</Button>

38
frontend/src/components/UserSettings/DeleteAccount.tsx

@ -1,35 +1,19 @@
import {
Button,
Container,
Heading,
Text,
useDisclosure,
} from "@chakra-ui/react"
import { Container, Heading, Text } from "@chakra-ui/react"
import DeleteConfirmation from "./DeleteConfirmation"
const DeleteAccount = () => {
const confirmationModal = useDisclosure()
return (
<>
<Container maxW="full">
<Heading size="sm" py={4}>
Delete Account
</Heading>
<Text>
Permanently delete your data and everything associated with your
account.
</Text>
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}>
Delete
</Button>
<DeleteConfirmation
isOpen={confirmationModal.isOpen}
onClose={confirmationModal.onClose}
/>
</Container>
</>
<Container maxW="full">
<Heading size="sm" py={4}>
Delete Account
</Heading>
<Text>
Permanently delete your data and everything associated with your
account.
</Text>
<DeleteConfirmation />
</Container>
)
}
export default DeleteAccount

124
frontend/src/components/UserSettings/DeleteConfirmation.tsx

@ -1,30 +1,27 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { Button, ButtonGroup, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import React from "react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { type ApiError, UsersService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog"
import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
interface DeleteProps {
isOpen: boolean
onClose: () => void
}
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
const DeleteConfirmation = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const showToast = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const { showSuccessToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
@ -34,16 +31,12 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
const mutation = useMutation({
mutationFn: () => UsersService.deleteUserMe(),
onSuccess: () => {
showToast(
"Success",
"Your account has been successfully deleted.",
"success",
)
showSuccessToast("Your account has been successfully deleted")
setIsOpen(false)
logout()
onClose()
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
@ -56,39 +49,58 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
return (
<>
<AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef}
size={{ base: "sm", md: "md" }}
isCentered
<DialogRoot
size={{ base: "xs", md: "md" }}
role="alertdialog"
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<AlertDialogOverlay>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
<DialogTrigger asChild>
<Button variant="solid" colorPalette="red" mt={4}>
Delete
</Button>
</DialogTrigger>
<AlertDialogBody>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot be
undone.
</AlertDialogBody>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Confirmation Required</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot
be undone.
</Text>
</DialogBody>
<AlertDialogFooter gap={3}>
<Button variant="danger" type="submit" isLoading={isSubmitting}>
Confirm
</Button>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isSubmitting}
>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
<DialogFooter gap={2}>
<ButtonGroup>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
type="submit"
loading={isSubmitting}
>
Delete
</Button>
</ButtonGroup>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
</>
)
}

57
frontend/src/components/UserSettings/UserInformation.tsx

@ -3,13 +3,9 @@ import {
Button,
Container,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
Text,
useColorModeValue,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
@ -24,11 +20,11 @@ import {
import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils"
import { Field } from "../ui/field"
const UserInformation = () => {
const queryClient = useQueryClient()
const color = useColorModeValue("inherit", "ui.light")
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const [editMode, setEditMode] = useState(false)
const { user: currentUser } = useAuth()
const {
@ -54,10 +50,10 @@ const UserInformation = () => {
mutationFn: (data: UserUpdateMe) =>
UsersService.updateUserMe({ requestBody: data }),
onSuccess: () => {
showToast("Success!", "User updated successfully.", "success")
showSuccessToast("User updated successfully.")
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries()
@ -84,13 +80,9 @@ const UserInformation = () => {
as="form"
onSubmit={handleSubmit(onSubmit)}
>
<FormControl>
<FormLabel color={color} htmlFor="name">
Full name
</FormLabel>
<Field label="Full name">
{editMode ? (
<Input
id="name"
{...register("full_name", { maxLength: 30 })}
type="text"
size="md"
@ -98,23 +90,24 @@ const UserInformation = () => {
/>
) : (
<Text
size="md"
fontSize="md"
py={2}
color={!currentUser?.full_name ? "ui.dim" : "inherit"}
isTruncated
color={!currentUser?.full_name ? "gray" : "inherit"}
truncate
maxWidth="250px"
>
{currentUser?.full_name || "N/A"}
</Text>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.email}>
<FormLabel color={color} htmlFor="email">
Email
</FormLabel>
</Field>
<Field
mt={4}
label="Email"
invalid={!!errors.email}
errorText={errors.email?.message}
>
{editMode ? (
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
@ -124,26 +117,28 @@ const UserInformation = () => {
w="auto"
/>
) : (
<Text size="md" py={2} isTruncated maxWidth="250px">
<Text fontSize="md" py={2} truncate maxWidth="250px">
{currentUser?.email}
</Text>
)}
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
</Field>
<Flex mt={4} gap={3}>
<Button
variant="primary"
variant="solid"
onClick={toggleEditMode}
type={editMode ? "button" : "submit"}
isLoading={editMode ? isSubmitting : false}
isDisabled={editMode ? !isDirty || !getValues("email") : false}
loading={editMode ? isSubmitting : false}
disabled={editMode ? !isDirty || !getValues("email") : false}
>
{editMode ? "Save" : "Edit"}
</Button>
{editMode && (
<Button onClick={onCancel} isDisabled={isSubmitting}>
<Button
variant="subtle"
colorPalette="gray"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
)}

40
frontend/src/components/ui/button.tsx

@ -0,0 +1,40 @@
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
import {
AbsoluteCenter,
Button as ChakraButton,
Span,
Spinner,
} from "@chakra-ui/react"
import * as React from "react"
interface ButtonLoadingProps {
loading?: boolean
loadingText?: React.ReactNode
}
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display="inline-flex">
<Spinner size="inherit" color="inherit" />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size="inherit" color="inherit" />
{loadingText}
</>
) : (
children
)}
</ChakraButton>
)
},
)

25
frontend/src/components/ui/checkbox.tsx

@ -0,0 +1,25 @@
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
import * as React from "react"
export interface CheckboxProps extends ChakraCheckbox.RootProps {
icon?: React.ReactNode
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
rootRef?: React.Ref<HTMLLabelElement>
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(props, ref) {
const { icon, children, inputProps, rootRef, ...rest } = props
return (
<ChakraCheckbox.Root ref={rootRef} {...rest}>
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckbox.Control>
{icon || <ChakraCheckbox.Indicator />}
</ChakraCheckbox.Control>
{children != null && (
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
)}
</ChakraCheckbox.Root>
)
},
)

17
frontend/src/components/ui/close-button.tsx

@ -0,0 +1,17 @@
import type { ButtonProps } from "@chakra-ui/react"
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
import * as React from "react"
import { LuX } from "react-icons/lu"
export type CloseButtonProps = ButtonProps
export const CloseButton = React.forwardRef<
HTMLButtonElement,
CloseButtonProps
>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
)
})

107
frontend/src/components/ui/color-mode.tsx

@ -0,0 +1,107 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme } = useTheme()
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: resolvedTheme as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

62
frontend/src/components/ui/dialog.tsx

@ -0,0 +1,62 @@
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
import * as React from "react"
import { CloseButton } from "./close-button"
interface DialogContentProps extends ChakraDialog.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
backdrop?: boolean
}
export const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(function DialogContent(props, ref) {
const {
children,
portalled = true,
portalRef,
backdrop = true,
...rest
} = props
return (
<Portal disabled={!portalled} container={portalRef}>
{backdrop && <ChakraDialog.Backdrop />}
<ChakraDialog.Positioner>
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
{children}
</ChakraDialog.Content>
</ChakraDialog.Positioner>
</Portal>
)
})
export const DialogCloseTrigger = React.forwardRef<
HTMLButtonElement,
ChakraDialog.CloseTriggerProps
>(function DialogCloseTrigger(props, ref) {
return (
<ChakraDialog.CloseTrigger
position="absolute"
top="2"
insetEnd="2"
{...props}
asChild
>
<CloseButton size="sm" ref={ref}>
{props.children}
</CloseButton>
</ChakraDialog.CloseTrigger>
)
})
export const DialogRoot = ChakraDialog.Root
export const DialogFooter = ChakraDialog.Footer
export const DialogHeader = ChakraDialog.Header
export const DialogBody = ChakraDialog.Body
export const DialogBackdrop = ChakraDialog.Backdrop
export const DialogTitle = ChakraDialog.Title
export const DialogDescription = ChakraDialog.Description
export const DialogTrigger = ChakraDialog.Trigger
export const DialogActionTrigger = ChakraDialog.ActionTrigger

52
frontend/src/components/ui/drawer.tsx

@ -0,0 +1,52 @@
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
import * as React from "react"
import { CloseButton } from "./close-button"
interface DrawerContentProps extends ChakraDrawer.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
offset?: ChakraDrawer.ContentProps["padding"]
}
export const DrawerContent = React.forwardRef<
HTMLDivElement,
DrawerContentProps
>(function DrawerContent(props, ref) {
const { children, portalled = true, portalRef, offset, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraDrawer.Positioner padding={offset}>
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
{children}
</ChakraDrawer.Content>
</ChakraDrawer.Positioner>
</Portal>
)
})
export const DrawerCloseTrigger = React.forwardRef<
HTMLButtonElement,
ChakraDrawer.CloseTriggerProps
>(function DrawerCloseTrigger(props, ref) {
return (
<ChakraDrawer.CloseTrigger
position="absolute"
top="2"
insetEnd="2"
{...props}
asChild
>
<CloseButton size="sm" ref={ref} />
</ChakraDrawer.CloseTrigger>
)
})
export const DrawerTrigger = ChakraDrawer.Trigger
export const DrawerRoot = ChakraDrawer.Root
export const DrawerFooter = ChakraDrawer.Footer
export const DrawerHeader = ChakraDrawer.Header
export const DrawerBody = ChakraDrawer.Body
export const DrawerBackdrop = ChakraDrawer.Backdrop
export const DrawerDescription = ChakraDrawer.Description
export const DrawerTitle = ChakraDrawer.Title
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger

33
frontend/src/components/ui/field.tsx

@ -0,0 +1,33 @@
import { Field as ChakraField } from "@chakra-ui/react"
import * as React from "react"
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
label?: React.ReactNode
helperText?: React.ReactNode
errorText?: React.ReactNode
optionalText?: React.ReactNode
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } =
props
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && (
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
)}
{errorText && (
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
)}
</ChakraField.Root>
)
},
)

53
frontend/src/components/ui/input-group.tsx

@ -0,0 +1,53 @@
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
import { Group, InputElement } from "@chakra-ui/react"
import * as React from "react"
export interface InputGroupProps extends BoxProps {
startElementProps?: InputElementProps
endElementProps?: InputElementProps
startElement?: React.ReactNode
endElement?: React.ReactNode
children: React.ReactElement<InputElementProps>
startOffset?: InputElementProps["paddingStart"]
endOffset?: InputElementProps["paddingEnd"]
}
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
function InputGroup(props, ref) {
const {
startElement,
startElementProps,
endElement,
endElementProps,
children,
startOffset = "6px",
endOffset = "6px",
...rest
} = props
const child =
React.Children.only<React.ReactElement<InputElementProps>>(children)
return (
<Group ref={ref} {...rest}>
{startElement && (
<InputElement pointerEvents="none" {...startElementProps}>
{startElement}
</InputElement>
)}
{React.cloneElement(child, {
...(startElement && {
ps: `calc(var(--input-height) - ${startOffset})`,
}),
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
...children.props,
})}
{endElement && (
<InputElement placement="end" {...endElementProps}>
{endElement}
</InputElement>
)}
</Group>
)
},
)

12
frontend/src/components/ui/link-button.tsx

@ -0,0 +1,12 @@
"use client"
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
import { createRecipeContext } from "@chakra-ui/react"
export interface LinkButtonProps
extends HTMLChakraProps<"a", RecipeProps<"button">> {}
const { withContext } = createRecipeContext({ key: "button" })
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a")

112
frontend/src/components/ui/menu.tsx

@ -0,0 +1,112 @@
"use client"
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
import * as React from "react"
import { LuCheck, LuChevronRight } from "react-icons/lu"
interface MenuContentProps extends ChakraMenu.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
}
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
function MenuContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraMenu.Positioner>
<ChakraMenu.Content ref={ref} {...rest} />
</ChakraMenu.Positioner>
</Portal>
)
},
)
export const MenuArrow = React.forwardRef<
HTMLDivElement,
ChakraMenu.ArrowProps
>(function MenuArrow(props, ref) {
return (
<ChakraMenu.Arrow ref={ref} {...props}>
<ChakraMenu.ArrowTip />
</ChakraMenu.Arrow>
)
})
export const MenuCheckboxItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.CheckboxItemProps
>(function MenuCheckboxItem(props, ref) {
return (
<ChakraMenu.CheckboxItem ps="8" ref={ref} {...props}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
{props.children}
</ChakraMenu.CheckboxItem>
)
})
export const MenuRadioItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.RadioItemProps
>(function MenuRadioItem(props, ref) {
const { children, ...rest } = props
return (
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
</ChakraMenu.RadioItem>
)
})
export const MenuItemGroup = React.forwardRef<
HTMLDivElement,
ChakraMenu.ItemGroupProps
>(function MenuItemGroup(props, ref) {
const { title, children, ...rest } = props
return (
<ChakraMenu.ItemGroup ref={ref} {...rest}>
{title && (
<ChakraMenu.ItemGroupLabel userSelect="none">
{title}
</ChakraMenu.ItemGroupLabel>
)}
{children}
</ChakraMenu.ItemGroup>
)
})
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
startIcon?: React.ReactNode
}
export const MenuTriggerItem = React.forwardRef<
HTMLDivElement,
MenuTriggerItemProps
>(function MenuTriggerItem(props, ref) {
const { startIcon, children, ...rest } = props
return (
<ChakraMenu.TriggerItem ref={ref} {...rest}>
{startIcon}
{children}
<LuChevronRight />
</ChakraMenu.TriggerItem>
)
})
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
export const MenuContextTrigger = ChakraMenu.ContextTrigger
export const MenuRoot = ChakraMenu.Root
export const MenuSeparator = ChakraMenu.Separator
export const MenuItem = ChakraMenu.Item
export const MenuItemText = ChakraMenu.ItemText
export const MenuItemCommand = ChakraMenu.ItemCommand
export const MenuTrigger = ChakraMenu.Trigger

211
frontend/src/components/ui/pagination.tsx

@ -0,0 +1,211 @@
"use client"
import type { ButtonProps, TextProps } from "@chakra-ui/react"
import {
Button,
Pagination as ChakraPagination,
IconButton,
Text,
createContext,
usePaginationContext,
} from "@chakra-ui/react"
import * as React from "react"
import {
HiChevronLeft,
HiChevronRight,
HiMiniEllipsisHorizontal,
} from "react-icons/hi2"
import { LinkButton } from "./link-button"
interface ButtonVariantMap {
current: ButtonProps["variant"]
default: ButtonProps["variant"]
ellipsis: ButtonProps["variant"]
}
type PaginationVariant = "outline" | "solid" | "subtle"
interface ButtonVariantContext {
size: ButtonProps["size"]
variantMap: ButtonVariantMap
getHref?: (page: number) => string
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: "RootPropsProvider",
})
export interface PaginationRootProps
extends Omit<ChakraPagination.RootProps, "type"> {
size?: ButtonProps["size"]
variant?: PaginationVariant
getHref?: (page: number) => string
}
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
outline: { default: "ghost", ellipsis: "plain", current: "outline" },
solid: { default: "outline", ellipsis: "outline", current: "solid" },
subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
}
export const PaginationRoot = React.forwardRef<
HTMLDivElement,
PaginationRootProps
>(function PaginationRoot(props, ref) {
const { size = "sm", variant = "outline", getHref, ...rest } = props
return (
<RootPropsProvider
value={{ size, variantMap: variantMap[variant], getHref }}
>
<ChakraPagination.Root
ref={ref}
type={getHref ? "link" : "button"}
{...rest}
/>
</RootPropsProvider>
)
})
export const PaginationEllipsis = React.forwardRef<
HTMLDivElement,
ChakraPagination.EllipsisProps
>(function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps()
return (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as="span" variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
)
})
export const PaginationItem = React.forwardRef<
HTMLButtonElement,
ChakraPagination.ItemProps
>(function PaginationItem(props, ref) {
const { page } = usePaginationContext()
const { size, variantMap, getHref } = useRootProps()
const current = page === props.value
const variant = current ? variantMap.current : variantMap.default
if (getHref) {
return (
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
)
}
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
)
})
export const PaginationPrevTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.PrevTriggerProps
>(function PaginationPrevTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { previousPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
)
}
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
)
})
export const PaginationNextTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.NextTriggerProps
>(function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { nextPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={nextPage != null ? getHref(nextPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronRight />
</LinkButton>
)
}
return (
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronRight />
</IconButton>
</ChakraPagination.NextTrigger>
)
})
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
return (
<ChakraPagination.Context>
{({ pages }) =>
pages.map((page, index) => {
return page.type === "ellipsis" ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem
key={index}
type="page"
value={page.value}
{...props}
/>
)
})
}
</ChakraPagination.Context>
)
}
interface PageTextProps extends TextProps {
format?: "short" | "compact" | "long"
}
export const PaginationPageText = React.forwardRef<
HTMLParagraphElement,
PageTextProps
>(function PaginationPageText(props, ref) {
const { format = "compact", ...rest } = props
const { page, totalPages, pageRange, count } = usePaginationContext()
const content = React.useMemo(() => {
if (format === "short") return `${page} / ${totalPages}`
if (format === "compact") return `${page} of ${totalPages}`
return `${pageRange.start + 1} - ${Math.min(
pageRange.end,
count,
)} of ${count}`
}, [format, page, totalPages, pageRange, count])
return (
<Text fontWeight="medium" ref={ref} {...rest}>
{content}
</Text>
)
})

162
frontend/src/components/ui/password-input.tsx

@ -0,0 +1,162 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import { forwardRef, useRef } from "react"
import { FiEye, FiEyeOff } from "react-icons/fi"
import { Field } from "./field"
import { InputGroup } from "./input-group"
export interface PasswordVisibilityProps {
defaultVisible?: boolean
visible?: boolean
onVisibleChange?: (visible: boolean) => void
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
startElement?: React.ReactNode
type: string
errors: any
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <FiEye />, off: <FiEyeOff /> },
startElement,
type,
errors,
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = useRef<HTMLInputElement>(null)
return (
<Field
invalid={!!errors[type]}
errorText={errors[type]?.message}
alignSelf="start"
>
<InputGroup
width="100%"
startElement={startElement}
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
</Field>
)
},
)
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
color="inherit"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" ref={ref} {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}

18
frontend/src/components/ui/provider.tsx

@ -0,0 +1,18 @@
"use client"
import { ChakraProvider } from "@chakra-ui/react"
import React, { type PropsWithChildren } from "react"
import { system } from "../../theme"
import { ColorModeProvider } from "./color-mode"
import { Toaster } from "./toaster"
export function CustomProvider(props: PropsWithChildren) {
return (
<ChakraProvider value={system}>
<ColorModeProvider defaultTheme="light">
{props.children}
</ColorModeProvider>
<Toaster />
</ChakraProvider>
)
}

24
frontend/src/components/ui/radio.tsx

@ -0,0 +1,24 @@
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
import * as React from "react"
export interface RadioProps extends ChakraRadioGroup.ItemProps {
rootRef?: React.Ref<HTMLDivElement>
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
function Radio(props, ref) {
const { children, inputProps, rootRef, ...rest } = props
return (
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
<ChakraRadioGroup.ItemIndicator />
{children && (
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
)}
</ChakraRadioGroup.Item>
)
},
)
export const RadioGroup = ChakraRadioGroup.Root

47
frontend/src/components/ui/skeleton.tsx

@ -0,0 +1,47 @@
import type {
SkeletonProps as ChakraSkeletonProps,
CircleProps,
} from "@chakra-ui/react"
import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"
import * as React from "react"
export interface SkeletonCircleProps extends ChakraSkeletonProps {
size?: CircleProps["size"]
}
export const SkeletonCircle = React.forwardRef<
HTMLDivElement,
SkeletonCircleProps
>(function SkeletonCircle(props, ref) {
const { size, ...rest } = props
return (
<Circle size={size} asChild ref={ref}>
<ChakraSkeleton {...rest} />
</Circle>
)
})
export interface SkeletonTextProps extends ChakraSkeletonProps {
noOfLines?: number
}
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
function SkeletonText(props, ref) {
const { noOfLines = 3, gap, ...rest } = props
return (
<Stack gap={gap} width="full" ref={ref}>
{Array.from({ length: noOfLines }).map((_, index) => (
<ChakraSkeleton
height="4"
key={index}
{...props}
_last={{ maxW: "80%" }}
{...rest}
/>
))}
</Stack>
)
},
)
export const Skeleton = ChakraSkeleton

43
frontend/src/components/ui/toaster.tsx

@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "top-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }} color={toast.meta?.color}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.meta?.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

32
frontend/src/hooks/useAuth.ts

@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useState } from "react"
import { AxiosError } from "axios"
import {
type Body_login_login_access_token as AccessToken,
type ApiError,
@ -11,7 +10,7 @@ import {
type UserRegister,
UsersService,
} from "../client"
import useCustomToast from "./useCustomToast"
import { handleError } from "../utils"
const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null
@ -20,9 +19,8 @@ const isLoggedIn = () => {
const useAuth = () => {
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const showToast = useCustomToast()
const queryClient = useQueryClient()
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({
const { data: user } = useQuery<UserPublic | null, Error>({
queryKey: ["currentUser"],
queryFn: UsersService.readUserMe,
enabled: isLoggedIn(),
@ -34,20 +32,9 @@ const useAuth = () => {
onSuccess: () => {
navigate({ to: "/login" })
showToast(
"Account created.",
"Your account has been created successfully.",
"success",
)
},
onError: (err: ApiError) => {
let errDetail = (err.body as any)?.detail
if (err instanceof AxiosError) {
errDetail = err.message
}
showToast("Something went wrong.", errDetail, "error")
handleError(err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
@ -67,17 +54,7 @@ const useAuth = () => {
navigate({ to: "/" })
},
onError: (err: ApiError) => {
let errDetail = (err.body as any)?.detail
if (err instanceof AxiosError) {
errDetail = err.message
}
if (Array.isArray(errDetail)) {
errDetail = "Something went wrong"
}
setError(errDetail)
handleError(err)
},
})
@ -91,7 +68,6 @@ const useAuth = () => {
loginMutation,
logout,
user,
isLoading,
error,
resetError: () => setError(null),
}

34
frontend/src/hooks/useCustomToast.ts

@ -1,23 +1,25 @@
import { useToast } from "@chakra-ui/react"
import { useCallback } from "react"
"use client"
import { toaster } from "../components/ui/toaster"
const useCustomToast = () => {
const toast = useToast()
const showSuccessToast = (description: string) => {
toaster.create({
title: "Success!",
description,
type: "success",
})
}
const showToast = useCallback(
(title: string, description: string, status: "success" | "error") => {
toast({
title,
description,
status,
isClosable: true,
position: "bottom-right",
})
},
[toast],
)
const showErrorToast = (description: string) => {
toaster.create({
title: "Something went wrong!",
description,
type: "error",
})
}
return showToast
return { showSuccessToast, showErrorToast }
}
export default useCustomToast

10
frontend/src/main.tsx

@ -1,12 +1,12 @@
import { ChakraProvider } from "@chakra-ui/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import React from "react"
import ReactDOM from "react-dom/client"
import { routeTree } from "./routeTree.gen"
import { StrictMode } from "react"
import { OpenAPI } from "./client"
import theme from "./theme"
import { CustomProvider } from "./components/ui/provider"
OpenAPI.BASE = import.meta.env.VITE_API_URL
OpenAPI.TOKEN = async () => {
@ -15,7 +15,7 @@ OpenAPI.TOKEN = async () => {
const queryClient = new QueryClient()
const router = createRouter({ routeTree })
const router = createRouter({ routeTree, context: { queryClient } })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
@ -24,10 +24,10 @@ declare module "@tanstack/react-router" {
ReactDOM.createRoot(document.getElementById("root")!).render(
<StrictMode>
<ChakraProvider theme={theme}>
<CustomProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ChakraProvider>
</CustomProvider>
</StrictMode>,
)

26
frontend/src/routes/_layout.tsx

@ -1,9 +1,9 @@
import { Flex, Spinner } from "@chakra-ui/react"
import { Flex } from "@chakra-ui/react"
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
import Navbar from "../components/Common/Navbar"
import Sidebar from "../components/Common/Sidebar"
import UserMenu from "../components/Common/UserMenu"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
import { isLoggedIn } from "../hooks/useAuth"
export const Route = createFileRoute("/_layout")({
component: Layout,
@ -17,19 +17,17 @@ export const Route = createFileRoute("/_layout")({
})
function Layout() {
const { isLoading } = useAuth()
return (
<Flex maxW="large" h="auto" position="relative">
<Sidebar />
{isLoading ? (
<Flex justify="center" align="center" height="100vh" width="full">
<Spinner size="xl" color="ui.main" />
<Flex direction="column" h="100vh">
<Navbar />
<Flex flex="1" overflow="hidden">
<Sidebar />
<Flex flex="1" direction="column" p={4} overflowY="auto">
<Outlet />
</Flex>
) : (
<Outlet />
)}
<UserMenu />
</Flex>
</Flex>
)
}
export default Layout

192
frontend/src/routes/_layout/admin.tsx

@ -1,38 +1,23 @@
import {
Badge,
Box,
Container,
Flex,
Heading,
SkeletonText,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react"
import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { z } from "zod"
import { type UserPublic, UsersService } from "../../client"
import AddUser from "../../components/Admin/AddUser"
import ActionsMenu from "../../components/Common/ActionsMenu"
import Navbar from "../../components/Common/Navbar"
import { PaginationFooter } from "../../components/Common/PaginationFooter.tsx"
import { UserActionsMenu } from "../../components/Common/UserActionsMenu"
import PendingUsers from "../../components/Pending/PendingUsers"
import {
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "../../components/ui/pagination.tsx"
const usersSearchSchema = z.object({
page: z.number().catch(1),
})
export const Route = createFileRoute("/_layout/admin")({
component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
})
const PER_PAGE = 5
function getUsersQueryOptions({ page }: { page: number }) {
@ -43,106 +28,87 @@ function getUsersQueryOptions({ page }: { page: number }) {
}
}
export const Route = createFileRoute("/_layout/admin")({
component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
})
function UsersTable() {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { page } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) =>
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
const { page } = Route.useSearch()
const {
data: users,
isPending,
isPlaceholderData,
} = useQuery({
const { data, isLoading, isPlaceholderData } = useQuery({
...getUsersQueryOptions({ page }),
placeholderData: (prevData) => prevData,
})
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
const hasPreviousPage = page > 1
const setPage = (page: number) =>
navigate({
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
})
const users = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
useEffect(() => {
if (hasNextPage) {
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
}
}, [page, queryClient, hasNextPage])
if (isLoading) {
return <PendingUsers />
}
return (
<>
<TableContainer>
<Table size={{ base: "sm", md: "md" }}>
<Thead>
<Tr>
<Th width="20%">Full name</Th>
<Th width="50%">Email</Th>
<Th width="10%">Role</Th>
<Th width="10%">Status</Th>
<Th width="10%">Actions</Th>
</Tr>
</Thead>
{isPending ? (
<Tbody>
<Tr>
{new Array(4).fill(null).map((_, index) => (
<Td key={index}>
<SkeletonText noOfLines={1} paddingBlock="16px" />
</Td>
))}
</Tr>
</Tbody>
) : (
<Tbody>
{users?.data.map((user) => (
<Tr key={user.id}>
<Td
color={!user.full_name ? "ui.dim" : "inherit"}
isTruncated
maxWidth="150px"
>
{user.full_name || "N/A"}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Td>
<Td isTruncated maxWidth="150px">
{user.email}
</Td>
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
<Td>
<Flex gap={2}>
<Box
w="2"
h="2"
borderRadius="50%"
bg={user.is_active ? "ui.success" : "ui.danger"}
alignSelf="center"
/>
{user.is_active ? "Active" : "Inactive"}
</Flex>
</Td>
<Td>
<ActionsMenu
type="User"
value={user}
disabled={currentUser?.id === user.id}
/>
</Td>
</Tr>
))}
</Tbody>
)}
</Table>
</TableContainer>
<PaginationFooter
onChangePage={setPage}
page={page}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
/>
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
<Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
<Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{users?.map((user) => (
<Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Table.Cell w="20%" color={!user.full_name ? "gray" : "inherit"}>
{user.full_name || "N/A"}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Table.Cell>
<Table.Cell w="25%">{user.email}</Table.Cell>
<Table.Cell w="15%">
{user.is_superuser ? "Superuser" : "User"}
</Table.Cell>
<Table.Cell w="20%">
{user.is_active ? "Active" : "Inactive"}
</Table.Cell>
<Table.Cell w="20%">
<UserActionsMenu
user={user}
disabled={currentUser?.id === user.id}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Flex justifyContent="flex-end" mt={4}>
<PaginationRoot
count={count}
pageSize={PER_PAGE}
onPageChange={({ page }) => setPage(page)}
>
<Flex>
<PaginationPrevTrigger />
<PaginationItems />
<PaginationNextTrigger />
</Flex>
</PaginationRoot>
</Flex>
</>
)
}
@ -150,11 +116,11 @@ function UsersTable() {
function Admin() {
return (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
<Heading size="lg" pt={12}>
Users Management
</Heading>
<Navbar type={"User"} addModalAs={AddUser} />
<AddUser />
<UsersTable />
</Container>
)

181
frontend/src/routes/_layout/items.tsx

@ -1,35 +1,31 @@
import {
Container,
EmptyState,
Flex,
Heading,
SkeletonText,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
VStack,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { FiSearch } from "react-icons/fi"
import { z } from "zod"
import { useQuery } from "@tanstack/react-query"
import { ItemsService } from "../../client"
import ActionsMenu from "../../components/Common/ActionsMenu"
import Navbar from "../../components/Common/Navbar"
import { ItemActionsMenu } from "../../components/Common/ItemActionsMenu"
import AddItem from "../../components/Items/AddItem"
import { PaginationFooter } from "../../components/Common/PaginationFooter.tsx"
import PendingItems from "../../components/Pending/PendingItems"
import {
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "../../components/ui/pagination.tsx"
const itemsSearchSchema = z.object({
page: z.number().catch(1),
})
export const Route = createFileRoute("/_layout/items")({
component: Items,
validateSearch: (search) => itemsSearchSchema.parse(search),
})
const PER_PAGE = 5
function getItemsQueryOptions({ page }: { page: number }) {
@ -40,83 +36,97 @@ function getItemsQueryOptions({ page }: { page: number }) {
}
}
export const Route = createFileRoute("/_layout/items")({
component: Items,
validateSearch: (search) => itemsSearchSchema.parse(search),
})
function ItemsTable() {
const queryClient = useQueryClient()
const { page } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) =>
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
const { page } = Route.useSearch()
const {
data: items,
isPending,
isPlaceholderData,
} = useQuery({
const { data, isLoading, isPlaceholderData } = useQuery({
...getItemsQueryOptions({ page }),
placeholderData: (prevData) => prevData,
})
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
const hasPreviousPage = page > 1
const setPage = (page: number) =>
navigate({
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
})
const items = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
useEffect(() => {
if (hasNextPage) {
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
}
}, [page, queryClient, hasNextPage])
if (isLoading) {
return <PendingItems />
}
if (items.length === 0) {
return (
<EmptyState.Root>
<EmptyState.Content>
<EmptyState.Indicator>
<FiSearch />
</EmptyState.Indicator>
<VStack textAlign="center">
<EmptyState.Title>You don't have any items yet</EmptyState.Title>
<EmptyState.Description>
Add a new item to get started
</EmptyState.Description>
</VStack>
</EmptyState.Content>
</EmptyState.Root>
)
}
return (
<>
<TableContainer>
<Table size={{ base: "sm", md: "md" }}>
<Thead>
<Tr>
<Th>ID</Th>
<Th>Title</Th>
<Th>Description</Th>
<Th>Actions</Th>
</Tr>
</Thead>
{isPending ? (
<Tbody>
<Tr>
{new Array(4).fill(null).map((_, index) => (
<Td key={index}>
<SkeletonText noOfLines={1} paddingBlock="16px" />
</Td>
))}
</Tr>
</Tbody>
) : (
<Tbody>
{items?.data.map((item) => (
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Td>{item.id}</Td>
<Td isTruncated maxWidth="150px">
{item.title}
</Td>
<Td
color={!item.description ? "ui.dim" : "inherit"}
isTruncated
maxWidth="150px"
>
{item.description || "N/A"}
</Td>
<Td>
<ActionsMenu type={"Item"} value={item} />
</Td>
</Tr>
))}
</Tbody>
)}
</Table>
</TableContainer>
<PaginationFooter
page={page}
onChangePage={setPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
/>
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
<Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items?.map((item) => (
<Table.Row key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Table.Cell truncate maxW="30%">
{item.id}
</Table.Cell>
<Table.Cell truncate maxW="30%">
{item.title}
</Table.Cell>
<Table.Cell
color={!item.description ? "gray" : "inherit"}
truncate
maxW="30%"
>
{item.description || "N/A"}
</Table.Cell>
<Table.Cell width="10%">
<ItemActionsMenu item={item} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Flex justifyContent="flex-end" mt={4}>
<PaginationRoot
count={count}
pageSize={PER_PAGE}
onPageChange={({ page }) => setPage(page)}
>
<Flex>
<PaginationPrevTrigger />
<PaginationItems />
<PaginationNextTrigger />
</Flex>
</PaginationRoot>
</Flex>
</>
)
}
@ -124,11 +134,10 @@ function ItemsTable() {
function Items() {
return (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
<Heading size="lg" pt={12}>
Items Management
</Heading>
<Navbar type={"Item"} addModalAs={AddItem} />
<AddItem />
<ItemsTable />
</Container>
)

55
frontend/src/routes/_layout/settings.tsx

@ -1,26 +1,17 @@
import {
Container,
Heading,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { Container, Heading, Tabs } from "@chakra-ui/react"
import { createFileRoute } from "@tanstack/react-router"
import type { UserPublic } from "../../client"
import Appearance from "../../components/UserSettings/Appearance"
import ChangePassword from "../../components/UserSettings/ChangePassword"
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
import UserInformation from "../../components/UserSettings/UserInformation"
import useAuth from "../../hooks/useAuth"
const tabsConfig = [
{ title: "My profile", component: UserInformation },
{ title: "Password", component: ChangePassword },
{ title: "Appearance", component: Appearance },
{ title: "Danger zone", component: DeleteAccount },
{ value: "my-profile", title: "My profile", component: UserInformation },
{ value: "password", title: "Password", component: ChangePassword },
{ value: "appearance", title: "Appearance", component: Appearance },
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
]
export const Route = createFileRoute("/_layout/settings")({
@ -28,31 +19,35 @@ export const Route = createFileRoute("/_layout/settings")({
})
function UserSettings() {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { user: currentUser } = useAuth()
const finalTabs = currentUser?.is_superuser
? tabsConfig.slice(0, 3)
: tabsConfig
if (!currentUser) {
return null
}
return (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
User Settings
</Heading>
<Tabs variant="enclosed">
<TabList>
{finalTabs.map((tab, index) => (
<Tab key={index}>{tab.title}</Tab>
))}
</TabList>
<TabPanels>
{finalTabs.map((tab, index) => (
<TabPanel key={index}>
<tab.component />
</TabPanel>
<Tabs.Root defaultValue="my-profile" variant="subtle">
<Tabs.List>
{finalTabs.map((tab) => (
<Tabs.Trigger key={tab.value} value={tab.value}>
{tab.title}
</Tabs.Trigger>
))}
</TabPanels>
</Tabs>
</Tabs.List>
{finalTabs.map((tab) => (
<Tabs.Content key={tab.value} value={tab.value}>
<tab.component />
</Tabs.Content>
))}
</Tabs.Root>
</Container>
)
}

95
frontend/src/routes/login.tsx

@ -1,18 +1,4 @@
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
import {
Button,
Container,
FormControl,
FormErrorMessage,
Icon,
Image,
Input,
InputGroup,
InputRightElement,
Link,
Text,
useBoolean,
} from "@chakra-ui/react"
import { Container, Image, Input, Text } from "@chakra-ui/react"
import {
Link as RouterLink,
createFileRoute,
@ -20,10 +6,15 @@ import {
} from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock, FiMail } from "react-icons/fi"
import Logo from "/assets/images/fastapi-logo.svg"
import type { Body_login_login_access_token as AccessToken } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { PasswordInput } from "../components/ui/password-input"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
import { emailPattern } from "../utils"
import { emailPattern, passwordRules } from "../utils"
export const Route = createFileRoute("/login")({
component: Login,
@ -37,7 +28,6 @@ export const Route = createFileRoute("/login")({
})
function Login() {
const [show, setShow] = useBoolean()
const { loginMutation, error, resetError } = useAuth()
const {
register,
@ -84,59 +74,40 @@ function Login() {
alignSelf="center"
mb={4}
/>
<FormControl id="username" isInvalid={!!errors.username || !!error}>
<Input
id="username"
{...register("username", {
required: "Username is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
required
/>
{errors.username && (
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="password" isInvalid={!!error}>
<InputGroup>
<Field
invalid={!!errors.username}
errorText={errors.username?.message || !!error}
>
<InputGroup w="100%" startElement={<FiMail />}>
<Input
{...register("password", {
required: "Password is required",
id="username"
{...register("username", {
required: "Username is required",
pattern: emailPattern,
})}
type={show ? "text" : "password"}
placeholder="Password"
required
placeholder="Email"
type="email"
/>
<InputRightElement
color="ui.dim"
_hover={{
cursor: "pointer",
}}
>
<Icon
as={show ? ViewOffIcon : ViewIcon}
onClick={setShow.toggle}
aria-label={show ? "Hide password" : "Show password"}
>
{show ? <ViewOffIcon /> : <ViewIcon />}
</Icon>
</InputRightElement>
</InputGroup>
{error && <FormErrorMessage>{error}</FormErrorMessage>}
</FormControl>
<Link as={RouterLink} to="/recover-password" color="blue.500">
Forgot password?
</Link>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
</Field>
<PasswordInput
type="password"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password"
errors={errors}
/>
<RouterLink to="/recover-password" className="main-link">
Forgot Password?
</RouterLink>
<Button variant="solid" type="submit" loading={isSubmitting} size="md">
Log In
</Button>
<Text>
Don't have an account?{" "}
<Link as={RouterLink} to="/signup" color="blue.500">
Sign up
</Link>
<RouterLink to="/signup" className="main-link">
Sign Up
</RouterLink>
</Text>
</Container>
</>

55
frontend/src/routes/recover-password.tsx

@ -1,17 +1,13 @@
import {
Button,
Container,
FormControl,
FormErrorMessage,
Heading,
Input,
Text,
} from "@chakra-ui/react"
import { Container, Heading, Input, Text } from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FiMail } from "react-icons/fi"
import { type ApiError, LoginService } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast"
import { emailPattern, handleError } from "../utils"
@ -38,7 +34,7 @@ function RecoverPassword() {
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>()
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const recoverPassword = async (data: FormData) => {
await LoginService.recoverPassword({
@ -49,15 +45,11 @@ function RecoverPassword() {
const mutation = useMutation({
mutationFn: recoverPassword,
onSuccess: () => {
showToast(
"Email sent.",
"We sent an email with a link to get back into your account.",
"success",
)
showSuccessToast("Password recovery email sent successfully.")
reset()
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
})
@ -79,24 +71,23 @@ function RecoverPassword() {
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Password Recovery
</Heading>
<Text align="center">
<Text textAlign="center">
A password recovery email will be sent to the registered account.
</Text>
<FormControl isInvalid={!!errors.email}>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
<Field invalid={!!errors.email} errorText={errors.email?.message}>
<InputGroup w="100%" startElement={<FiMail />}>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</InputGroup>
</Field>
<Button variant="solid" type="submit" loading={isSubmitting}>
Continue
</Button>
</Container>

60
frontend/src/routes/reset-password.tsx

@ -1,18 +1,12 @@
import {
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
Text,
} from "@chakra-ui/react"
import { Container, Heading, Text } from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock } from "react-icons/fi"
import { type ApiError, LoginService, type NewPassword } from "../client"
import { Button } from "../components/ui/button"
import { PasswordInput } from "../components/ui/password-input"
import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
@ -46,7 +40,7 @@ function ResetPassword() {
new_password: "",
},
})
const showToast = useCustomToast()
const { showSuccessToast } = useCustomToast()
const navigate = useNavigate()
const resetPassword = async (data: NewPassword) => {
@ -60,12 +54,12 @@ function ResetPassword() {
const mutation = useMutation({
mutationFn: resetPassword,
onSuccess: () => {
showToast("Success!", "Password updated successfully.", "success")
showSuccessToast("Password updated successfully.")
reset()
navigate({ to: "/login" })
},
onError: (err: ApiError) => {
handleError(err, showToast)
handleError(err)
},
})
@ -90,31 +84,21 @@ function ResetPassword() {
<Text textAlign="center">
Please enter your new password and confirm it to reset your password.
</Text>
<FormControl mt={4} isInvalid={!!errors.new_password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register("new_password", passwordRules())}
placeholder="Password"
type="password"
/>
{errors.new_password && (
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
)}
</FormControl>
<Button variant="primary" type="submit">
<PasswordInput
startElement={<FiLock />}
type="new_password"
errors={errors}
{...register("new_password", passwordRules())}
placeholder="New Password"
/>
<PasswordInput
startElement={<FiLock />}
type="confirm_password"
errors={errors}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
/>
<Button variant="solid" type="submit">
Reset Password
</Button>
</Container>

130
frontend/src/routes/signup.tsx

@ -1,15 +1,4 @@
import {
Button,
Container,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Image,
Input,
Link,
Text,
} from "@chakra-ui/react"
import { Container, Flex, Image, Input, Text } from "@chakra-ui/react"
import {
Link as RouterLink,
createFileRoute,
@ -17,8 +6,13 @@ import {
} from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock, FiUser } from "react-icons/fi"
import Logo from "/assets/images/fastapi-logo.svg"
import type { UserRegister } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { PasswordInput } from "../components/ui/password-input"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
@ -80,80 +74,58 @@ function SignUp() {
alignSelf="center"
mb={4}
/>
<FormControl id="full_name" isInvalid={!!errors.full_name}>
<FormLabel htmlFor="full_name" srOnly>
Full Name
</FormLabel>
<Input
id="full_name"
minLength={3}
{...register("full_name", { required: "Full Name is required" })}
placeholder="Full Name"
type="text"
/>
{errors.full_name && (
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="email" isInvalid={!!errors.email}>
<FormLabel htmlFor="email" srOnly>
Email
</FormLabel>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="password" isInvalid={!!errors.password}>
<FormLabel htmlFor="password" srOnly>
Password
</FormLabel>
<Input
id="password"
{...register("password", passwordRules())}
placeholder="Password"
type="password"
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl
id="confirm_password"
isInvalid={!!errors.confirm_password}
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
>
<FormLabel htmlFor="confirm_password" srOnly>
Confirm Password
</FormLabel>
<InputGroup w="100%" startElement={<FiUser />}>
<Input
id="full_name"
minLength={3}
{...register("full_name", {
required: "Full Name is required",
})}
placeholder="Full Name"
type="text"
/>
</InputGroup>
</Field>
<Input
id="confirm_password"
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Repeat Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
<Field invalid={!!errors.email} errorText={errors.email?.message}>
<InputGroup w="100%" startElement={<FiUser />}>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</InputGroup>
</Field>
<PasswordInput
type="password"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password"
errors={errors}
/>
<PasswordInput
type="confirm_password"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
errors={errors}
/>
<Button variant="solid" type="submit" loading={isSubmitting}>
Sign Up
</Button>
<Text>
Already have an account?{" "}
<Link as={RouterLink} to="/login" color="blue.500">
<RouterLink to="/login" className="main-link">
Log In
</Link>
</RouterLink>
</Text>
</Container>
</Flex>

74
frontend/src/theme.tsx

@ -1,61 +1,31 @@
import { extendTheme } from "@chakra-ui/react"
import { createSystem, defaultConfig } from "@chakra-ui/react"
import { buttonRecipe } from "./theme/button.recipe"
const disabledStyles = {
_disabled: {
backgroundColor: "ui.main",
},
}
const theme = extendTheme({
colors: {
ui: {
main: "#009688",
secondary: "#EDF2F7",
success: "#48BB78",
danger: "#E53E3E",
light: "#FAFAFA",
dark: "#1A202C",
darkSlate: "#252D3D",
dim: "#A0AEC0",
export const system = createSystem(defaultConfig, {
globalCss: {
html: {
fontSize: "16px",
},
body: {
fontSize: "0.875rem",
margin: 0,
padding: 0,
},
".main-link": {
color: "ui.main",
fontWeight: "bold",
},
},
components: {
Button: {
variants: {
primary: {
backgroundColor: "ui.main",
color: "ui.light",
_hover: {
backgroundColor: "#00766C",
},
_disabled: {
...disabledStyles,
_hover: {
...disabledStyles,
},
},
},
danger: {
backgroundColor: "ui.danger",
color: "ui.light",
_hover: {
backgroundColor: "#E32727",
},
theme: {
tokens: {
colors: {
ui: {
main: { value: "#009688" },
},
},
},
Tabs: {
variants: {
enclosed: {
tab: {
_selected: {
color: "ui.main",
},
},
},
},
recipes: {
button: buttonRecipe,
},
},
})
export default theme

21
frontend/src/theme/button.recipe.ts

@ -0,0 +1,21 @@
import { defineRecipe } from "@chakra-ui/react"
export const buttonRecipe = defineRecipe({
base: {
fontWeight: "bold",
display: "flex",
alignItems: "center",
justifyContent: "center",
colorPalette: "teal",
},
variants: {
variant: {
ghost: {
bg: "transparent",
_hover: {
bg: "gray.100",
},
},
},
},
})

6
frontend/src/utils.ts

@ -1,4 +1,5 @@
import type { ApiError } from "./client"
import useCustomToast from "./hooks/useCustomToast"
export const emailPattern = {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
@ -43,11 +44,12 @@ export const confirmPasswordRules = (
return rules
}
export const handleError = (err: ApiError, showToast: any) => {
export const handleError = (err: ApiError) => {
const { showErrorToast } = useCustomToast()
const errDetail = (err.body as any)?.detail
let errorMessage = errDetail || "Something went wrong."
if (Array.isArray(errDetail) && errDetail.length > 0) {
errorMessage = errDetail[0].msg
}
showToast("Error", errorMessage, "error")
showErrorToast(errorMessage)
}

12
frontend/tests/reset-password.spec.ts

@ -64,8 +64,8 @@ test("User can reset password successfully using the link", async ({
// Set the new password and confirm it
await page.goto(url)
await page.getByLabel("Set Password").fill(newPassword)
await page.getByLabel("Confirm Password").fill(newPassword)
await page.getByPlaceholder("New Password").fill(newPassword)
await page.getByPlaceholder("Confirm Password").fill(newPassword)
await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Password updated successfully")).toBeVisible()
@ -79,8 +79,8 @@ test("Expired or invalid reset link", async ({ page }) => {
await page.goto(invalidUrl)
await page.getByLabel("Set Password").fill(password)
await page.getByLabel("Confirm Password").fill(password)
await page.getByPlaceholder("New Password").fill(password)
await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Invalid token")).toBeVisible()
@ -115,8 +115,8 @@ test("Weak new password validation", async ({ page, request }) => {
// Set a weak new password
await page.goto(url)
await page.getByLabel("Set Password").fill(weakPassword)
await page.getByLabel("Confirm Password").fill(weakPassword)
await page.getByPlaceholder("New Password").fill(weakPassword)
await page.getByPlaceholder("Confirm Password").fill(weakPassword)
await page.getByRole("button", { name: "Reset Password" }).click()
await expect(

4
frontend/tests/sign-up.spec.ts

@ -18,7 +18,7 @@ const fillForm = async (
await page.getByPlaceholder("Full Name").fill(full_name)
await page.getByPlaceholder("Email").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password)
await page.getByPlaceholder("Repeat Password").fill(confirm_password)
await page.getByPlaceholder("Confirm Password").fill(confirm_password)
}
const verifyInput = async (
@ -38,7 +38,7 @@ test("Inputs are visible, empty and editable", async ({ page }) => {
await verifyInput(page, "Full Name")
await verifyInput(page, "Email")
await verifyInput(page, "Password", { exact: true })
await verifyInput(page, "Repeat Password")
await verifyInput(page, "Confirm Password")
})
test("Sign Up button is visible", async ({ page }) => {

115
frontend/tests/user-settings.spec.ts

@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test"
import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
import { createUser } from "./utils/privateApi.ts"
import { randomEmail, randomPassword } from "./utils/random"
import { logInUser, logOutUser } from "./utils/user"
import { createUser } from "./utils/privateApi.ts"
const tabs = ["My profile", "Password", "Appearance"]
@ -151,9 +151,9 @@ test.describe("Change password successfully", () => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password)
await page.getByLabel("Set Password*").fill(NewPassword)
await page.getByLabel("Confirm Password*").fill(NewPassword)
await page.getByPlaceholder("Current Password").fill(password)
await page.getByPlaceholder("New Password").fill(NewPassword)
await page.getByPlaceholder("Confirm Password").fill(NewPassword)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Password updated successfully.")).toBeVisible()
@ -179,9 +179,9 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password)
await page.getByLabel("Set Password*").fill(weakPassword)
await page.getByLabel("Confirm Password*").fill(weakPassword)
await page.getByPlaceholder("Current Password").fill(password)
await page.getByPlaceholder("New Password").fill(weakPassword)
await page.getByPlaceholder("Confirm Password").fill(weakPassword)
await expect(
page.getByText("Password must be at least 8 characters"),
).toBeVisible()
@ -202,11 +202,11 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password)
await page.getByLabel("Set Password*").fill(newPassword)
await page.getByLabel("Confirm Password*").fill(confirmPassword)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Passwords do not match")).toBeVisible()
await page.getByPlaceholder("Current Password").fill(password)
await page.getByPlaceholder("New Password").fill(newPassword)
await page.getByPlaceholder("Confirm Password").fill(confirmPassword)
await page.getByLabel("Password", { exact: true }).locator("form").click()
await expect(page.getByText("The passwords do not match")).toBeVisible()
})
test("Current password and new password are the same", async ({ page }) => {
@ -220,9 +220,9 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password)
await page.getByLabel("Set Password*").fill(password)
await page.getByLabel("Confirm Password*").fill(password)
await page.getByPlaceholder("Current Password").fill(password)
await page.getByPlaceholder("New Password").fill(password)
await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Save" }).click()
await expect(
page.getByText("New password cannot be the same as the current one"),
@ -238,22 +238,50 @@ test("Appearance tab is visible", async ({ page }) => {
await expect(page.getByLabel("Appearance")).toBeVisible()
})
test("User can switch from light mode to dark mode", async ({ page }) => {
test("User can switch from light mode to dark mode and vice versa", async ({
page,
}) => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Appearance" }).click()
await page.getByLabel("Appearance").locator("span").nth(3).click()
// Ensure the initial state is light mode
if (
await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
) {
await page
.locator("label")
.filter({ hasText: "Light Mode" })
.locator("span")
.first()
.click()
}
let isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
)
expect(isLightMode).toBe(true)
await page
.locator("label")
.filter({ hasText: "Dark Mode" })
.locator("span")
.first()
.click()
const isDarkMode = await page.evaluate(() =>
document.body.classList.contains("chakra-ui-dark"),
document.documentElement.classList.contains("dark"),
)
expect(isDarkMode).toBe(true)
})
test("User can switch from dark mode to light mode", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Appearance" }).click()
await page.getByLabel("Appearance").locator("span").first().click()
const isLightMode = await page.evaluate(() =>
document.body.classList.contains("chakra-ui-light"),
await page
.locator("label")
.filter({ hasText: "Light Mode" })
.locator("span")
.first()
.click()
isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
)
expect(isLightMode).toBe(true)
})
@ -261,13 +289,42 @@ test("User can switch from dark mode to light mode", async ({ page }) => {
test("Selected mode is preserved across sessions", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("tab", { name: "Appearance" }).click()
await page.getByLabel("Appearance").locator("span").nth(3).click()
await logOutUser(page)
// Ensure the initial state is light mode
if (
await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
) {
await page
.locator("label")
.filter({ hasText: "Light Mode" })
.locator("span")
.first()
.click()
}
const isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
)
expect(isLightMode).toBe(true)
await page
.locator("label")
.filter({ hasText: "Dark Mode" })
.locator("span")
.first()
.click()
let isDarkMode = await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
expect(isDarkMode).toBe(true)
await logOutUser(page)
await logInUser(page, firstSuperuser, firstSuperuserPassword)
const isDarkMode = await page.evaluate(() =>
document.body.classList.contains("chakra-ui-dark"),
isDarkMode = await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
expect(isDarkMode).toBe(true)
})

5
frontend/tests/utils/user.ts

@ -11,11 +11,8 @@ export async function signUpNewUser(
await page.getByPlaceholder("Full Name").fill(name)
await page.getByPlaceholder("Email").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password)
await page.getByPlaceholder("Repeat Password").fill(password)
await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Sign Up" }).click()
await expect(
page.getByText("Your account has been created successfully"),
).toBeVisible()
await page.goto("/login")
}

Loading…
Cancel
Save