diff --git a/front/index.html b/front/index.html
index b575f2061..e771bca48 100644
--- a/front/index.html
+++ b/front/index.html
@@ -1,45 +1,68 @@
-
-
-
-
-
- Stylo
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Stylo
+
+
+
+
+
+
+
+
+
+
+
{{#if SNOWPACK_MATOMO}}
{{/if}}
-
+
diff --git a/front/src/components/Header.jsx b/front/src/components/Header.jsx
index 568dcef1c..e31eb6517 100644
--- a/front/src/components/Header.jsx
+++ b/front/src/components/Header.jsx
@@ -1,7 +1,8 @@
import React, { useMemo } from 'react'
import { LifeBuoy } from 'react-feather'
import { useSelector } from 'react-redux'
-import { Link, Route, Switch } from 'react-router-dom'
+import { NavLink, Route, Switch } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import logoContent from '/images/logo.svg?inline'
import { useActiveWorkspace } from '../hooks/workspace.js'
@@ -9,7 +10,6 @@ import { useActiveWorkspace } from '../hooks/workspace.js'
import styles from './header.module.scss'
import LanguagesMenu from './header/LanguagesMenu.jsx'
import UserMenu from './header/UserMenu.jsx'
-import LanguagesIcon from './header/LanguagesIcon.jsx'
function Header() {
const activeWorkspace = useActiveWorkspace()
@@ -18,6 +18,7 @@ function Header() {
[activeWorkspace]
)
const connected = useSelector((state) => state.loggedIn)
+ const { t } = useTranslation()
return (
@@ -26,16 +27,16 @@ function Header() {
-
+
-
+
{connected && (
<>
@@ -74,18 +75,25 @@ function Header() {
>
)}
{!connected && (
-
+ <>
+
+
+ >
)}
diff --git a/front/src/components/Register.jsx b/front/src/components/Register.jsx
index 7349b5531..dee287847 100644
--- a/front/src/components/Register.jsx
+++ b/front/src/components/Register.jsx
@@ -1,9 +1,7 @@
-import React, { useState } from 'react'
+import React, { useCallback, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
import { Link, useHistory } from 'react-router-dom'
-
-import etv from '../helpers/eventTargetValue'
-import validateEmail from '../helpers/validationEmail'
-
+import { useToasts } from '@geist-ui/core'
import { useGraphQL } from '../helpers/graphQL'
import * as queries from './Credentials.graphql'
@@ -11,143 +9,108 @@ import styles from './login.module.scss'
import Field from './Field'
import Button from './Button'
import { ArrowLeftCircle, Check } from 'react-feather'
+import { fromFormData, validateSameFieldValue } from '../helpers/forms.js'
-function Register() {
+export default function Register() {
+ const { t } = useTranslation()
+ const { setToast } = useToasts()
+ const passwordRef = useRef()
+ const passwordConfirmationRef = useRef()
const history = useHistory()
- const [email, setEmail] = useState('')
- const [username, setUsername] = useState('')
- const [password, setPassword] = useState('')
- const [passwordC, setPasswordC] = useState('')
- const [displayName, setDisplayName] = useState('')
- const [firstName, setFirstName] = useState('')
- const [lastName, setLastName] = useState('')
- const [institution, setInstitution] = useState('')
const runQuery = useGraphQL()
- const details = {
- email,
- username,
- password,
- passwordC,
- displayName,
- firstName,
- lastName,
- institution,
- }
-
- const createUser = async (details) => {
- if (details.password !== details.passwordC) {
- alert('Password and Password confirm mismatch')
- return false
- }
- if (details.password === '') {
- alert('password is empty')
- return false
- }
- if (details.username === '') {
- alert('Username is empty')
- return false
- }
- if (details.email === '') {
- alert('Email is empty')
- return false
- }
- if (!validateEmail(details.email)) {
- alert('Email appears to be malformed')
- return false
- }
+ const handleFormSubmit = useCallback(async (event) => {
+ event.preventDefault()
+ const details = fromFormData(event.target)
try {
await runQuery({ query: queries.createUser, variables: { details } })
// if no error thrown, we can navigate to /
+ setToast({
+ type: 'default',
+ text: t('credentials.register.successToast'),
+ })
history.push('/')
} catch (err) {
- console.log('Unable to create a user', err)
+ setToast({
+ type: 'error',
+ text: t('credentials.register.errorToast', { message: err.message }),
+ })
}
- }
+ }, [])
return (
)
}
-
-export default Register
diff --git a/front/src/helpers/forms.js b/front/src/helpers/forms.js
new file mode 100644
index 000000000..edc9f7378
--- /dev/null
+++ b/front/src/helpers/forms.js
@@ -0,0 +1,39 @@
+/**
+ * Transforms a form into a plain object
+ *
+ * formData.entries() does not handle multiple values (sic) and formData.getAll() only works at a field level
+ *
+ * @param {React.ReactHTMLElement|FormData} formElement
+ * @returns {Record}
+ */
+export function fromFormData(input) {
+ const d = input instanceof FormData ? input : new FormData(input)
+
+ return Array.from(d.keys()).reduce(
+ (data, key) => ({
+ ...data,
+ [key.replace('[]', '')]: key.endsWith('[]') ? d.getAll(key) : d.get(key),
+ }),
+ {}
+ )
+}
+
+/**
+ *
+ * @param {React.RefObject} field
+ * @param {React.RefObject} referenceField
+ * @param {string} errorMessage
+ * @returns {(event: React.ChangeEvent) => undefined}
+ */
+export function validateSameFieldValue(field, referenceField, errorMessage) {
+ return function onChangeEvent() {
+ const fieldValue = field.current.value
+ const referenceFieldValue = referenceField.current.value
+
+ if (fieldValue !== referenceFieldValue) {
+ field.current.setCustomValidity(errorMessage)
+ } else {
+ field.current.setCustomValidity('')
+ }
+ }
+}
diff --git a/front/src/helpers/forms.test.js b/front/src/helpers/forms.test.js
new file mode 100644
index 000000000..69e595313
--- /dev/null
+++ b/front/src/helpers/forms.test.js
@@ -0,0 +1,87 @@
+import { describe, test, expect } from 'vitest'
+import { fromFormData, validateSameFieldValue } from './forms.js'
+
+describe('fromFormData()', () => {
+ test('with simple values', () => {
+ const d = new FormData()
+ d.append('un', 1)
+ d.append('deux', 'deux')
+ expect(fromFormData(d)).toEqual({ un: '1', deux: 'deux' })
+ })
+
+ test('with checkboxes values', () => {
+ const d = new FormData()
+ d.append('un', 'un')
+ d.append('un', 'deux')
+ d.append('tags[]', 'un')
+ d.append('tags[]', 'deux')
+ expect(fromFormData(d)).toEqual({ un: 'un', tags: ['un', 'deux'] })
+ })
+})
+
+describe('validateSameFieldValue()', () => {
+ function createPassword(name) {
+ const el = document.createElement('input')
+ el.type = 'password'
+ el.name = name
+ el.required = true
+ return {
+ get current() {
+ return el
+ },
+ }
+ }
+
+ test('built-in validation is triggered when no value', () => {
+ const [fieldA, fieldB] = [createPassword('a'), createPassword('b')]
+
+ validateSameFieldValue(fieldA, fieldB, 'mismatch!')()
+ expect(fieldA.current.validity.valid).toBe(false)
+ expect(fieldA.current.validationMessage).toBe('Constraints not satisfied')
+ expect(fieldA.current.validity.valueMissing).toBe(true)
+ })
+
+ test('works with same values', () => {
+ const [fieldA, fieldB] = [createPassword('a'), createPassword('b')]
+ fieldA.current.value = '@'
+ fieldB.current.value = '@'
+
+ validateSameFieldValue(fieldA, fieldB, 'mismatch!')()
+ expect(fieldA.current.validity.valid).toBe(true)
+ expect(fieldA.current.validationMessage).toBe('')
+ expect(fieldA.current.validity.valueMissing).toBe(false)
+ })
+
+ test('fails when reference field is not set', () => {
+ const [fieldA, fieldB] = [createPassword('a'), createPassword('b')]
+ fieldA.current.value = ''
+ fieldB.current.value = '@'
+
+ validateSameFieldValue(fieldA, fieldB, 'mismatch!')()
+ expect(fieldA.current.validity.valid).toBe(false)
+ expect(fieldA.current.validationMessage).toBe('mismatch!')
+ expect(fieldA.current.validity.valueMissing).toBe(true)
+ })
+
+ test('fails when reference field is set but does not validate', () => {
+ const [fieldA, fieldB] = [createPassword('a'), createPassword('b')]
+ fieldA.current.value = '@'
+ fieldB.current.value = ''
+
+ validateSameFieldValue(fieldA, fieldB, 'mismatch!')()
+ expect(fieldA.current.validity.valid).toBe(false)
+ expect(fieldA.current.validationMessage).toBe('mismatch!')
+ expect(fieldA.current.validity.valueMissing).toBe(false)
+ })
+
+ test('fails when field value mismatches reference field', () => {
+ const [fieldA, fieldB] = [createPassword('a'), createPassword('b')]
+ fieldA.current.value = '@@'
+ fieldB.current.value = '@'
+
+ validateSameFieldValue(fieldA, fieldB, 'mismatch!')()
+ expect(fieldA.current.validity.valid).toBe(false)
+ expect(fieldA.current.validationMessage).toBe('mismatch!')
+ expect(fieldA.current.validity.valueMissing).toBe(false)
+ })
+})
diff --git a/front/src/helpers/validationEmail.js b/front/src/helpers/validationEmail.js
deleted file mode 100644
index c2b423c32..000000000
--- a/front/src/helpers/validationEmail.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const validateEmail = (email) => {
- // eslint-disable-next-line
- const re =
- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
- return re.test(String(email).toLowerCase())
-}
-export default validateEmail
diff --git a/front/src/locales/en/translation.json b/front/src/locales/en/translation.json
index 7eb3e6d87..564e3e7d1 100644
--- a/front/src/locales/en/translation.json
+++ b/front/src/locales/en/translation.json
@@ -3,6 +3,7 @@
"user.account.displayName": "Display name",
"user.account.firstName": "First name",
"user.account.lastName": "Last name",
+ "user.account.username": "Username",
"user.account.institution": "Institution",
"user.account.id": "Identifier",
"user.account.email": "Email",
@@ -12,6 +13,22 @@
"user.account.authentication": "Authentication type",
"user.account.copyApiKey": "Copy API key value to clipboard",
"user.account.apiKeyValue": "API key value is: {{token}}",
+ "credentials.manage": "Manage my account",
+ "credentials.register.title": "Create a local Stylo account",
+ "credentials.register.requiredFields": "Required informations",
+ "credentials.register.successToast": "Account created. You can now login.",
+ "credentials.register.errorToast": "Unable to create the account ({{ message }}).",
+ "credentials.register.optionalFields": "Optional details",
+ "credentials.login.withRemoteAccount": "Login with an external account",
+ "credentials.login.withLocalAccount": "Login with a local account",
+ "credentials.login.withService": "Login with {{ name }}",
+ "credentials.login.registerWithService": "Create a {{ name }} account",
+ "credentials.login.recommendedMethod": "recommended",
+ "credentials.login.howto": "How does it work?",
+ "credentials.login.confirmButton": "Login",
+ "credentials.login.registerLink": "Create an account",
+ "credentials.login.goBackLink": "Go back to Login",
+ "credentials.logout.confirmButton": "Logout",
"credentials.updatePassword.updatingButton": "Updating…",
"credentials.updatePassword.confirmButton": "Update",
"workspace.myspace": "My space",
@@ -136,6 +153,7 @@
"credentials.changePassword.title": "Change password",
"credentials.changePassword.para": "This section is strictly private, changing your password will only affect your combination of username/email and password.",
"credentials.oldPassword.placeholder": "Old password",
+ "credentials.password.placeholder": "Password",
"credentials.newPassword.placeholder": "New password",
"credentials.confirmNewPassword.placeholder": "Confirm new password",
"export.format.label": "Formats",
@@ -146,6 +164,8 @@
"export.bookDivision.part": "Book division: Part & chapters",
"export.bookDivision.chapter": "Book division: Chapter only",
"export.submitForm.button": "Export with these settings",
+ "header.manage": "Manage my account and workpaces",
+ "header.languages": "Change interface language",
"footer.documentation.link": "Documentation",
"footer.changelog.link": "Changelog",
"footer.navStats.checkbox": "I accept to share my navigation stats",
diff --git a/front/src/locales/fr/translation.json b/front/src/locales/fr/translation.json
index 50ea66268..c9462d43a 100644
--- a/front/src/locales/fr/translation.json
+++ b/front/src/locales/fr/translation.json
@@ -3,6 +3,7 @@
"user.account.displayName": "Nom d'affichage",
"user.account.firstName": "Prénom",
"user.account.lastName": "Nom",
+ "user.account.username": "Identifiant de compte",
"user.account.institution": "Institution",
"user.account.id": "Identifiant",
"user.account.email": "Courriel",
@@ -12,6 +13,22 @@
"user.account.authentication": "Type d'authentification",
"user.account.copyApiKey": "Copier la clé d'accès à l'API dans le presse-papier",
"user.account.apiKeyValue": "La valeur de la clé d'API est : {{token}}",
+ "credentials.manage": "Gérer mon compte",
+ "credentials.register.title": "Créer un compte Stylo local",
+ "credentials.register.successToast": "Compte créé. Vous pouvez vous connecter dès à présent.",
+ "credentials.register.errorToast": "Erreur lors de la création du compte ({{ message }}).",
+ "credentials.register.requiredFields": "Champs obligatoires",
+ "credentials.register.optionalFields": "Informations optionnelles",
+ "credentials.login.withRemoteAccount": "Connexion avec un compte externe",
+ "credentials.login.withLocalAccount": "Connexion avec un compte local",
+ "credentials.login.withService": "Se connecter avec {{ name }}",
+ "credentials.login.registerWithService": "Créer un compte {{ name }}",
+ "credentials.login.recommendedMethod": "recommandé",
+ "credentials.login.howto": "Comment ça fonctionne ?",
+ "credentials.login.confirmButton": "Connexion",
+ "credentials.login.registerLink": "Créer un compte",
+ "credentials.login.goBackLink": "Retour à l'écran d'accueil",
+ "credentials.logout.confirmButton": "Se déconnecter",
"credentials.updatePassword.updatingButton": "Mise à jour…",
"credentials.updatePassword.confirmButton": "Mettre à jour",
"workspace.myspace": "Mon espace",
@@ -136,6 +153,7 @@
"credentials.changePassword.title": "Changer le mot de passe",
"credentials.changePassword.para": "Cette section est strictement privée, la modification de votre mot de passe n'affectera que votre combinaison nom d'utilisateur/email et mot de passe.",
"credentials.oldPassword.placeholder": "Ancien mot de passe",
+ "credentials.password.placeholder": "Mot de passe",
"credentials.newPassword.placeholder": "Nouveau mot de passe",
"credentials.confirmNewPassword.placeholder": "Confirmer le nouveau mot de passe",
"export.format.label": "Formats",
@@ -146,6 +164,8 @@
"export.bookDivision.part": "Division du corpus : parties et chapitres",
"export.bookDivision.chapter": "Division du corpus : chapitres seulement",
"export.submitForm.button": "Exporter avec ces paramètres",
+ "header.manage": "Gérer mon compte et mes espaces de travail",
+ "header.languages": "Modifier la langue de l'interface",
"footer.documentation.link": "Documentation",
"footer.changelog.link": "Journal des modifications",
"footer.navStats.checkbox": "J'accepte de partager mes statistiques de navigation",