diff --git a/TODO.md b/TODO.md index 9cb72f33e07d5a23b1b3b42b6c165ae46d3b99a2..06d42a1da7f1dae0df14401a86aa5d62820af55d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,4 @@ - Implement detailed view of Participant information -- Password reset after account creation - Client side field validation - Traduction 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/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/i18n/en.ts b/client/src/i18n/en.ts index 0e871e85f0acb69cf765a4b62b4dcbf93c88c1c7..8e8718bab3a9fcf1bbe5f969b9c78eece1dbf085 100644 --- a/client/src/i18n/en.ts +++ b/client/src/i18n/en.ts @@ -96,6 +96,8 @@ const loginPage = { email: "Email", password: "Password", signIn: "Sign in", + forgotPassword: "Forgot password?", + resetEmailSent: "An email has been sent to reset your password.", } const additionalInfo = { diff --git a/client/src/i18n/fr.ts b/client/src/i18n/fr.ts index ea6c0f9257252474b479eb706988fbc1d634651e..681f6c8c49af6a071cce794c19d4277015c816ac 100644 --- a/client/src/i18n/fr.ts +++ b/client/src/i18n/fr.ts @@ -96,6 +96,8 @@ const loginPage = { email: "Courriel", password: "Mot de passe", signIn: "Se connecter", + forgotPassword: "Mot de passe oublié?", + resetEmailSent: "Courriel de réinitialisation envoyé", } const 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/routes.ts b/client/src/request/routes.ts index 581276fb45afea74b85227882109c75b07992097..9f4f91f16bef30513d4a31d6e8f499b7e3e2a0ff 100644 --- a/client/src/request/routes.ts +++ b/client/src/request/routes.ts @@ -1,5 +1,6 @@ import { AuthPayload } from "../binding/AuthPayload" import { ChangePasswordPayload } from "../binding/ChangePasswordPayload" +import { EmailResetPayload } from "../binding/EmailResetPayload" import { MinimalParticipant } from "../binding/MinimalParticipant" import { ParticipantPreview } from "../binding/ParticipantPreview" import { @@ -49,3 +50,7 @@ export async function testAuth() { } return await request.json() } + +export async function resetEmail(email: EmailResetPayload) { + return await fetch_post_no_token("/password", email) +} 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/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-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json b/server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json similarity index 74% rename from server/.sqlx/query-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json rename to server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json index 59756034a61f8b1062a8ec64744fad4586f8cb26..8a68ae7371aa8d648e8e5f7c17fc1ebfee61d60d 100644 --- a/server/.sqlx/query-e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce.json +++ b/server/.sqlx/query-8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, role AS \"role: Role\", university FROM participants WHERE email = $1", + "query": "SELECT id, role AS \"role: Role\", university AS \"university: University\" FROM participants WHERE email = $1", "describe": { "columns": [ { @@ -27,7 +27,7 @@ }, { "ordinal": 2, - "name": "university", + "name": "university: University", "type_info": "Text" } ], @@ -42,5 +42,5 @@ false ] }, - "hash": "e08b705c8f710fe9496a9b72c258fed4cd4c7952a9c5cb2a6f8e5564758c3fce" + "hash": "8e6f6c7229ba7d13f4690c70229bac29698bbccae3adfb0f2cdcdff1daa9fe15" } diff --git a/server/.sqlx/query-4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb.json b/server/.sqlx/query-a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b.json similarity index 81% rename from server/.sqlx/query-4ba558a81017dfe3d22e15a97f7e57e8a5404d9910102f7bd87eb9b887e97fcb.json rename to server/.sqlx/query-a902b8930ff8374bde666f05bbab23a168364c617c4de636fe799cf00f8bc16b.json index 9c528615386e95f606d73bf734a1aa0963a53f9c..a4f03b84fe355d40c4010fe45c57b713b67a1bf3 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,7 +32,7 @@ }, { "ordinal": 3, - "name": "university", + "name": "university: University", "type_info": "Text" } ], @@ -48,5 +48,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..ba5d2a47e5a98916db88e46c6a1362cb652a90b3 --- /dev/null +++ b/server/.sqlx/query-b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294.json @@ -0,0 +1,27 @@ +{ + "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", + "Text", + "Text", + "Text", + "Bool", + "Text", + "Bytea", + "Bytea", + "Bytea", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b2cf8ace57c4a960f64b49060612f3c4918a7eb20626e5827dd2ebd0b5c65294" +} diff --git a/server/src/routes/new_participant.rs b/server/src/routes/new_participant.rs index 8336ae983195e857fda6a00c6d8fefeed385f526..7a3f8decd55336ef12fb025f2e166874b1404e31 100644 --- a/server/src/routes/new_participant.rs +++ b/server/src/routes/new_participant.rs @@ -21,8 +21,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 : {} @@ -34,8 +33,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,