diff --git a/backend/app/main.py b/backend/app/main.py index 51a02aaa1363b58ebb8fae34dff3160861d695f5..50d657ef948b6dfde4c3387d6bfc38c72ed0c89e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from collections import defaultdict -from fastapi import APIRouter, FastAPI, status, Depends, HTTPException, BackgroundTasks +from typing import Annotated +from fastapi import APIRouter, FastAPI, Form, status, Depends, HTTPException, BackgroundTasks from sqlalchemy.orm import Session from fastapi.security import OAuth2PasswordRequestForm from fastapi.middleware.cors import CORSMiddleware @@ -62,6 +63,18 @@ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depend "refresh_token": hashing.create_refresh_token(db_user), } +@authRouter.post("/register", status_code=status.HTTP_201_CREATED) +def register(email: Annotated[str, Form()], password: Annotated[str, Form()], nickname: Annotated[str, Form()], db: Session = Depends(get_db)): + db_user = crud.get_user_by_email(db, email=email) + if db_user: + raise HTTPException(status_code=400, detail="User already registered") + + user_data = schemas.UserCreate(email=email, password=password, nickname=nickname) + + user = crud.create_user(db=db, user=user_data) + + return user.id + @authRouter.post("/refresh", response_model=schemas.Token) def refresh_token(current_user: models.User = Depends(hashing.get_jwt_user_from_refresh_token)): return { diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index ca1f95ec445e9dba2e4a1e0398e6ec77dadfcba2..0c8a2735e1d66fe9006964a49c10f7e4d852ad4e 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -51,3 +51,39 @@ export async function loginAPI(email: string, password: string): Promise<string> return error.toString(); }); } + +export async function registerAPI( + email: string, + password: string, + nickname: string +): Promise<string> { + return axiosPublicInstance + .post( + `/auth/register`, + { + email, + username: email, + password, + nickname + }, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + .then((response) => { + if (response.status === 401) { + return response.data.detail ?? 'Unauthorized'; + } else if (response.status === 422) { + return 'Invalid request'; + } else if (response.status === 201) { + return 'OK'; + } + + return 'Error ' + response.status + ': ' + response.data.detail; + }) + .catch((error) => { + return error.toString(); + }); +} diff --git a/frontend/src/lib/components/header.svelte b/frontend/src/lib/components/header.svelte index c4de06dc7fd8bc076c7d83e2f84862c17090d3ae..7a4ff31776adf32090e85f21a03a7603ba01ced5 100644 --- a/frontend/src/lib/components/header.svelte +++ b/frontend/src/lib/components/header.svelte @@ -26,6 +26,6 @@ )}"><Login /></a > {/if} - <LocalSelector /> + <LocalSelector class="ml-2" /> </div> </header> diff --git a/frontend/src/lib/components/header/localSelector.svelte b/frontend/src/lib/components/header/localSelector.svelte index 8d1b3cd62c82df9b0f33ee75a0ca42ae4bee09c4..43b5e40873ab1e058ce0f7989a1ac12415510719 100644 --- a/frontend/src/lib/components/header/localSelector.svelte +++ b/frontend/src/lib/components/header/localSelector.svelte @@ -1,7 +1,14 @@ <script lang="ts"> import { _, _activeLocale, locales, setupI18n } from '../../services/i18n'; - let value: string = _activeLocale; + let classes = ''; + export { classes as class }; + + $: value = $_activeLocale; + + _activeLocale.subscribe((locale) => { + value = locale; + }); function onChange(event: Event) { const target = event.target as HTMLSelectElement; @@ -12,8 +19,8 @@ } </script> -<div class="ml-2"> - <select {value} on:change={onChange} class="bg-transparent"> +<div class=" flex-1"> + <select {value} on:change={onChange} class="bg-transparent {classes}"> {#each Object.entries(locales) as [locale, name] (locale)} <option value={locale}>{name}</option> {/each} diff --git a/frontend/src/lib/services/i18n.ts b/frontend/src/lib/services/i18n.ts index a410c02e994345beee0bbbb44e7924d69ef2e13f..f294944d11cad27807bfbde41b662c961cb614de 100644 --- a/frontend/src/lib/services/i18n.ts +++ b/frontend/src/lib/services/i18n.ts @@ -23,7 +23,7 @@ const fallbackLocale = 'fr'; const MESSAGE_FILE_URL_TEMPLATE = '/lang/{locale}.json'; -export let _activeLocale: string; +export let _activeLocale = writable(fallbackLocale); const isDownloading = writable(false); @@ -32,13 +32,15 @@ function setupI18n(options = { withLocale: fallbackLocale }) { init({ initialLocale: locale_, fallbackLocale: fallbackLocale }); + _activeLocale.set(locale_); + if (!hasLoadedLocale(locale_)) { isDownloading.set(true); const messagesFileUrl = MESSAGE_FILE_URL_TEMPLATE.replace('{locale}', locale_); return loadJson(messagesFileUrl).then((messages) => { - _activeLocale = locale_; + _activeLocale.set(locale_); addMessages(locale_, messages); @@ -53,8 +55,8 @@ const isLocaleLoaded = derived( [isDownloading, dictionary], ([$isDownloading, $dictionary]) => !$isDownloading && - $dictionary[_activeLocale] && - Object.keys($dictionary[_activeLocale]).length > 0 + $dictionary[get(_activeLocale)] && + Object.keys($dictionary[get(_activeLocale)]).length > 0 ); const dir = derived(locale, ($locale) => ($locale === 'ar' ? 'rtl' : 'ltr')); diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..030dd3b67263d472af64ae99dc6d57ae37e3997e --- /dev/null +++ b/frontend/src/routes/register/+page.svelte @@ -0,0 +1,164 @@ +<script lang="ts"> + import Header from '$lib/components/header.svelte'; + import JWTSession from '$lib/stores/JWTSession'; + import { onMount } from 'svelte'; + import { _ } from '$lib/services/i18n'; + import LocalSelector from '$lib/components/header/localSelector.svelte'; + import { CheckCircle, ExclamationTriangle, Icon } from 'svelte-hero-icons'; + import { loginAPI, registerAPI } from '$lib/api/auth'; + import { toastAlert } from '$lib/utils/toasts'; + + let checker: HTMLDivElement; + + onMount(() => { + if (JWTSession.isLoggedIn()) { + const redirect = new URLSearchParams(window.location.search).get('redirect') ?? '/'; + window.location.href = redirect; + } + + checker.innerHTML = + '<input type="checkbox" id="humanCheck" required><label for="humanCheck">' + + $_('signup.humans') + + '</label>'; + }); + + let message = ''; + + let nickname = ''; + let email = ''; + let password = ''; + let confirmPassword = ''; + + const signup = async () => { + if (nickname == '' || email == '') { + message = $_('signup.emptyFields'); + return; + } + if (password.length < 8) { + message = $_('signup.passwordRules'); + return; + } + if (password != confirmPassword) { + message = $_('signup.differentPasswords'); + return; + } + if (!checker.querySelector('input')?.checked) { + message = $_('signup.humanity'); + return; + } + + const result = await registerAPI(email, password, nickname); + + if (result !== 'OK') { + message = result; + return; + } + message = ''; + + const loginRes = await loginAPI(email, password); + + if (loginRes !== 'OK') { + toastAlert('Failed to login: ' + loginRes); + document.location.href = '/login'; + return; + } + + document.location.href = '/first-login'; + }; +</script> + +<Header /> + +<div class="flex items-center justify-center h-screen"> + <form action="#" class="shadow-md w-1/2 min-w-fit max-w-2xl mb-7 flex items-center flex-col p-5"> + <div class="text-xl mb-4 font-bold"> + {$_('signup.title')} + </div> + {#if message} + <div class="w-full py-1 bg-red-600 text-white text-center font-bold rounded mb-4"> + {message} + </div> + {/if} + <div class="flex w-full mb-4"> + <label for="language">{$_('signup.language')}</label> + <LocalSelector class="w-full !bg-gray-200 py-2 px-4 rounded" /> + </div> + <div class="flex w-full mb-4"> + <label for="nickname">{$_('signup.nickname')}</label> + <input + class="w-1/2" + type="text" + id="nickname" + name="nickname" + bind:value={nickname} + required + /> + </div> + <div class="flex w-full mb-4"> + <label for="email">{$_('signup.email')}</label> + <input class="w-1/2" type="email" id="email" name="email" bind:value={email} required /> + </div> + <div class="flex w-full mb-4"> + <label for="password">{$_('signup.password')}</label> + <div class="w-1/2 flex"> + <div class="flex-grow"> + <input + class="w-full" + type="password" + id="password" + name="password" + bind:value={password} + required + /> + </div> + <div class="w-12 ml-2 flex items-center justify-center"> + {#if password.length < 8} + <div title={$_('signup.passwordRules')}> + <Icon src={ExclamationTriangle} class="w-8 text-orange-600" /> + </div> + {:else} + <Icon src={CheckCircle} class="w-8 text-green-600" /> + {/if} + </div> + </div> + </div> + <div class="flex w-full mb-4"> + <label for="password">{$_('signup.password')}</label> + <div class="w-1/2 -ml-1 flex"> + <div class="flex-grow"> + <input + class="w-full" + type="password" + id="password" + name="password" + bind:value={confirmPassword} + required + /> + </div> + <div class="w-12 ml-2 flex items-center justify-center"> + {#if confirmPassword == '' || password != confirmPassword} + <div title={$_('signup.differentPasswords')}> + <Icon src={ExclamationTriangle} class="w-8 text-orange-600" /> + </div> + {:else} + <Icon src={CheckCircle} class="w-8 text-green-600" /> + {/if} + </div> + </div> + </div> + <div bind:this={checker} class="mb-4 space-x-4"></div> + <button type="submit" on:click|preventDefault={signup} class="button" + >{$_('signup.signup')}</button + > + </form> +</div> + +<style lang="postcss"> + label { + @apply font-bold pr-4 w-1/2 flex items-center justify-end; + } + + input { + @apply border-2 bg-gray-200 rounded py-2 px-4; + } +</style> diff --git a/frontend/static/lang/en.json b/frontend/static/lang/en.json index 0931054a733d7af8a669f3c575bbc83e60486385..0bda99c8019ad1686b6c2005c102089a5f9094b3 100644 --- a/frontend/static/lang/en.json +++ b/frontend/static/lang/en.json @@ -18,6 +18,15 @@ "participantPlaceholder": "Please select", "remainingDuration": "Remaining duration" }, + "signup": { + "title": "Register", + "language": "Language", + "nickname": "Nickname", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "signup": "Sign up" + }, "utils": { "month": { "january": "january", diff --git a/frontend/static/lang/fr.json b/frontend/static/lang/fr.json index 76a7d6d8f058f3af9edf316d45bfeb9da31ee115..2e529e65b9eb2f353144e83a5a8cef95a35a0aec 100644 --- a/frontend/static/lang/fr.json +++ b/frontend/static/lang/fr.json @@ -32,6 +32,20 @@ "actions": "Actions", "passwordPrompt": "Veuillez entrer le mot de passe. Attention, celui ci ne sera plus récupérable." }, + "signup": { + "title": "Inscription", + "language": "Langue / Language", + "nickname": "Pseudo", + "email": "Email", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "signup": "S'inscrire", + "passwordRules": "Le mot de passe doit contenir au moins 8 caractères", + "differentPasswords": "Les mots de passe ne correspondent pas", + "emptyFields": "Veuillez remplir tous les champs", + "humans": "Je ne suis pas un robot", + "humanity": "Veuillez confirmer que vous n'êtes pas un robot" + }, "tests": { "sendResults": "Envoyer les résultats", "sendResultsDone": "Envoyé"