diff --git a/TODO.md b/TODO.md index 6adfd3b5e88fba73be20c8a7e15bdf06566a6578..f3367a02d7e259c788e517edd5b883cf2f814d2c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,4 @@ -- Finish Autentification and route protection with JWT -- Implement detailed view of Participant information -- Password reset after account creation -- Client side field validation -- Deployment +- Fix Chef seeing CO and volunteer in same university - Traduction - Search functionality diff --git a/client/src/binding/Claims.ts b/client/src/binding/Claims.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f6a7dac48af8aae572eec430bd49be84f4ef8f6 --- /dev/null +++ b/client/src/binding/Claims.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Role } from "./Role"; +import type { University } from "./University"; + +export type Claims = { + id: string; + role: Role; + university: University; + exp: number; +}; diff --git a/client/src/binding/EmailResetPayload.ts b/client/src/binding/EmailResetPayload.ts new file mode 100644 index 0000000000000000000000000000000000000000..94648bda8e3fec53f32897d2058d1189eb757a71 --- /dev/null +++ b/client/src/binding/EmailResetPayload.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EmailResetPayload = { email: string }; diff --git a/client/src/binding/MinimalParticipant.ts b/client/src/binding/MinimalParticipant.ts index bb2e0d2c0bb12c6e97e17560d468f826391c62d6..be03e0913a13f2898b20c7bb230da140e8104e26 100644 --- a/client/src/binding/MinimalParticipant.ts +++ b/client/src/binding/MinimalParticipant.ts @@ -1,11 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Competition } from "./Competition"; import type { Role } from "./Role"; +import type { University } from "./University"; export type MinimalParticipant = { first_name: string; last_name: string; email: string; competition: Competition; + university: University; role: Role; }; diff --git a/client/src/binding/Participant.ts b/client/src/binding/ParticipantInfo.ts similarity index 51% rename from client/src/binding/Participant.ts rename to client/src/binding/ParticipantInfo.ts index cf21abe4dc7e422eb8dd867adcf3b2b2414c587a..4d2a8110be81fc7c8585173953df878e22b72901 100644 --- a/client/src/binding/Participant.ts +++ b/client/src/binding/ParticipantInfo.ts @@ -1,25 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Competition } from "./Competition"; -import type { Role } from "./Role"; +import type { TshirtSize } from "./TshirtSize"; -export type Participant = { - id: string; - role: Role; - email: string; - first_name: string; - last_name: string; - university: string | null; +export type ParticipantInfo = { medical_conditions: string | null; allergies: string | null; pronouns: string | null; - competition: Competition | null; + supper: string | null; + is_vegetarian: boolean | null; phone_number: string | null; - tshirt_size: string | null; + tshirt_size: TshirtSize | null; comments: string | null; emergency_contact: string | null; has_monthly_opus_card: boolean | null; reduced_mobility: string | null; - study_proof: string; - photo: string; - cv: string; + study_proof: File; + photo: File; + cv: File; }; diff --git a/client/src/binding/ParticipantPreview.ts b/client/src/binding/ParticipantPreview.ts index d8d7738d9b566f82638bea7d1e46f0469d381305..cf23154e440e3e1104e52ff7dc6ade4bdc4d4374 100644 --- a/client/src/binding/ParticipantPreview.ts +++ b/client/src/binding/ParticipantPreview.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Competition } from "./Competition"; import type { Role } from "./Role"; +import type { University } from "./University"; export type ParticipantPreview = { id: string; @@ -9,5 +10,6 @@ export type ParticipantPreview = { email: string; role: Role; competition: Competition; + university: University; contain_cv: boolean; }; diff --git a/client/src/binding/TshirtSize.ts b/client/src/binding/TshirtSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..01256388734ff2489a96e02ae755006825445ff9 --- /dev/null +++ b/client/src/binding/TshirtSize.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TshirtSize = "xs" | "s" | "m" | "l" | "xl" | "xxl"; diff --git a/client/src/binding/University.ts b/client/src/binding/University.ts new file mode 100644 index 0000000000000000000000000000000000000000..f62004e94784db01e193ac3a53db8cab9d9d1619 --- /dev/null +++ b/client/src/binding/University.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type University = + | "uqac" + | "uqar" + | "uqat" + | "uqo" + | "uqtr" + | "mcgill" + | "mcgill_macdonald" + | "concordia" + | "ets" + | "polymtl" + | "ulaval" + | "ulaval_agriculture" + | "uds" + | "none"; diff --git a/client/src/components/ProtectedRoute.tsx b/client/src/components/ProtectedRoute.tsx index 95d5749adf74a532ae2cd06b073680389111aedc..dc07511d8692de9cdfedfa52f534683e3896254d 100644 --- a/client/src/components/ProtectedRoute.tsx +++ b/client/src/components/ProtectedRoute.tsx @@ -17,6 +17,9 @@ export function ProtectedRoute(props: ProtectedRouteProps) { localStorage.removeItem("token") navigate("/login") } + localStorage.setItem("id", response.id) + localStorage.setItem("role", response.role) + localStorage.setItem("university", response.university) }) return <>{props.children}</> diff --git a/client/src/components/forms-component/Checkbox.tsx b/client/src/components/forms-component/Checkbox.tsx index fb9b1ab013172a67a7501315f99c6211e2fa2890..4da6bfbd327e4ac760f4ad30e977d37c38e34254 100644 --- a/client/src/components/forms-component/Checkbox.tsx +++ b/client/src/components/forms-component/Checkbox.tsx @@ -1,5 +1,6 @@ import { JSX, splitProps } from "solid-js" import { InputError } from "./InputError" +import clsx from "clsx" type CheckboxProps = { ref: (element: HTMLInputElement) => void diff --git a/client/src/components/forms-component/FileInput.tsx b/client/src/components/forms-component/FileInput.tsx index 41999eff69ff21e467fd3617bc54c8d82fd86661..b41f9ca33602b9493f2adf8815bb1f3f5ae598f9 100644 --- a/client/src/components/forms-component/FileInput.tsx +++ b/client/src/components/forms-component/FileInput.tsx @@ -42,7 +42,7 @@ export function FileInput(props: FileInputProps) { ) return ( - <div class={clsx("px-8 lg:px-10", props.class)}> + <div> <InputLabel name={props.name} label={props.label} diff --git a/client/src/components/forms-component/SubmitSuccess.tsx b/client/src/components/forms-component/SubmitSuccess.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bcf7696c61a9b14fe1d24c1fcf1c59754a4aee09 --- /dev/null +++ b/client/src/components/forms-component/SubmitSuccess.tsx @@ -0,0 +1,15 @@ +import { Expandable } from "./Expandable" + +type Props = { + success: () => string | null +} + +export function SubmitSuccess(props: Props) { + return ( + <Expandable expanded={!!props.success()}> + <div class="pt-4 text-sm text-green-500 dark:text-green-400 md:text-base lg:pt-5 lg:text-lg"> + {props.success()} + </div> + </Expandable> + ) +} diff --git a/client/src/components/forms-component/TextInput.tsx b/client/src/components/forms-component/TextInput.tsx index 006aca991f3f7ea1e1bcbc673303343fa2dc53c7..7afc9ab72526672eb783d702f0966aed0f7bb51a 100644 --- a/client/src/components/forms-component/TextInput.tsx +++ b/client/src/components/forms-component/TextInput.tsx @@ -45,7 +45,7 @@ export function TextInput(props: TextInputProps) { ) return ( - <div class={clsx(props.class)}> + <div class={clsx("w-full", props.class)}> <InputLabel name={props.name} label={props.label} diff --git a/client/src/components/forms/AdditionnalInfoForm.tsx b/client/src/components/forms/AdditionnalInfoForm.tsx index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..77290903dd6dd523ac662563169cf4e24c74ee4d 100644 --- a/client/src/components/forms/AdditionnalInfoForm.tsx +++ b/client/src/components/forms/AdditionnalInfoForm.tsx @@ -0,0 +1,241 @@ +import { useNavigate } from "@solidjs/router" +import { createForm, setValues, SubmitHandler } from "@modular-forms/solid" +import { TextInput } from "../forms-component/TextInput" +import { Checkbox } from "../forms-component/Checkbox" +import { ParticipantInfo } from "../../binding/ParticipantInfo" +import { Select } from "../forms-component/Select" +import { FileInput } from "../forms-component/FileInput" +import { getParticipant, patchParticipantInfo } from "../../request/routes" +import { createEffect } from "solid-js" +import { t } from "../../stores/locale" + +export function AdditionalInfoForm() { + const navigate = useNavigate() + const [loginForm, { Form, Field }] = createForm<ParticipantInfo>() + + const handleSubmit: SubmitHandler<ParticipantInfo> = async ( + values, + event, + ) => { + event.preventDefault() + const response = await patchParticipantInfo(values) + if (response && response.status == 201) { + navigate("/dashboard") + } else { + console.log(response) + } + } + + createEffect(async () => { + const id = localStorage.getItem("id") + if (!id) return + const response = await getParticipant(id) + if (!response.error) { + const participant = response as ParticipantInfo + setValues(loginForm, participant) + } + }) + + return ( + <Form + class="flex w-1/2 flex-col space-y-6" + action="#" + method="post" + onSubmit={handleSubmit} + > + <Field name="medical_conditions"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.medicalConditionsLabel")} + type="text" + placeholder={t("additionalInfo.medicalConditions")} + /> + )} + </Field> + + <Field name="allergies"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.allergiesLabel")} + type="text" + placeholder={t("additionalInfo.allergies")} + /> + )} + </Field> + + <Field name="pronouns"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.pronounsLabel")} + type="text" + placeholder={t("additionalInfo.pronouns")} + /> + )} + </Field> + + <Field name="is_vegetarian" type="boolean"> + {(field, props) => ( + <Checkbox + {...props} + checked={field.value || false} + error={field.error} + label={t("additionalInfo.vegetarianLabel")} + /> + )} + </Field> + + <Field name="phone_number"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.phoneNumberLabel")} + type="text" + placeholder={t("additionalInfo.phoneNumber")} + /> + )} + </Field> + + <Field name="tshirt_size"> + {(field, props) => ( + <Select + {...props} + value={field.value || "m"} + error={field.error} + label={t("additionalInfo.tshirtSize")} + options={[ + { + label: "XS", + value: "xs", + }, + { + label: "S", + value: "s", + }, + { + label: "M", + value: "m", + }, + { + label: "L", + value: "l", + }, + { + label: "XL", + value: "xl", + }, + { + label: "2XL", + value: "xxl", + }, + ]} + /> + )} + </Field> + + <Field name="emergency_contact"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.emergencyContactLabel")} + type="text" + placeholder={t("additionalInfo.emergencyContact")} + /> + )} + </Field> + + <Field name="has_monthly_opus_card" type="boolean"> + {(field, props) => ( + <Checkbox + {...props} + error={field.error} + label={t("additionalInfo.hasMonthlyOpusCard")} + /> + )} + </Field> + + <Field name="reduced_mobility"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label={t("additionalInfo.reducedMobilityLabel")} + type="text" + placeholder={t("additionalInfo.reducedMobility")} + /> + )} + </Field> + + <Field name="study_proof" type="File"> + {(field, props) => ( + <FileInput + {...props} + value={field.value} + error={field.error} + label={t("additionalInfo.studyProofLabel")} + accept="image/*,.pdf,.doc,.docx,.odt,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" + /> + )} + </Field> + + <Field name="photo" type="File"> + {(field, props) => ( + <FileInput + {...props} + value={field.value} + error={field.error} + label={t("additionalInfo.photoLabel")} + accept="image/*" + /> + )} + </Field> + + <Field name="cv" type="File"> + {(field, props) => ( + <FileInput + {...props} + value={field.value} + error={field.error} + label={t("additionalInfo.cvLabel")} + accept=".pdf,.doc,.docx,.odt,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" + /> + )} + </Field> + + <Field name="comments"> + {(field, props) => ( + <TextInput + {...props} + value={field.value || ""} + error={field.error} + label="Commentaires" + type="text" + placeholder="Commentaires" + /> + )} + </Field> + + <div> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-light-highlight px-5 py-4 text-xl font-semibold leading-6 text-white shadow-sm hover:bg-light-highlight focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-light-highlight" + > + Mettre à jour les renseignements personnels + </button> + </div> + </Form> + ) +} diff --git a/client/src/components/forms/EmailResetForm.tsx b/client/src/components/forms/EmailResetForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4248fe79e1e65bbd1cdf0bce0bb6d888e6cc619a --- /dev/null +++ b/client/src/components/forms/EmailResetForm.tsx @@ -0,0 +1,68 @@ +import { + createForm, + email, + required, + SubmitHandler, +} from "@modular-forms/solid" +import { createSignal } from "solid-js" +import { EmailResetPayload } from "../../binding/EmailResetPayload" +import { resetEmail } from "../../request/routes" +import { t } from "../../stores/locale" +import { SubmitError } from "../forms-component/SubmitError" +import { SubmitSuccess } from "../forms-component/SubmitSuccess" +import { TextInput } from "../forms-component/TextInput" + +export function EmailResetForm() { + const [_loginForm, { Form, Field }] = createForm<EmailResetPayload>() + + const [error, setError] = createSignal<string | null>(null) + const [success, setSuccess] = createSignal<string | null>(null) + + const handleSubmit: SubmitHandler<EmailResetPayload> = async ( + values, + event, + ) => { + event.preventDefault() + const request = await resetEmail(values) + if (request.status !== 200) { + setError(t("loginPage.badLogin")) + setSuccess(null) + } else { + setError(null) + setSuccess(t("loginPage.resetEmailSent")) + } + } + return ( + <Form class="space-y-6" onSubmit={handleSubmit}> + <Field + name="email" + validate={[ + required(t("loginPage.requiredEmail")), + email(t("loginPage.invalidEmail")), + ]} + > + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + label={t("loginPage.email")} + type="email" + placeholder="exemple@courriel.com" + required + /> + )} + </Field> + <div> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-light-highlight py-3 font-semibold text-white shadow-sm" + > + {t("loginPage.signIn")} + </button> + <SubmitSuccess success={success} /> + <SubmitError error={error} /> + </div> + </Form> + ) +} diff --git a/client/src/components/forms/LoginForm.tsx b/client/src/components/forms/LoginForm.tsx index 8e6198f0a8d3b2adf82dfdaabd1389cadde8099b..397c1b767eef343dffe69e19b6d9a68785cd08bd 100644 --- a/client/src/components/forms/LoginForm.tsx +++ b/client/src/components/forms/LoginForm.tsx @@ -12,6 +12,7 @@ import { TextInput } from "../forms-component/TextInput" import { SubmitError } from "../forms-component/SubmitError" import { createSignal } from "solid-js" import { t } from "../../stores/locale" +import PrefetchLink from "../PrefetchLink" export default function LoginForm() { const [_loginForm, { Form, Field }] = createForm<AuthPayload>() @@ -32,61 +33,67 @@ export default function LoginForm() { } return ( - <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> - <Form class="space-y-6" onSubmit={handleSubmit}> - <Field - name="email" - validate={[ - required(t("loginPage.requiredEmail")), - email(t("loginPage.invalidEmail")), - ]} - > - {(field, props) => ( - <TextInput - {...props} - value={field.value} - error={field.error} - label={t("loginPage.email")} - type="email" - placeholder="exemple@courriel.com" - required - /> - )} - </Field> + <Form class="space-y-6" onSubmit={handleSubmit}> + <Field + name="email" + validate={[ + required(t("loginPage.requiredEmail")), + email(t("loginPage.invalidEmail")), + ]} + > + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + label={t("loginPage.email")} + type="email" + placeholder="exemple@courriel.com" + required + /> + )} + </Field> - <Field - name="password" - validate={[ - required(t("loginPage.requiredPassword")), - minLength( - 8, - "You password must have 8 characters or more.", - ), - ]} + <Field + name="password" + validate={[ + required(t("loginPage.requiredPassword")), + minLength( + 8, + "You password must have 8 characters or more.", + ), + ]} + > + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + type="password" + label={t("loginPage.password")} + placeholder="********" + required + /> + )} + </Field> + <PrefetchLink + to="/forgotten-password" + file="ForgottenPassword" + class="block" + > + <span class="text-green-500"> + {t("loginPage.forgotPassword")} + </span> + </PrefetchLink> + <div> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-light-highlight py-3 font-semibold text-white shadow-sm" > - {(field, props) => ( - <TextInput - {...props} - value={field.value} - error={field.error} - type="password" - label={t("loginPage.password")} - placeholder="********" - required - /> - )} - </Field> - - <div> - <button - type="submit" - class="flex w-full justify-center rounded-md bg-light-highlight py-3 font-semibold text-white shadow-sm" - > - {t("loginPage.signIn")} - </button> - <SubmitError error={error} /> - </div> - </Form> - </div> + {t("loginPage.signIn")} + </button> + <SubmitError error={error} /> + </div> + </Form> ) } diff --git a/client/src/components/forms/NewPasswordForm.tsx b/client/src/components/forms/NewPasswordForm.tsx index e38a66cbf5dfd6f24a8a16cde9487412ed5ca9e8..a3849abe648a85b46ec068e0710b587875e7594f 100644 --- a/client/src/components/forms/NewPasswordForm.tsx +++ b/client/src/components/forms/NewPasswordForm.tsx @@ -3,15 +3,19 @@ import { ChangePasswordPayload } from "../../binding/ChangePasswordPayload" import { changePassword } from "../../request/routes" import { TextInput } from "../forms-component/TextInput" import { SubmitError } from "../forms-component/SubmitError" -import { createSignal } from "solid-js" +import { createEffect, createSignal } from "solid-js" export function NewPassword() { const [_form, { Form, Field }] = createForm<ChangePasswordPayload>() const [error, setError] = createSignal<string | null>(null) const onSubmit = async (data: ChangePasswordPayload) => { - await changePassword(data) + const response = await changePassword(data) + if (response.error) { + setError("Erreur lors du changement de mot de passe") + } } + createEffect(async () => {}) return ( <Form onSubmit={onSubmit} class="flex flex-col gap-8"> <Field name="new_password"> diff --git a/client/src/components/forms/ParticipantForm.tsx b/client/src/components/forms/ParticipantForm.tsx index 7baa521aa99235cf945a9028ae533d0a585bed7c..8de21106d4001d6c078e1232b0e9b38ec31a8e5c 100644 --- a/client/src/components/forms/ParticipantForm.tsx +++ b/client/src/components/forms/ParticipantForm.tsx @@ -1,4 +1,4 @@ -import { Info, PlusCircle, Trash } from "phosphor-solid-js" +import { PlusCircle, Trash } from "phosphor-solid-js" import { MinimalParticipant } from "../../binding/MinimalParticipant" import { createResource, For } from "solid-js" import { createForm } from "@modular-forms/solid" @@ -29,20 +29,26 @@ function ParticipantRow(props: ParticipantRowProps) { <td class="p-2 text-center">{p.email}</td> <td class="p-2 text-center">{p.competition}</td> <td class="p-2 text-center">{p.role}</td> + {localStorage.getItem("role") === "organizer" && ( + <td class="p-2 text-center">{p.university}</td> + )} <td class="flex flex-row gap-4 p-2 text-center"> - <button + {/* <button type="button" class="rounded bg-blue-500 p-1 font-bold text-white hover:bg-blue-700" > <Info class="h-8 w-8"></Info> - </button> - <button - type="button" - class="rounded bg-red-500 p-1 font-bold text-white hover:bg-red-700" - onClick={deleteParticipantOnClick} - > - <Trash class="h-8 w-8"></Trash> - </button> + </button> */} + + {localStorage.getItem("role") !== "volunteer" && ( + <button + type="button" + class="rounded bg-red-500 p-1 font-bold text-white hover:bg-red-700" + onClick={deleteParticipantOnClick} + > + <Trash class="h-8 w-8"></Trash> + </button> + )} </td> </tr> ) @@ -55,6 +61,43 @@ async function fetchWrapper() { return participants } +function getGivableRole() { + const role = localStorage.getItem("role") + if (role == "organizer") { + return [ + { + label: "CO/Directeur", + value: "organizer", + }, + { + label: "Volontaire", + value: "volunteer", + }, + { + label: "Chef", + value: "chef", + }, + { + label: "Participant", + value: "participant", + }, + ] + } else if (role == "chef") { + return [ + { + label: "Chef", + value: "chef", + }, + { + label: "Participant", + value: "participant", + }, + ] + } else { + return [] + } +} + export default function ParticipantForm() { const [user, { refetch }] = createResource(fetchWrapper) const [_form, { Form, Field }] = createForm<MinimalParticipant>() @@ -84,6 +127,11 @@ export default function ParticipantForm() { <th class="border-b border-gray-300 p-2 text-center"> Rôle </th> + {localStorage.getItem("role") === "organizer" && ( + <th class="border-b border-gray-300 p-2 text-center"> + Université + </th> + )} <th class="border-b border-gray-300 p-2 text-center"> Actions </th> @@ -98,141 +146,198 @@ export default function ParticipantForm() { /> )} </For> - <tr class="border-b border-gray-200"> - <td class="p-2"> - <Field name="first_name"> - {(field, props) => ( - <TextInput - {...props} - value={field.value} - error={field.error} - class="w-52" - type="text" - placeholder="Prénom" - required - /> - )} - </Field> - </td> - <td class="p-2"> - <Field name="last_name"> - {(field, props) => ( - <TextInput - {...props} - value={field.value} - error={field.error} - class="w-52" - type="text" - placeholder="Nom" - required - /> - )} - </Field> - </td> - <td class="p-2"> - <Field name="email"> - {(field, props) => ( - <TextInput - {...props} - value={field.value} - error={field.error} - type="email" - placeholder="exemple@courriel.com" - required - /> - )} - </Field> - </td> - <td class="p-2"> - <Field name="competition"> - {(field, props) => ( - <Select - {...props} - value={field.value} - error={field.error} - options={[ - { - label: "Aucune", - value: "none", - }, - { - label: "Conception Senior", - value: "conception_senior", - }, - { - label: "Conception Junior", - value: "conception_junior", - }, - { - label: "Débats oratoires", - value: "debats_oratoires", - }, - { - label: "Reingénierie", - value: "reingenierie", - }, - { - label: "Génie Conseil", - value: "genie_conseil", - }, - { - label: "Communication Scientifique", - value: "communication_scientifique", - }, - { - label: "Programmation", - value: "programmation", - }, - { - label: "Conception innovatrice", - value: "conception_innovatrice", - }, - { - label: "Cycle supérieur", - value: "cycle_superieur", - }, - ]} - required - /> - )} - </Field> - </td> - <td class="p-2"> - <Field name="role"> - {(field, props) => ( - <Select - {...props} - value={field.value} - error={field.error} - options={[ - { - label: "CO/Directeur", - value: "organizer", - }, - { - label: "Volontaire", - value: "volunteer", - }, - { - label: "Chef", - value: "chef", - }, - { - label: "Participant", - value: "participant", - }, - ]} - required - /> - )} - </Field> - </td> - <td class="p-2"> - <button class="rounded bg-green-500 p-1 font-bold text-white hover:bg-green-700"> - <PlusCircle class="h-8 w-8"></PlusCircle> - </button> - </td> - </tr> + {localStorage.getItem("role") !== "volunteer" && ( + <tr class="border-b border-gray-200"> + <td class="p-2"> + <Field name="first_name"> + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + class="w-52" + type="text" + placeholder="Prénom" + required + /> + )} + </Field> + </td> + <td class="p-2"> + <Field name="last_name"> + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + class="w-52" + type="text" + placeholder="Nom" + required + /> + )} + </Field> + </td> + <td class="p-2"> + <Field name="email"> + {(field, props) => ( + <TextInput + {...props} + value={field.value} + error={field.error} + type="email" + placeholder="exemple@courriel.com" + required + /> + )} + </Field> + </td> + <td class="p-2"> + <Field name="competition"> + {(field, props) => ( + <Select + {...props} + value={field.value} + error={field.error} + options={[ + { + label: "Aucune", + value: "none", + }, + { + label: "Conception Senior", + value: "conception_senior", + }, + { + label: "Conception Junior", + value: "conception_junior", + }, + { + label: "Débats oratoires", + value: "debats_oratoires", + }, + { + label: "Reingénierie", + value: "reingenierie", + }, + { + label: "Génie Conseil", + value: "genie_conseil", + }, + { + label: "Communication Scientifique", + value: "communication_scientifique", + }, + { + label: "Programmation", + value: "programmation", + }, + { + label: "Conception innovatrice", + value: "conception_innovatrice", + }, + { + label: "Cycle supérieur", + value: "cycle_superieur", + }, + ]} + required + /> + )} + </Field> + </td> + <td class="p-2"> + <Field name="role"> + {(field, props) => ( + <Select + {...props} + value={field.value} + error={field.error} + options={getGivableRole()} + required + /> + )} + </Field> + </td> + {localStorage.getItem("role") === "organizer" && ( + <td class="p-2"> + <Field name="university"> + {(field, props) => ( + <Select + {...props} + value="none" + error={field.error} + options={[ + { + label: "UQAC", + value: "uqac", + }, + { + label: "UQAR", + value: "uqar", + }, + { + label: "UQAT", + value: "uqat", + }, + { + label: "UQO", + value: "uqo", + }, + { + label: "UQTR", + value: "uqtr", + }, + { + label: "McGill", + value: "mcgill", + }, + { + label: "McGill Macdonald", + value: "mcgill_macdonald", + }, + { + label: "Concordia", + value: "concordia", + }, + { + label: "ETS", + value: "ets", + }, + { + label: "PolyMTL", + value: "polymtl", + }, + { + label: "ULaval", + value: "ulaval", + }, + { + label: "ULaval Agroalimentaire", + value: "ulaval-agriculture", + }, + { + label: "UDS", + value: "uds", + }, + { + label: "Aucune", + value: "none", + }, + ]} + required + /> + )} + </Field> + </td> + )} + <td class="p-2"> + <button class="rounded bg-green-500 p-1 font-bold text-white hover:bg-green-700"> + <PlusCircle class="h-8 w-8"></PlusCircle> + </button> + </td> + </tr> + )} </tbody> </table> </Form> diff --git a/client/src/i18n/en.ts b/client/src/i18n/en.ts index 56780541709b39b90cea1246355346cb88b91035..3d63291de8436137e4ede6f4079a5462e80ef542 100644 --- a/client/src/i18n/en.ts +++ b/client/src/i18n/en.ts @@ -96,6 +96,30 @@ const loginPage = { email: "Email", password: "Password", signIn: "Sign in", + forgotPassword: "Forgot password?", + resetEmailSent: "An email has been sent to reset your password.", +} + +const additionalInfo = { + medicalConditions: "Medical conditions", + medicalConditionsLabel: + "Please specify any medical conditions that we should be aware of.", + allergies: "Allergies", + allergiesLabel: "Please specify any allergies that we should be aware of.", + pronouns: "Pronouns", + pronounsLabel: "Please specify your pronouns.", + vegetarianLabel: "Are you vegetarian?", + phoneNumber: "Phone number", + phoneNumberLabel: "Please specify your phone number.", + tshirtSize: "T-shirt size", + emergencyContact: "Emergency contact", + emergencyContactLabel: "Please specify your emergency contact.", + hasMonthlyOpusCard: "Do you have a monthly OPUS card?", + reducedMobilityLabel: "Any acommodation for reduced mobility?", + reducedMobility: "What do you need?", + studyProofLabel: "Study proof", + photoLabel: "Photo", + cvLabel: "CV", } export const dict = { @@ -109,5 +133,6 @@ export const dict = { documents: documents, login: "Login", loginPage: loginPage, + additionalInfo: additionalInfo, madeBy: "Made by", } diff --git a/client/src/i18n/fr.ts b/client/src/i18n/fr.ts index dff81fcfb3bbabdfa1071fa58f64d0a9a03e14ee..1195552625ac38c0e569a439ab33e2c06fa85df6 100644 --- a/client/src/i18n/fr.ts +++ b/client/src/i18n/fr.ts @@ -96,6 +96,31 @@ const loginPage = { email: "Courriel", password: "Mot de passe", signIn: "Se connecter", + forgotPassword: "Mot de passe oublié?", + resetEmailSent: "Courriel de réinitialisation envoyé", +} + +const additionalInfo = { + medicalConditions: "Conditions médicales", + medicalConditionsLabel: + "Veuillez spécifier toute condition médicale dont nous devrions être informés.", + allergies: "Allergies", + allergiesLabel: + "Veuillez spécifier toute allergie dont nous devrions être informés.", + pronouns: "Pronoms", + pronounsLabel: "Veuillez spécifier vos pronoms.", + vegetarianLabel: "Êtes-vous végétarien?", + phoneNumber: "Numéro de téléphone", + phoneNumberLabel: "Veuillez spécifier votre numéro de téléphone.", + tshirtSize: "Taille de T-shirt", + emergencyContact: "Contact d'urgence", + emergencyContactLabel: "Veuillez spécifier votre contact d'urgence.", + hasMonthlyOpusCard: "Avez-vous une carte OPUS mensuelle?", + reducedMobilityLabel: "Besoin d'aménagement pour mobilité réduite?", + reducedMobility: "Qu'avez-vous besoin?", + studyProofLabel: "Preuve d'études", + photoLabel: "Photo", + cvLabel: "CV", } export const dict = { @@ -110,4 +135,5 @@ export const dict = { login: "Connexion", madeBy: "Créé par", loginPage: loginPage, + additionalInfo: additionalInfo, } diff --git a/client/src/index.tsx b/client/src/index.tsx index 101035db7403a5db3a78704b71cd9d42bf2c98dc..1d662c9f3979cc58eb72419f42c14f81e210c216 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -27,6 +27,7 @@ const Dashboard = lazy(() => import("./routes/Dashboard")) const AdditionalForm = lazy(() => import("./routes/AdditionalForm")) const ListParticipant = lazy(() => import("./routes/ListParticipant")) const ChangePassword = lazy(() => import("./routes/ChangePassword")) +const ForgottenPassword = lazy(() => import("./routes/ForgottenPassword")) const app = document.getElementById("app") if (app) { @@ -47,6 +48,10 @@ if (app) { path="/change-password/:token?" component={ChangePassword} /> + <Route + path="/forgotten-password" + component={ForgottenPassword} + /> <Route path="*" component={NotFound} /> </Router> ), diff --git a/client/src/request/fetch_wrapper.ts b/client/src/request/fetch_wrapper.ts index ed6e17c1d3b87349a07faa97d6ca2f8a83e5b76b..58a6e1df78e07df49218bdcc449c0f674153af89 100644 --- a/client/src/request/fetch_wrapper.ts +++ b/client/src/request/fetch_wrapper.ts @@ -72,6 +72,7 @@ export async function fetch_patch(url: string, body: any) { return await fetch(import.meta.env.VITE_API_URL + url, { method: "PATCH", headers: { + "Content-Type": "application/json", Authorization: "Bearer " + token, }, body: JSON.stringify(body), diff --git a/client/src/request/routes.ts b/client/src/request/routes.ts index 581276fb45afea74b85227882109c75b07992097..0f9976d5d5967606cbbd5b06e9b61de81df467c8 100644 --- a/client/src/request/routes.ts +++ b/client/src/request/routes.ts @@ -1,10 +1,13 @@ import { AuthPayload } from "../binding/AuthPayload" import { ChangePasswordPayload } from "../binding/ChangePasswordPayload" +import { EmailResetPayload } from "../binding/EmailResetPayload" import { MinimalParticipant } from "../binding/MinimalParticipant" +import { ParticipantInfo } from "../binding/ParticipantInfo" import { ParticipantPreview } from "../binding/ParticipantPreview" import { fetch_delete, fetch_get, + fetch_patch, fetch_post, fetch_post_no_token, fetch_put, @@ -22,7 +25,11 @@ export async function fetchParticipants() { export async function submitMinimalParticipant( participant: MinimalParticipant, ) { - return await fetch_post("/participant", participant) + const payload: any = participant + if (localStorage.getItem("role") == "chef") { + payload.university = localStorage.getItem("university") + } + return await fetch_post("/participant", payload) } export async function deleteParticipant(p: ParticipantPreview) { @@ -49,3 +56,60 @@ export async function testAuth() { } return await request.json() } + +export async function resetEmail(email: EmailResetPayload) { + return await fetch_post_no_token("/password", email) +} + +function getBase64(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + // Event listener when the file reading is completed + reader.onload = () => { + // `reader.result` contains the base64 string + const base64String = reader.result as string + const base64StringSplit = base64String.split(",")[1] + if (!base64StringSplit) { + reject(new Error("Failed to convert file to base64")) + } + resolve(base64StringSplit) // Removing the base64 prefix + } + + // Event listener for any error + reader.onerror = () => { + reject(new Error("Failed to convert file to base64")) + } + + // Reading the file as a data URL (which contains base64 encoded string) + reader.readAsDataURL(file) + }) +} + +export async function patchParticipantInfo(info: ParticipantInfo) { + const payload: any = info + if (info.study_proof) { + payload.study_proof = await getBase64(info.study_proof) + } else { + payload.study_proof = "" + } + if (info.photo) { + payload.photo = await getBase64(info.photo) + } else { + payload.photo = "" + } + if (info.cv) { + payload.cv = await getBase64(info.cv) + } else { + payload.cv = "" + } + return await fetch_patch("/participant", payload) +} + +export async function getParticipant(id: string) { + const response = await fetch_get("/participant/" + id) + if (!response) { + return undefined + } + return await response.json() +} diff --git a/client/src/routes/AdditionalForm.tsx b/client/src/routes/AdditionalForm.tsx index 16c04e3e0a80a063c509da0fc2978dc7c48a4eac..378b7fb6cb6bede2f7855b2acf7f1157a998f48c 100644 --- a/client/src/routes/AdditionalForm.tsx +++ b/client/src/routes/AdditionalForm.tsx @@ -1,113 +1,18 @@ -import { createForm, SubmitHandler } from "@modular-forms/solid" import FixedImage from "../components/FixedImage" -import { useNavigate } from "@solidjs/router" +import { AdditionalInfoForm } from "../components/forms/AdditionnalInfoForm" import { ProtectedRoute } from "../components/ProtectedRoute" -import { Participant } from "../binding/Participant" export default function AdditionalForm() { - const navigate = useNavigate() - const [_loginForm, { Form, Field }] = createForm<Participant>() - - const handleSubmit: SubmitHandler<Participant> = (values, event) => { - event.preventDefault() - console.log(values) - navigate("/leader") - } - - interface Props { - id: any - name: string - } - - function TextField(prop: Props) { - return ( - <Field name={prop.id}> - {(_field, props) => ( - <div> - <label - for={prop.id} - class="block text-sm font-medium leading-6 text-gray-900" - > - {prop.name} - </label> - <div class="mt-2"> - <input - {...props} - id={prop.id} - name={prop.id} - type="text" - required - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-light-highlight sm:text-sm sm:leading-6" - /> - </div> - </div> - )} - </Field> - ) - } - return ( <ProtectedRoute> <div class="flex w-full flex-col items-center justify-center"> <FixedImage url="/banners/documents.svg" height="32rem"> <h1 class="text-center font-futur text-6xl text-white"> - {"Tableau de bord des chefs"} + {"Tableau de bord"} </h1> </FixedImage> - <div class="-mt-32 sm:mx-auto sm:w-full sm:max-w-sm"> - <Form - class="space-y-6" - action="#" - method="post" - onSubmit={handleSubmit} - > - <TextField - id="medical_conditions" - name="Conditions médicales" - /> - <TextField id="allergies" name="Allergies" /> - <TextField id="pronouns" name="Pronoms" /> - <TextField - id="phone_number" - name="Numéro de téléphone" - /> - <TextField id="tshirt_size" name="Taille de T-shirt" /> - <TextField id="comments" name="Commentaires" /> - <TextField - id="emergency_contact" - name="Contact d'urgence" - /> - - <Field name="has_monthly_opus_card"> - {(_field, props) => ( - <div class="flex items-center"> - <input - {...props} - id="has_monthly_opus_card" - type="checkbox" - value="" - class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600" - /> - <label - for="has_monthly_opus_card" - class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300" - > - Je possède un abonnement pour la carte - OPUS. - </label> - </div> - )} - </Field> - - <div> - <button - type="submit" - class="flex w-full justify-center rounded-md bg-light-highlight px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-light-highlight focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-light-highlight" - > - Envoyer les renseignements personnels - </button> - </div> - </Form> + <div class="-mt-32 flex flex-col items-center"> + <AdditionalInfoForm /> </div> </div> </ProtectedRoute> diff --git a/client/src/routes/Dashboard.tsx b/client/src/routes/Dashboard.tsx index 0816014efd080148712d7338d6d8af461311ee6d..b404f89634669547a989f659fe79ab229c29fecc 100644 --- a/client/src/routes/Dashboard.tsx +++ b/client/src/routes/Dashboard.tsx @@ -37,9 +37,14 @@ export default function Dashboard() { <PrefetchLink to="/additional-form" file="AdditionalForm"> <BigButton text="Changer mes renseignements personnels" /> </PrefetchLink> - <PrefetchLink to="/list-participant" file="ListParticipant"> - <BigButton text="Liste des participants" /> - </PrefetchLink> + {localStorage.getItem("role") !== "participant" && ( + <PrefetchLink + to="/list-participant" + file="ListParticipant" + > + <BigButton text="Liste des participants" /> + </PrefetchLink> + )} <PrefetchLink to="/change-password" file="ChangePassword"> <BigButton text="Changer le mot de passe" /> </PrefetchLink> diff --git a/client/src/routes/ForgottenPassword.tsx b/client/src/routes/ForgottenPassword.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fcc7e4d7e6ac738894025ca64cedca9ca5261dea --- /dev/null +++ b/client/src/routes/ForgottenPassword.tsx @@ -0,0 +1,18 @@ +import FixedImage from "../components/FixedImage" +import { EmailResetForm } from "../components/forms/EmailResetForm" +import { t } from "../stores/locale" + +export default function ForgottenPassword() { + return ( + <div class="flex w-full flex-col items-center justify-center"> + <FixedImage url="/banners/documents.svg" height="32rem"> + <h1 class="text-center font-futur text-6xl text-white"> + {t("loginPage.forgotPassword")} + </h1> + </FixedImage> + <div class="-mt-32 flex h-full w-full flex-row items-center justify-center gap-4 p-4 font-futur text-xl font-bold"> + <EmailResetForm /> + </div> + </div> + ) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000000000000000000000000000000000..b706e5ac9de007b3d16b6fabe4c5c4318e2150fc --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1729413321, + "narHash": "sha256-I4tuhRpZFa6Fu6dcH9Dlo5LlH17peT79vx1y1SpeKt0=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "1997e4aa514312c1af7e2bda7fad1644e778ff26", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 5a30df4c0efa90d091118e19c56433fddc628653..c93f588a8c1b0def16ac2c56ccc98340554ee3c0 100644 --- a/flake.nix +++ b/flake.nix @@ -29,12 +29,12 @@ packages = with pkgs; [ bun ]; - DB_USER="postgres"; - DB_PASSWORD="password"; - DB_NAME="dev"; - DB_PORT="5432"; - DB_HOST="localhost"; - DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"; + POSTGRES_USER="postgres"; + POSTGRES_PASSWORD="password"; + POSTGRES_NAME="dev"; + POSTGRES_PORT="5432"; + POSTGRES_HOST="localhost"; + DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_NAME}"; TS_RS_EXPORT_DIR = "../client/src/binding"; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.openssl ]; diff --git a/server/.sqlx/query-01bd25a5698e76cdec3ae0eb1a638059914202a09aa606af11f38d7848f37c67.json b/server/.sqlx/query-01bd25a5698e76cdec3ae0eb1a638059914202a09aa606af11f38d7848f37c67.json deleted file mode 100644 index 4f74f8d77594a8f983649021ed0c37be7bdfbeb5..0000000000000000000000000000000000000000 --- a/server/.sqlx/query-01bd25a5698e76cdec3ae0eb1a638059914202a09aa606af11f38d7848f37c67.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE participants SET (medical_conditions, allergies, supper, pronouns, competition, phone_number, tshirt_size, comments, emergency_contact, has_monthly_opus_card, reduced_mobility, study_proof, photo, cv)\n = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) WHERE id = $15", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - { - "Custom": { - "name": "competition", - "kind": { - "Enum": [ - "none", - "conception_senior", - "conception_junior", - "debats_oratoires", - "reingenierie", - "genie_conseil", - "communication_scientifique", - "programmation", - "conception_innovatrice", - "cycle_superieur" - ] - } - } - }, - "Text", - "Text", - "Text", - "Text", - "Bool", - "Text", - "Bytea", - "Bytea", - "Bytea", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "01bd25a5698e76cdec3ae0eb1a638059914202a09aa606af11f38d7848f37c67" -} diff --git a/server/.sqlx/query-11746d3bbe0047f2995a6366ba00cfd84d0dbc1c83bef77cfc83af19e69d5b94.json b/server/.sqlx/query-11746d3bbe0047f2995a6366ba00cfd84d0dbc1c83bef77cfc83af19e69d5b94.json index 769fa9905f54a907d716ccb1585bf552ecd22650..2cd25e169e027ed22cf1ec5cfa5ac5786afccb0b 100644 --- a/server/.sqlx/query-11746d3bbe0047f2995a6366ba00cfd84d0dbc1c83bef77cfc83af19e69d5b94.json +++ b/server/.sqlx/query-11746d3bbe0047f2995a6366ba00cfd84d0dbc1c83bef77cfc83af19e69d5b94.json @@ -42,7 +42,29 @@ } } }, - "Text" + { + "Custom": { + "name": "university", + "kind": { + "Enum": [ + "uqac", + "uqar", + "uqat", + "uqo", + "uqtr", + "mcgill", + "mcgill_macdonald", + "concordia", + "ets", + "polymtl", + "ulaval", + "ulaval-agriculture", + "uds", + "none" + ] + } + } + } ] }, "nullable": [] diff --git a/server/.sqlx/query-668d7e49b4dae36348dbddca35bd8292cb59f2c0f8c20f4d0ac5faf058b183ac.json b/server/.sqlx/query-668d7e49b4dae36348dbddca35bd8292cb59f2c0f8c20f4d0ac5faf058b183ac.json index e8d8cfab73ac0d12780207ac5dfe3f93188d66d0..db0e348f87eeaa1e60014bcdaa9c2e1efcee4ccc 100644 --- a/server/.sqlx/query-668d7e49b4dae36348dbddca35bd8292cb59f2c0f8c20f4d0ac5faf058b183ac.json +++ b/server/.sqlx/query-668d7e49b4dae36348dbddca35bd8292cb59f2c0f8c20f4d0ac5faf058b183ac.json @@ -6,7 +6,29 @@ "parameters": { "Left": [ "Uuid", - "Text" + { + "Custom": { + "name": "university", + "kind": { + "Enum": [ + "uqac", + "uqar", + "uqat", + "uqo", + "uqtr", + "mcgill", + "mcgill_macdonald", + "concordia", + "ets", + "polymtl", + "ulaval", + "ulaval-agriculture", + "uds", + "none" + ] + } + } + } ] }, "nullable": [] diff --git a/server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json b/server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json new file mode 100644 index 0000000000000000000000000000000000000000..34f167eaf2146e1ca99811d668eac3bd1041698a --- /dev/null +++ b/server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, role AS \"role: Role\", university AS \"university: University\" FROM participants WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "participant", + "organizer", + "volunteer", + "chef" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "university: University", + "type_info": { + "Custom": { + "name": "university", + "kind": { + "Enum": [ + "uqac", + "uqar", + "uqat", + "uqo", + "uqtr", + "mcgill", + "mcgill_macdonald", + "concordia", + "ets", + "polymtl", + "ulaval", + "ulaval-agriculture", + "uds", + "none" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15" +} diff --git a/server/.sqlx/query-4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb.json b/server/.sqlx/query-a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b.json similarity index 53% rename from server/.sqlx/query-4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb.json rename to server/.sqlx/query-a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b.json index 9c528615386e95f606d73bf734a1aa0963a53f9c..5ef37548fba369ddac240234ce3b4e192708889c 100644 --- a/server/.sqlx/query-4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb.json +++ b/server/.sqlx/query-a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, role AS \"role: Role\", password_hash, university FROM participants WHERE email = $1", + "query": "SELECT id, role AS \"role: Role\", password_hash, university AS \"university: University\" FROM participants WHERE email = $1", "describe": { "columns": [ { @@ -32,8 +32,30 @@ }, { "ordinal": 3, - "name": "university", - "type_info": "Text" + "name": "university: University", + "type_info": { + "Custom": { + "name": "university", + "kind": { + "Enum": [ + "uqac", + "uqar", + "uqat", + "uqo", + "uqtr", + "mcgill", + "mcgill_macdonald", + "concordia", + "ets", + "polymtl", + "ulaval", + "ulaval-agriculture", + "uds", + "none" + ] + } + } + } } ], "parameters": { @@ -48,5 +70,5 @@ false ] }, - "hash": "4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb" + "hash": "a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b" } diff --git a/server/.sqlx/query-b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294.json b/server/.sqlx/query-b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294.json new file mode 100644 index 0000000000000000000000000000000000000000..746f3d3cd9ed62f5f2696605234d9c9d493fac42 --- /dev/null +++ b/server/.sqlx/query-b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE participants SET (medical_conditions, allergies, is_vegetarian, pronouns, phone_number, tshirt_size, comments, emergency_contact, has_monthly_opus_card, reduced_mobility, study_proof, photo, cv)\n = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) WHERE id = $14", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool", + "Text", + "Text", + { + "Custom": { + "name": "tshirt_size", + "kind": { + "Enum": [ + "xs", + "s", + "m", + "l", + "xl", + "xxl" + ] + } + } + }, + "Text", + "Text", + "Bool", + "Text", + "Bytea", + "Bytea", + "Bytea", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294" +} diff --git a/server/.sqlx/query-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json b/server/.sqlx/query-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json deleted file mode 100644 index 59756034a61f8b1062a8ec64744fad4586f8cb26..0000000000000000000000000000000000000000 --- a/server/.sqlx/query-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, role AS \"role: Role\", university FROM participants WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "role: Role", - "type_info": { - "Custom": { - "name": "role", - "kind": { - "Enum": [ - "participant", - "organizer", - "volunteer", - "chef" - ] - } - } - } - }, - { - "ordinal": 2, - "name": "university", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce" -} diff --git a/server/launch_db.sh b/server/launch_db.sh index 82ab1660e23fa88ce54eb34844d0b467536181fb..3743a2e2a213b3efd0d28544a8b5e3d7b77a7361 100755 --- a/server/launch_db.sh +++ b/server/launch_db.sh @@ -14,17 +14,17 @@ set -x set -eo pipefail docker run \ - -e POSTGRES_USER=${DB_USER} \ - -e POSTGRES_PASSWORD=${DB_PASSWORD} \ - -p "${DB_PORT}":5432 \ + -e POSTGRES_USER=${POSTGRES_USER} \ + -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \ + -p "${POSTGRES_PORT}":5432 \ -d postgres -export PGPASSWORD=${DB_PASSWORD} +export PGPASSWORD=${POSTGRES_PASSWORD} until psql -h "${POSTGRES_HOST}" -U "${POSTGRES_USER}" -p "${POSTGRES_PORT}" -d "postgres" -c '\q'; do >&2 echo "Postgres is unavailable - sleeping" sleep 1 done ->&2 echo "Postgres is up and running on port ${DB_PORT}" +>&2 echo "Postgres is up and running on port ${POSTGRES_PORT}" bash configure_db.sh diff --git a/server/migrations/20241015182258_create_participants_table.sql b/server/migrations/20241015182258_create_participants_table.sql index 3c404ab8d14c6b99d50e370c123fba965eb4a737..e9eefbaccd8bfdd80644e28dd0b0bbf147520e50 100644 --- a/server/migrations/20241015182258_create_participants_table.sql +++ b/server/migrations/20241015182258_create_participants_table.sql @@ -2,6 +2,10 @@ CREATE TYPE ROLE AS ENUM ('participant', 'organizer', 'volunteer', 'chef'); CREATE TYPE COMPETITION AS ENUM ('none', 'conception_senior', 'conception_junior', 'debats_oratoires', 'reingenierie', 'genie_conseil', 'communication_scientifique', 'programmation', 'conception_innovatrice', 'cycle_superieur'); +CREATE TYPE UNIVERSITY AS ENUM ('uqac', 'uqar', 'uqat', 'uqo', 'uqtr', 'mcgill', 'mcgill_macdonald', 'concordia', 'ets', 'polymtl', 'ulaval', 'ulaval-agriculture', 'uds', 'none'); + +CREATE TYPE TSHIRT_SIZE AS ENUM ('xs', 's', 'm', 'l', 'xl', 'xxl'); + CREATE TABLE participants ( id UUID PRIMARY KEY, role ROLE NOT NULL, @@ -10,13 +14,14 @@ CREATE TABLE participants ( first_name TEXT NOT NULL, last_name TEXT NOT NULL, competition COMPETITION NOT NULL, - university TEXT NOT NULL, + university UNIVERSITY NOT NULL, medical_conditions TEXT, allergies TEXT, supper TEXT, + is_vegetarian BOOLEAN, pronouns TEXT, phone_number TEXT, - tshirt_size TEXT, + tshirt_size TSHIRT_SIZE, comments TEXT, emergency_contact TEXT, has_monthly_opus_card BOOLEAN, diff --git a/server/src/auth/claims.rs b/server/src/auth/claims.rs index d6cd920974a67872a7d653ca0aba957f6f0a1e0d..72dc09125baddfa83930d35cf5f00bd1ccd9fb63 100644 --- a/server/src/auth/claims.rs +++ b/server/src/auth/claims.rs @@ -1,4 +1,4 @@ -use crate::model::role::Role; +use crate::model::{role::Role, university::University}; use argon2::{password_hash::PasswordVerifier, Argon2, PasswordHash}; use axum::{async_trait, extract::FromRequestParts, http::request::Parts, RequestPartsExt}; use axum_extra::{ @@ -9,17 +9,19 @@ use chrono::Utc; use jsonwebtoken::{decode, Validation}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use ts_rs::TS; use uuid::Uuid; use crate::KEYS; use super::auth_error::AuthError; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(export)] pub struct Claims { pub id: Uuid, pub role: Role, - pub university: String, + pub university: University, pub exp: usize, } @@ -33,7 +35,7 @@ impl Claims { .and_utc() .timestamp() as usize; let info = sqlx::query!( - r#"SELECT id, role AS "role: Role", university FROM participants WHERE email = $1"#, + r#"SELECT id, role AS "role: Role", university AS "university: University" FROM participants WHERE email = $1"#, email ) .fetch_one(db) @@ -51,7 +53,7 @@ impl Claims { } pub async fn new(email: String, password: String, db: &PgPool) -> Option<Self> { let user = sqlx::query!( - r#"SELECT id, role AS "role: Role", password_hash, university FROM participants WHERE email = $1"#, + r#"SELECT id, role AS "role: Role", password_hash, university AS "university: University" FROM participants WHERE email = $1"#, email ) .fetch_one(db) diff --git a/server/src/bin/create_admin.rs b/server/src/bin/create_admin.rs index a878bf8e48a40deac20992a31bb85ed8e5b97241..e421db26b1c67c0d4cf1faa9feda94ececdaff18 100644 --- a/server/src/bin/create_admin.rs +++ b/server/src/bin/create_admin.rs @@ -1,5 +1,8 @@ use backend_cqi::{ - model::{competition::Competition, minimal_participant::MinimalParticipant, role::Role}, + model::{ + competition::Competition, minimal_participant::MinimalParticipant, role::Role, + university::University, + }, Result, }; use rand::distributions::{Alphanumeric, DistString}; @@ -18,10 +21,9 @@ async fn main() -> Result<()> { email: "mamanningham@cqi-qec.qc.ca".to_string(), competition: Competition::None, role: Role::Organizer, + university: University::Polymtl, }; - participant - .write_to_database(&password, &db, "".to_string()) - .await?; + participant.write_to_database(&password, &db).await?; println!("Password: {}", password); Ok(()) } diff --git a/server/src/model/minimal_participant.rs b/server/src/model/minimal_participant.rs index 1461713243f9c38fed1e34d15bf73f1a2d2e9e81..e96af409caa681df131a24849a68ecfc3282560a 100644 --- a/server/src/model/minimal_participant.rs +++ b/server/src/model/minimal_participant.rs @@ -5,7 +5,7 @@ use sqlx::PgPool; use ts_rs::TS; use uuid::Uuid; -use super::{competition::Competition, role::Role}; +use super::{competition::Competition, role::Role, university::University}; #[derive(Debug, Serialize, Deserialize, TS, sqlx::FromRow)] #[ts(export)] @@ -14,6 +14,7 @@ pub struct MinimalParticipant { pub last_name: String, pub email: String, pub competition: Competition, + pub university: University, pub role: Role, } @@ -24,12 +25,7 @@ impl MinimalParticipant { .await } - pub async fn write_to_database( - &self, - password: &str, - db: &PgPool, - university: String, - ) -> Result<(), sqlx::Error> { + pub async fn write_to_database(&self, password: &str, db: &PgPool) -> Result<(), sqlx::Error> { let id = Uuid::new_v4(); tracing::info!("Generated password: {}", password); // TODO: remove this debug line let salt = SaltString::generate(&mut OsRng); @@ -48,7 +44,7 @@ impl MinimalParticipant { self.first_name, self.last_name, self.competition as Competition, - university + self.university as University ) .execute(db) .await?; diff --git a/server/src/model/mod.rs b/server/src/model/mod.rs index 5c6b6a99f7e0dfb3a41eaee04b8496911f0d0882..babe45612533fbd96c9c0f86bbd7cec63502ae2e 100644 --- a/server/src/model/mod.rs +++ b/server/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod competition; pub mod minimal_participant; -pub mod participant; +pub mod participant_info; pub mod preview_participant; pub mod role; -pub mod staff; +pub mod tshirt_size; +pub mod university; diff --git a/server/src/model/participant.rs b/server/src/model/participant_info.rs similarity index 69% rename from server/src/model/participant.rs rename to server/src/model/participant_info.rs index c6d9aa834ed359dfad1ab4a157b494371392364a..834ee49439d84d1680931ed901ad80b9df6b9dfb 100644 --- a/server/src/model/participant.rs +++ b/server/src/model/participant_info.rs @@ -5,25 +5,20 @@ use sqlx::PgPool; use ts_rs::TS; use uuid::Uuid; -use super::{competition::Competition, role::Role}; use crate::utility::{deserialize_base64, serialize_base64}; +use super::{tshirt_size::TshirtSize, university::University}; + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, TS)] #[ts(export)] -pub struct Participant { - pub id: Uuid, - pub role: Role, - pub email: String, - pub first_name: String, - pub last_name: String, - pub university: String, +pub struct ParticipantInfo { pub medical_conditions: Option<String>, pub allergies: Option<String>, pub pronouns: Option<String>, pub supper: Option<String>, - pub competition: Option<Competition>, + pub is_vegetarian: Option<bool>, pub phone_number: Option<String>, - pub tshirt_size: Option<String>, + pub tshirt_size: Option<TshirtSize>, pub comments: Option<String>, pub emergency_contact: Option<String>, pub has_monthly_opus_card: Option<bool>, @@ -32,41 +27,52 @@ pub struct Participant { serialize_with = "serialize_base64", deserialize_with = "deserialize_base64" )] - #[ts(type = "string")] + #[ts(type = "File")] pub study_proof: Option<Vec<u8>>, #[serde( serialize_with = "serialize_base64", deserialize_with = "deserialize_base64" )] - #[ts(type = "string")] + #[ts(type = "File")] pub photo: Option<Vec<u8>>, #[serde( serialize_with = "serialize_base64", deserialize_with = "deserialize_base64" )] - #[ts(type = "string")] + #[ts(type = "File")] pub cv: Option<Vec<u8>>, } -impl Participant { - pub async fn get_participant(db: &PgPool) -> Result<Self, sqlx::Error> { +impl ParticipantInfo { + pub async fn get_participant(id: Uuid, db: &PgPool) -> Result<Self, sqlx::Error> { sqlx::query_as("SELECT * FROM participants WHERE id = $1") - .bind(Uuid::new_v4()) + .bind(id) + .fetch_one(db) + .await + } + + pub async fn get_participant_with_university( + id: Uuid, + university: University, + db: &PgPool, + ) -> Result<Self, sqlx::Error> { + sqlx::query_as("SELECT * FROM participants WHERE id = $1 AND university = $2") + .bind(id) + .bind(university) .fetch_one(db) .await } - pub async fn write_to_database(&self, db: &PgPool) -> Result<(), sqlx::Error> { + pub async fn write_to_database(&self, id: Uuid, db: &PgPool) -> Result<(), sqlx::Error> { sqlx::query!( - r#"UPDATE participants SET (medical_conditions, allergies, supper, pronouns, competition, phone_number, tshirt_size, comments, emergency_contact, has_monthly_opus_card, reduced_mobility, study_proof, photo, cv) - = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) WHERE id = $15"#, + r#"UPDATE participants SET (medical_conditions, allergies, is_vegetarian, pronouns, phone_number, tshirt_size, comments, emergency_contact, has_monthly_opus_card, reduced_mobility, study_proof, photo, cv) + = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) WHERE id = $14"#, self.medical_conditions, self.allergies, - self.supper, + self.is_vegetarian, self.pronouns, - self.competition as Option<Competition>, self.phone_number, - self.tshirt_size, + self.tshirt_size as Option<TshirtSize>, self.comments, self.emergency_contact, self.has_monthly_opus_card, @@ -74,7 +80,7 @@ impl Participant { self.study_proof, self.photo, self.cv, - self.id + id ) .execute(db) .await?; @@ -88,15 +94,15 @@ impl Participant { Ok(()) } - pub async fn delete_from_database_university( + pub async fn delete_from_database_with_university( id: Uuid, - university: String, + university: University, db: &PgPool, ) -> Result<(), sqlx::Error> { sqlx::query!( r#"DELETE FROM participants WHERE id = $1 AND university = $2"#, id, - university + university as University ) .execute(db) .await?; diff --git a/server/src/model/preview_participant.rs b/server/src/model/preview_participant.rs index c4f67ddbba4009d81eee6a21328a19d69900f213..79f7d2770d98ae0bf900a43b2b90d67f43069f80 100644 --- a/server/src/model/preview_participant.rs +++ b/server/src/model/preview_participant.rs @@ -3,7 +3,7 @@ use sqlx::PgPool; use ts_rs::TS; use uuid::Uuid; -use super::{competition::Competition, role::Role}; +use super::{competition::Competition, role::Role, university::University}; #[derive(Debug, Serialize, Deserialize, TS, sqlx::FromRow)] #[ts(export)] @@ -14,13 +14,14 @@ pub struct ParticipantPreview { pub email: String, pub role: Role, pub competition: Competition, + pub university: University, pub contain_cv: bool, } impl ParticipantPreview { pub async fn get_participants(db: &PgPool) -> Result<Vec<Self>, sqlx::Error> { sqlx::query_as::<_, Self>( - r#"SELECT id, first_name, last_name, email, role, competition, cv IS NOT NULL as contain_cv FROM participants"# + r#"SELECT id, first_name, last_name, email, role, competition, university, cv IS NOT NULL as contain_cv FROM participants"# ) .fetch_all(db) .await @@ -28,9 +29,9 @@ impl ParticipantPreview { pub async fn get_participants_from_university( db: &PgPool, - university: &str, + university: University, ) -> Result<Vec<Self>, sqlx::Error> { - sqlx::query_as("SELECT * FROM participants WHERE university_name = $1") + sqlx::query_as("SELECT id, first_name, last_name, email, role, competition, university, cv IS NOT NULL as contain_cv FROM participants WHERE university = $1 AND (role = 'participant' OR role = 'chef')") .bind(university) .fetch_all(db) .await diff --git a/server/src/model/role.rs b/server/src/model/role.rs index af2ee8ae026a4ed7a0e90999beed7c2e82764ac9..5c49f9faee4d6c95b99feb644de49dc5927da9c8 100644 --- a/server/src/model/role.rs +++ b/server/src/model/role.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; -#[derive(Copy, Clone, Debug, Serialize, Deserialize, sqlx::Type, TS)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, sqlx::Type, TS)] #[serde(rename_all = "snake_case")] #[sqlx(rename_all = "snake_case", type_name = "ROLE")] #[ts(export)] diff --git a/server/src/model/staff.rs b/server/src/model/staff.rs deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/server/src/model/tshirt_size.rs b/server/src/model/tshirt_size.rs new file mode 100644 index 0000000000000000000000000000000000000000..4aa1ec98125b39db1b6ab4621f2a517df2d0b4c1 --- /dev/null +++ b/server/src/model/tshirt_size.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, sqlx::Type, TS)] +#[serde(rename_all = "snake_case")] +#[sqlx(rename_all = "snake_case", type_name = "TSHIRT_SIZE")] +#[ts(export)] +pub enum TshirtSize { + Xs, + S, + M, + L, + Xl, + Xxl, +} diff --git a/server/src/model/university.rs b/server/src/model/university.rs new file mode 100644 index 0000000000000000000000000000000000000000..882aaf173af8976210f2526b57bab09a92e69462 --- /dev/null +++ b/server/src/model/university.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, sqlx::Type, TS)] +#[serde(rename_all = "snake_case")] +#[sqlx(rename_all = "snake_case", type_name = "UNIVERSITY")] +#[ts(export)] +pub enum University { + Uqac, + Uqar, + Uqat, + Uqo, + Uqtr, + Mcgill, + McgillMacdonald, + Concordia, + Ets, + Polymtl, + Ulaval, + UlavalAgriculture, + Uds, + None, +} diff --git a/server/src/routes/change_password.rs b/server/src/routes/change_password.rs index d51da8807a78359785c32bb5c94c54f7da2ac5dd..6997315e76d441e9c0ec4612a7670d1a1ae4b8a6 100644 --- a/server/src/routes/change_password.rs +++ b/server/src/routes/change_password.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use ts_rs::TS; use crate::auth::claims::Claims; -use crate::model::participant::Participant; +use crate::model::participant_info::ParticipantInfo; use crate::SharedState; #[derive(Deserialize, TS)] @@ -20,7 +20,7 @@ pub async fn change_password( State(state): State<SharedState>, Json(password): Json<ChangePasswordPayload>, ) -> impl IntoResponse { - match Participant::change_password(claims.id, password.new_password, &state.db).await { + match ParticipantInfo::change_password(claims.id, password.new_password, &state.db).await { Ok(_) => (StatusCode::OK, "Password changed".to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), } diff --git a/server/src/routes/delete_participant.rs b/server/src/routes/delete_participant.rs index d377925ea6de2fb662f9125acaef01f24abf053c..b9d325629c76fa2f7750a825ff3f6771b26a6869 100644 --- a/server/src/routes/delete_participant.rs +++ b/server/src/routes/delete_participant.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::{ auth::claims::Claims, - model::{participant::Participant, role::Role}, + model::{participant_info::ParticipantInfo, role::Role}, SharedState, }; @@ -17,13 +17,17 @@ pub async fn delete_participant( Path(id): Path<Uuid>, ) -> impl IntoResponse { match claims.role { - Role::Organizer => match Participant::delete_from_database(id, &state.db).await { + Role::Organizer => match ParticipantInfo::delete_from_database(id, &state.db).await { Ok(_) => (StatusCode::CREATED, "Participant deleted".to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), }, Role::Chef => { - match Participant::delete_from_database_university(id, claims.university, &state.db) - .await + match ParticipantInfo::delete_from_database_with_university( + id, + claims.university, + &state.db, + ) + .await { Ok(_) => (StatusCode::CREATED, "Participant deleted".to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), diff --git a/server/src/routes/get_participant.rs b/server/src/routes/get_participant.rs index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d074f127bfb3755dbcd568f7513ac37a5fd5bfe9 100644 --- a/server/src/routes/get_participant.rs +++ b/server/src/routes/get_participant.rs @@ -0,0 +1,50 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use uuid::Uuid; + +use crate::model::participant_info::ParticipantInfo; +use crate::{auth::claims::Claims, model::role::Role, SharedState}; + +pub async fn get_participant( + claims: Claims, + State(state): State<SharedState>, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + match claims.role { + Role::Organizer | Role::Volunteer => { + match ParticipantInfo::get_participant(id, &state.db).await { + Ok(participant) => (StatusCode::OK, Json(participant)).into_response(), + Err(e) => { + tracing::error!("Failed to get participant: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } + Role::Chef => { + match ParticipantInfo::get_participant_with_university(id, claims.university, &state.db) + .await + { + Ok(participant) => (StatusCode::OK, Json(participant)).into_response(), + Err(e) => { + tracing::error!("Failed to get participant: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } + Role::Participant => { + if id == claims.id { + match ParticipantInfo::get_participant(id, &state.db).await { + Ok(participant) => (StatusCode::OK, Json(participant)).into_response(), + Err(e) => { + tracing::error!("Failed to get participant: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } else { + (StatusCode::FORBIDDEN, "Forbidden").into_response() + } + } + } +} diff --git a/server/src/routes/get_participants.rs b/server/src/routes/get_participants.rs index 4240c5ebdccd15fe9cf69fb0d6793a7aeea4b3cc..4011c653dff8b15eb243f9942e1fbd7d7b2bb7c1 100644 --- a/server/src/routes/get_participants.rs +++ b/server/src/routes/get_participants.rs @@ -21,11 +21,8 @@ pub async fn get_participants( } } Role::Chef => { - match ParticipantPreview::get_participants_from_university( - &state.db, - &claims.university, - ) - .await + match ParticipantPreview::get_participants_from_university(&state.db, claims.university) + .await { Ok(participants) => return (StatusCode::OK, Json(participants)).into_response(), Err(e) => { diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index dc51889cb1a6e0b631a929e2efa63787795ff81a..7b81b8f34f48675127681c882d33b1890caf826a 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -8,10 +8,12 @@ use crate::SharedState; pub mod change_password; pub mod delete_participant; +pub mod get_participant; pub mod get_participants; pub mod login; pub mod new_participant; pub mod patch_participant; +pub mod send_email_reset; pub mod test_token; pub fn api_router(state: SharedState) -> Router { @@ -23,11 +25,14 @@ pub fn api_router(state: SharedState) -> Router { ) .route( "/participant/:id", - delete(delete_participant::delete_participant), + delete(delete_participant::delete_participant).get(get_participant::get_participant), ) .route("/participants", get(get_participants::get_participants)) .route("/login", post(login::login)) .route("/test", get(test_token::test_token)) - .route("/password", put(change_password::change_password)) + .route( + "/password", + put(change_password::change_password).post(send_email_reset::send_email_reset), + ) .with_state(state) } diff --git a/server/src/routes/new_participant.rs b/server/src/routes/new_participant.rs index a2e7531351be38a11b10017da224ec704ffef91f..c098d1bdf7e52cf69167a7660e694128b55b5a10 100644 --- a/server/src/routes/new_participant.rs +++ b/server/src/routes/new_participant.rs @@ -1,15 +1,30 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use rand::distributions::{Alphanumeric, DistString}; -use crate::{auth::claims::Claims, model::minimal_participant::MinimalParticipant, SharedState}; +use crate::{ + auth::claims::Claims, + model::{minimal_participant::MinimalParticipant, role::Role}, + SharedState, +}; pub async fn new_participant( - _claims: Claims, + claims: Claims, State(state): State<SharedState>, Json(participant): Json<MinimalParticipant>, ) -> impl IntoResponse { let password = Alphanumeric.sample_string(&mut rand::thread_rng(), 16); + if claims.role == Role::Volunteer || claims.role == Role::Participant { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + + if claims.role == Role::Chef + && ((participant.role == Role::Organizer || participant.role == Role::Volunteer) + || participant.university != claims.university) + { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + if let Err(e) = state .email .lock() @@ -17,8 +32,7 @@ pub async fn new_participant( .send_email( "Inscription CQI/QEC 2025", format!( - r#" -Bienvenue {} {}, + r#"Bienvenue {} {}, Vous avez été inscrit à la compétition CQI/QEC 2025. Votre courriel est : {} Votre mot de passe est : {} @@ -30,8 +44,7 @@ You have been registered for the CQI/QEC 2025 competition. Your email is : {} Your password is : {} You can log in at the following address : -https://cqi-qec.qc.ca/login - "#, +https://cqi-qec.qc.ca/login"#, &participant.first_name, &participant.last_name, &participant.email, @@ -48,10 +61,7 @@ https://cqi-qec.qc.ca/login return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); }; - if let Err(e) = participant - .write_to_database(&password, &state.db, _claims.university) - .await - { + if let Err(e) = participant.write_to_database(&password, &state.db).await { return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); }; diff --git a/server/src/routes/patch_participant.rs b/server/src/routes/patch_participant.rs index 350063c3e280803e6ad99c80e4c77091d11d02f0..2c99769dc2c671c5fcef564186d8b3300ff2b448 100644 --- a/server/src/routes/patch_participant.rs +++ b/server/src/routes/patch_participant.rs @@ -1,12 +1,13 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use crate::{model::participant::Participant, SharedState}; +use crate::{auth::claims::Claims, model::participant_info::ParticipantInfo, SharedState}; pub async fn patch_participant( + claims: Claims, State(state): State<SharedState>, - Json(participant): Json<Participant>, + Json(participant): Json<ParticipantInfo>, ) -> impl IntoResponse { - match participant.write_to_database(&state.db).await { + match participant.write_to_database(claims.id, &state.db).await { Ok(_) => (StatusCode::CREATED, "Participant created".to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), } diff --git a/server/src/routes/send_email_reset.rs b/server/src/routes/send_email_reset.rs new file mode 100644 index 0000000000000000000000000000000000000000..607baf00563c82bbaedaea8ff0f1d8a26da626ff --- /dev/null +++ b/server/src/routes/send_email_reset.rs @@ -0,0 +1,46 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use ts_rs::TS; + +use crate::{auth::claims::Claims, SharedState}; + +#[derive(Deserialize, TS)] +#[ts(export)] +pub struct EmailResetPayload { + email: String, +} + +pub async fn send_email_reset( + State(state): State<SharedState>, + Json(email): Json<EmailResetPayload>, +) -> impl IntoResponse { + let Some(claims) = + Claims::create_token_for_password_reset(email.email.clone(), &state.db).await + else { + return (StatusCode::BAD_REQUEST, "Email not found".to_string()).into_response(); + }; + if let Err(e) = state + .email + .lock() + .await + .send_email( + "Changement de mot de passe CQI/QEC 2025 password reinitalization", + format!( + r#"Bonjour, +Un changement de mot de passe a été demandé pour le compte associé à ce courriel. +Pour changer votre mot de passe, cliquez sur le lien suivant : + +A password change has been requested for the account associated with this email. +To change your password, click on the following link: + +https://cqi-qec.qc.ca/change-password/{}"#, + claims + ), + &email.email, + ) + .await + { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + }; + (StatusCode::OK, "Email sent".to_string()).into_response() +} diff --git a/server/src/routes/test_token.rs b/server/src/routes/test_token.rs index 1c49ec6f745a5bd955a23c42692ef6923bb67b58..684b7065c03f14d66df7fde5bf2a9672b6719842 100644 --- a/server/src/routes/test_token.rs +++ b/server/src/routes/test_token.rs @@ -1,14 +1,7 @@ use axum::{http::StatusCode, response::IntoResponse, Json}; -use serde::{Deserialize, Serialize}; -use crate::{auth::claims::Claims, model::role::Role}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthResponse { - pub role: Role, -} +use crate::auth::claims::Claims; pub async fn test_token(claims: Claims) -> impl IntoResponse { - let auth = AuthResponse { role: claims.role }; - (StatusCode::OK, Json(auth)).into_response() + (StatusCode::OK, Json(claims)).into_response() }