diff --git a/components/Application/Application.tsx b/components/Application/Application.tsx index 944f74f..e8214c2 100644 --- a/components/Application/Application.tsx +++ b/components/Application/Application.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react' import Image from 'next/image' +import useTranslation from 'next-translate/useTranslation' import { CogIcon, PlusIcon, @@ -19,6 +20,7 @@ import { Guilds } from './Guilds/Guilds' import { Divider } from '../design/Divider' import { Members } from './Members' import { useAuthentication } from 'utils/authentication' +import { API_URL } from 'utils/api' export interface GuildsChannelsPath { guildId: number @@ -37,6 +39,7 @@ export interface ApplicationProps { export const Application: React.FC = (props) => { const { children, path } = props + const { t } = useTranslation() const { user } = useAuthentication() const [visibleSidebars, setVisibleSidebars] = useState({ @@ -138,10 +141,10 @@ export const Application: React.FC = (props) => { return 'Join a Guild' } if (path === '/application/guilds/create') { - return 'Create a Guild' + return t('application:create-a-guild') } return 'Application' - }, [path]) + }, [path, t]) useEffect(() => { setMounted(true) @@ -193,7 +196,11 @@ export const Application: React.FC = (props) => { > logo { + return +} +CreateGuild.args = {} diff --git a/components/Application/CreateGuild/CreateGuild.tsx b/components/Application/CreateGuild/CreateGuild.tsx new file mode 100644 index 0000000..b2fd563 --- /dev/null +++ b/components/Application/CreateGuild/CreateGuild.tsx @@ -0,0 +1,82 @@ +import { useRouter } from 'next/router' +import useTranslation from 'next-translate/useTranslation' +import { Form } from 'react-component-form' +import TextareaAutosize from 'react-textarea-autosize' +import { AxiosResponse } from 'axios' + +import { useAuthentication } from '../../../utils/authentication' +import { HandleSubmitCallback, useForm } from '../../../hooks/useForm' +import { GuildComplete, guildSchema } from '../../../models/Guild' +import { Input } from '../../design/Input' +import { Main } from '../../design/Main' +import { Button } from '../../design/Button' +import { FormState } from '../../design/FormState' + +export const CreateGuild: React.FC = () => { + const { t } = useTranslation() + const router = useRouter() + + const { formState, message, errors, getErrorTranslation, handleSubmit } = + useForm({ + validateSchemaObject: { + name: guildSchema.name, + description: guildSchema.description + } + }) + + const { authentication } = useAuthentication() + + const onSubmit: HandleSubmitCallback = async (formData) => { + try { + const { data } = await authentication.api.post< + any, + AxiosResponse<{ guild: GuildComplete }> + >('/guilds', { name: formData.name, description: formData.description }) + const guildId = data.guild.id + const channelId = data.guild.channels[0].id + await router.push(`/application/${guildId}/${channelId}`) + return null + } catch (error) { + return { + type: 'error', + value: 'errors:server-error' + } + } + } + + return ( +
+
+ + +
+
+ +
+
+ +
+
+ + +
+ +
+ ) +} diff --git a/components/Application/CreateGuild/index.ts b/components/Application/CreateGuild/index.ts new file mode 100644 index 0000000..cc03400 --- /dev/null +++ b/components/Application/CreateGuild/index.ts @@ -0,0 +1 @@ +export * from './CreateGuild' diff --git a/components/UserProfile/UserProfile.stories.tsx b/components/Application/UserProfile/UserProfile.stories.tsx similarity index 84% rename from components/UserProfile/UserProfile.stories.tsx rename to components/Application/UserProfile/UserProfile.stories.tsx index e8e6953..ee76ced 100644 --- a/components/UserProfile/UserProfile.stories.tsx +++ b/components/Application/UserProfile/UserProfile.stories.tsx @@ -1,5 +1,5 @@ import { Meta, Story } from '@storybook/react' -import { user, userSettings } from '../../cypress/fixtures/users/user' +import { user, userSettings } from '../../../cypress/fixtures/users/user' import { UserProfile as Component, UserProfileProps } from './UserProfile' diff --git a/components/UserProfile/UserProfile.test.tsx b/components/Application/UserProfile/UserProfile.test.tsx similarity index 81% rename from components/UserProfile/UserProfile.test.tsx rename to components/Application/UserProfile/UserProfile.test.tsx index fa7d128..08a07a7 100644 --- a/components/UserProfile/UserProfile.test.tsx +++ b/components/Application/UserProfile/UserProfile.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' -import { user, userSettings } from '../../cypress/fixtures/users/user' +import { user, userSettings } from '../../../cypress/fixtures/users/user' import { UserProfile } from './UserProfile' diff --git a/components/UserProfile/UserProfile.tsx b/components/Application/UserProfile/UserProfile.tsx similarity index 99% rename from components/UserProfile/UserProfile.tsx rename to components/Application/UserProfile/UserProfile.tsx index c1bb81f..38a6f04 100644 --- a/components/UserProfile/UserProfile.tsx +++ b/components/Application/UserProfile/UserProfile.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames' import useTranslation from 'next-translate/useTranslation' import date from 'date-and-time' -import { UserPublic } from '../../models/User' +import { UserPublic } from '../../../models/User' export interface UserProfileProps { className?: string @@ -15,8 +15,6 @@ export interface UserProfileProps { export const UserProfile: React.FC = (props) => { const { user, isOwner = false } = props - console.log(user) - const { t } = useTranslation() const handleSubmitChanges = ( diff --git a/components/UserProfile/index.ts b/components/Application/UserProfile/index.ts similarity index 100% rename from components/UserProfile/index.ts rename to components/Application/UserProfile/index.ts diff --git a/components/Authentication/Authentication.tsx b/components/Authentication/Authentication.tsx index 587b48d..d1621ac 100644 --- a/components/Authentication/Authentication.tsx +++ b/components/Authentication/Authentication.tsx @@ -1,11 +1,7 @@ -import { useMemo, useState } from 'react' import { useRouter } from 'next/router' import Link from 'next/link' import useTranslation from 'next-translate/useTranslation' import { useTheme } from 'next-themes' -import { Type } from '@sinclair/typebox' -import type { ErrorObject } from 'ajv' -import type { HandleForm } from 'react-component-form' import axios from 'axios' import { SocialMediaButton } from '../design/SocialMediaButton' @@ -13,26 +9,14 @@ import { Main } from '../design/Main' import { Input } from '../design/Input' import { Button } from '../design/Button' import { FormState } from '../design/FormState' -import { useFormState } from '../../hooks/useFormState' import { AuthenticationForm } from './' import { userSchema } from '../../models/User' -import { ajv } from '../../utils/ajv' import { api } from 'utils/api' import { Tokens, Authentication as AuthenticationClass } from '../../utils/authentication' -import { getErrorTranslationKey } from './getErrorTranslationKey' - -interface Errors { - [key: string]: ErrorObject | null | undefined -} - -const findError = ( - field: string -): ((value: ErrorObject, index: number, object: ErrorObject[]) => boolean) => { - return (validationError) => validationError.instancePath === field -} +import { useForm, HandleSubmitCallback } from '../../hooks/useForm' export interface AuthenticationProps { mode: 'signup' | 'signin' @@ -44,84 +28,57 @@ export const Authentication: React.FC = (props) => { const router = useRouter() const { lang, t } = useTranslation() const { theme } = useTheme() - const [formState, setFormState] = useFormState() - const [messageTranslationKey, setMessageTranslationKey] = useState< - string | undefined - >(undefined) - const [errors, setErrors] = useState({ - name: null, - email: null, - password: null - }) - const validateSchema = useMemo(() => { - return Type.Object({ - ...(mode === 'signup' && { name: userSchema.name }), - email: userSchema.email, - password: userSchema.password + const { errors, formState, message, getErrorTranslation, handleSubmit } = + useForm({ + validateSchemaObject: { + ...(mode === 'signup' && { name: userSchema.name }), + email: userSchema.email, + password: userSchema.password + } }) - }, [mode]) - const validate = useMemo(() => { - return ajv.compile(validateSchema) - }, [validateSchema]) - - const getErrorTranslation = (error?: ErrorObject | null): string | null => { - if (error != null) { - return t(getErrorTranslationKey(error)).replace( - '{expected}', - error?.params?.limit - ) - } - return null - } - - const handleSubmit: HandleForm = async (formData, formElement) => { - const isValid = validate(formData) - if (!isValid) { - setFormState('error') - const nameError = validate?.errors?.find(findError('/name')) - const emailError = validate?.errors?.find(findError('/email')) - const passwordError = validate?.errors?.find(findError('/password')) - setErrors({ - name: nameError, - email: emailError, - password: passwordError - }) - } else { - setErrors({}) - setFormState('loading') - if (mode === 'signup') { - try { - await api.post( - `/users/signup?redirectURI=${window.location.origin}/authentication/signin`, - { ...formData, language: lang, theme } - ) - formElement.reset() - setFormState('success') - setMessageTranslationKey('authentication:success-signup') - } catch (error) { - setFormState('error') - if (axios.isAxiosError(error) && error.response?.status === 400) { - setMessageTranslationKey('authentication:alreadyUsed') - } else { - setMessageTranslationKey('errors:server-error') + const onSubmit: HandleSubmitCallback = async (formData) => { + if (mode === 'signup') { + try { + await api.post( + `/users/signup?redirectURI=${window.location.origin}/authentication/signin`, + { ...formData, language: lang, theme } + ) + return { + type: 'success', + value: 'authentication:success-signup' + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + return { + type: 'error', + value: 'authentication:alreadyUsed' } } - } else { - try { - const { data } = await api.post('/users/signin', formData) - const authentication = new AuthenticationClass(data) - authentication.signin() - await router.push('/application') - } catch (error) { - setFormState('error') - if (axios.isAxiosError(error) && error.response?.status === 400) { - setMessageTranslationKey('authentication:wrong-credentials') - } else { - setMessageTranslationKey('errors:server-error') + return { + type: 'error', + value: 'errors:server-error' + } + } + } else { + try { + const { data } = await api.post('/users/signin', formData) + const authentication = new AuthenticationClass(data) + authentication.signin() + await router.push('/application') + return null + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + return { + type: 'error', + value: 'authentication:wrong-credentials' } } + return { + type: 'error', + value: 'errors:server-error' + } } } } @@ -138,13 +95,13 @@ export const Authentication: React.FC = (props) => {
{t('authentication:or')}
- + {mode === 'signup' && ( )} @@ -182,13 +139,7 @@ export const Authentication: React.FC = (props) => {

- + ) } diff --git a/cypress/fixtures/channels/channel.ts b/cypress/fixtures/channels/channel.ts new file mode 100644 index 0000000..dc221f3 --- /dev/null +++ b/cypress/fixtures/channels/channel.ts @@ -0,0 +1,9 @@ +import { guild } from '../guilds/guild' + +export const channel = { + id: 1, + name: 'general', + guildId: guild.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() +} diff --git a/cypress/fixtures/guilds/guild.ts b/cypress/fixtures/guilds/guild.ts new file mode 100644 index 0000000..bcd9a50 --- /dev/null +++ b/cypress/fixtures/guilds/guild.ts @@ -0,0 +1,8 @@ +export const guild = { + id: 1, + name: 'GuildExample', + description: 'guild example.', + icon: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() +} diff --git a/cypress/fixtures/guilds/post.ts b/cypress/fixtures/guilds/post.ts new file mode 100644 index 0000000..8feefa3 --- /dev/null +++ b/cypress/fixtures/guilds/post.ts @@ -0,0 +1,20 @@ +import { Handler } from '../handler' + +import { guild } from './guild' +import { channel } from '../channels/channel' +import { memberComplete } from '../members/member' + +export const postGuildsHandler: Handler = { + method: 'POST', + url: '/guilds', + response: { + statusCode: 201, + body: { + guild: { + ...guild, + channels: [channel], + members: [memberComplete] + } + } + } +} diff --git a/cypress/fixtures/members/member.ts b/cypress/fixtures/members/member.ts new file mode 100644 index 0000000..46872c7 --- /dev/null +++ b/cypress/fixtures/members/member.ts @@ -0,0 +1,16 @@ +import { guild } from '../guilds/guild' +import { user } from '../users/user' + +export const member = { + id: 1, + isOwner: true, + userId: user.id, + guildId: guild.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() +} + +export const memberComplete = { + ...member, + user +} diff --git a/cypress/fixtures/users/user.ts b/cypress/fixtures/users/user.ts index 0024a65..d43dcb9 100644 --- a/cypress/fixtures/users/user.ts +++ b/cypress/fixtures/users/user.ts @@ -1,15 +1,18 @@ import { UserSettings } from '../../../models/UserSettings' -import { UserPublic } from '../../../models/User' +import { User } from '../../../models/User' -export const user: UserPublic = { +export const user: User = { id: 1, name: 'Divlo', email: 'contact@divlo.fr', - logo: undefined, - status: undefined, - biography: undefined, + password: 'somepassword', + logo: null, + status: null, + biography: null, website: 'https://divlo.fr', isConfirmed: true, + temporaryToken: 'temporaryUUIDtoken', + temporaryExpirationToken: '2021-10-20T20:59:08.485Z', createdAt: '2021-10-20T20:30:51.595Z', updatedAt: '2021-10-20T20:59:08.485Z' } diff --git a/cypress/integration/pages/application/guilds/create.spec.ts b/cypress/integration/pages/application/guilds/create.spec.ts new file mode 100644 index 0000000..e276376 --- /dev/null +++ b/cypress/integration/pages/application/guilds/create.spec.ts @@ -0,0 +1,37 @@ +import { channel } from '../../../../fixtures/channels/channel' +import { guild } from '../../../../fixtures/guilds/guild' +import { postGuildsHandler } from '../../../../fixtures/guilds/post' +import { authenticationHandlers } from '../../../../fixtures/handler' + +describe('Pages > /application/guilds/create', () => { + beforeEach(() => { + cy.task('stopMockServer') + }) + + it('should succeeds and create the guild', () => { + cy.task('startMockServer', [ + ...authenticationHandlers, + postGuildsHandler + ]).setCookie('refreshToken', 'refresh-token') + cy.visit('/application/guilds/create') + cy.get('#error-name').should('not.exist') + cy.get('[data-cy=input-name]').type(guild.name) + cy.get('[data-cy=submit]').click() + cy.location('pathname').should( + 'eq', + `/application/${guild.id}/${channel.id}` + ) + }) + + it('should fails with internal api server error', () => { + cy.task('startMockServer', [...authenticationHandlers]).setCookie( + 'refreshToken', + 'refresh-token' + ) + cy.visit('/application/guilds/create') + cy.get('#error-name').should('not.exist') + cy.get('[data-cy=input-name]').type(guild.name) + cy.get('[data-cy=submit]').click() + cy.get('#message').should('have.text', 'Error: Internal Server Error.') + }) +}) diff --git a/cypress/integration/pages/authentication/reset-password.spec.ts b/cypress/integration/pages/authentication/reset-password.spec.ts index 7195389..74e1460 100644 --- a/cypress/integration/pages/authentication/reset-password.spec.ts +++ b/cypress/integration/pages/authentication/reset-password.spec.ts @@ -40,6 +40,9 @@ describe('Pages > /authentication/reset-password', () => { cy.visit('/authentication/reset-password') cy.get('#message').should('not.exist') cy.get('[data-cy=submit]').click() - cy.get('#message').should('have.text', 'Error: Invalid value.') + cy.get('#message').should( + 'have.text', + 'Error: Oops, this field is required 🙈.' + ) }) }) diff --git a/components/Authentication/getErrorTranslationKey.test.ts b/hooks/useForm/getErrorTranslationKey.test.ts similarity index 100% rename from components/Authentication/getErrorTranslationKey.test.ts rename to hooks/useForm/getErrorTranslationKey.test.ts diff --git a/components/Authentication/getErrorTranslationKey.ts b/hooks/useForm/getErrorTranslationKey.ts similarity index 100% rename from components/Authentication/getErrorTranslationKey.ts rename to hooks/useForm/getErrorTranslationKey.ts diff --git a/hooks/useForm/index.ts b/hooks/useForm/index.ts new file mode 100644 index 0000000..f65863c --- /dev/null +++ b/hooks/useForm/index.ts @@ -0,0 +1 @@ +export * from './useForm' diff --git a/hooks/useForm/useForm.ts b/hooks/useForm/useForm.ts new file mode 100644 index 0000000..c3489c2 --- /dev/null +++ b/hooks/useForm/useForm.ts @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react' +import useTranslation from 'next-translate/useTranslation' +import { Type } from '@sinclair/typebox' +import type { FormDataObject, HandleForm } from 'react-component-form' +import type { ErrorObject } from 'ajv' + +import { FormState, useFormState } from '../useFormState' +import { ajv } from '../../utils/ajv' +import { getErrorTranslationKey } from './getErrorTranslationKey' + +interface Errors { + [key: string]: ErrorObject | null | undefined +} + +const findError = ( + field: string +): ((value: ErrorObject, index: number, object: ErrorObject[]) => boolean) => { + return (validationError) => validationError.instancePath === field +} + +export type GetErrorTranslation = (error?: ErrorObject | null) => string | null + +export interface UseFormOptions { + validateSchemaObject: { [key: string]: any } +} + +export type HandleSubmit = (callback: HandleSubmitCallback) => HandleForm + +interface Message { + type: 'error' | 'success' + value: string +} + +export type HandleSubmitCallback = ( + formData: FormDataObject, + formElement: HTMLFormElement +) => Promise + +export interface UseFormResult { + message: string | null + formState: FormState + getErrorTranslation: GetErrorTranslation + handleSubmit: HandleSubmit + errors: Errors +} + +export const useForm = (options: UseFormOptions): UseFormResult => { + const { validateSchemaObject } = options + const { t } = useTranslation() + const [formState, setFormState] = useFormState() + const [messageTranslationKey, setMessageTranslationKey] = useState< + string | undefined + >(undefined) + const [errors, setErrors] = useState({}) + + const validateSchema = useMemo(() => { + return Type.Object(validateSchemaObject) + }, [validateSchemaObject]) + + const validate = useMemo(() => { + return ajv.compile(validateSchema) + }, [validateSchema]) + + const getErrorTranslation = (error?: ErrorObject | null): string | null => { + if (error != null) { + return t(getErrorTranslationKey(error)).replace( + '{expected}', + error?.params?.limit + ) + } + return null + } + + const handleSubmit: HandleSubmit = (callback) => { + return async (formData, formElement) => { + const isValid = validate(formData) + if (!isValid) { + setFormState('error') + const errors: Errors = {} + for (const property in validateSchema.properties) { + errors[property] = validate.errors?.find(findError(`/${property}`)) + } + setErrors(errors) + } else { + setErrors({}) + setFormState('loading') + const message = await callback(formData, formElement) + if (message != null) { + setMessageTranslationKey(message.value) + if (message.type === 'success') { + setFormState('success') + formElement.reset() + } else { + setFormState('error') + } + } + } + } + } + + return { + getErrorTranslation, + errors, + formState, + handleSubmit, + message: messageTranslationKey != null ? t(messageTranslationKey) : null + } +} diff --git a/hooks/useFormState.tsx b/hooks/useFormState.ts similarity index 100% rename from hooks/useFormState.tsx rename to hooks/useFormState.ts diff --git a/i18n.json b/i18n.json index b2e0b17..e1a9a74 100644 --- a/i18n.json +++ b/i18n.json @@ -10,6 +10,9 @@ "/authentication/reset-password": ["authentication", "errors"], "/authentication/signup": ["authentication", "errors"], "/authentication/signin": ["authentication", "errors"], - "/application/users/[userId]": ["application"] + "/application/users/[userId]": ["application", "errors"], + "/application/guilds/create": ["application", "errors"], + "/application/guilds/join": ["application", "errors"], + "/application": ["application", "errors"] } } diff --git a/locales/en/application.json b/locales/en/application.json index 637074e..84f0ec5 100644 --- a/locales/en/application.json +++ b/locales/en/application.json @@ -1,3 +1,5 @@ { - "website": "Website" + "website": "Website", + "create": "Create", + "create-a-guild": "Create a Guild" } diff --git a/locales/en/authentication.json b/locales/en/authentication.json index 56909e7..7febab8 100644 --- a/locales/en/authentication.json +++ b/locales/en/authentication.json @@ -1,7 +1,6 @@ { "or": "OR", "password": "Password", - "name": "Name", "already-have-an-account": "Already have an account?", "dont-have-an-account": "Don't have an account?", "submit": "Submit", diff --git a/locales/en/common.json b/locales/en/common.json index d5bd8e4..95b71f3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2,5 +2,6 @@ "english": "English", "french": "French", "all-rights-reserved": "All rights reserved", - "description": "Stay close with your friends and communities, talk, chat, collaborate, share, and have fun." + "description": "Stay close with your friends and communities, talk, chat, collaborate, share, and have fun.", + "name": "Name" } diff --git a/locales/fr/application.json b/locales/fr/application.json index ca95713..c1af9f5 100644 --- a/locales/fr/application.json +++ b/locales/fr/application.json @@ -1,3 +1,5 @@ { - "website": "Site web" + "website": "Site web", + "create": "CrĂ©e", + "create-a-guild": "CrĂ©e une Guilde" } diff --git a/locales/fr/authentication.json b/locales/fr/authentication.json index 4fac5ad..9db8638 100644 --- a/locales/fr/authentication.json +++ b/locales/fr/authentication.json @@ -1,7 +1,6 @@ { "or": "OU", "password": "Mot de passe", - "name": "Nom", "already-have-an-account": "Vous avez dĂ©jĂ  un compte ?", "dont-have-an-account": "Vous n'avez pas de compte ?", "submit": "Soumettre", diff --git a/locales/fr/common.json b/locales/fr/common.json index a5980d4..0c3eae3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2,5 +2,6 @@ "english": "Anglais", "french": "Français", "all-rights-reserved": "Tous droits rĂ©servĂ©s", - "description": "Restez proche de vos amis et de vos communautĂ©s, parlez, collaborez, partagez et amusez-vous." + "description": "Restez proche de vos amis et de vos communautĂ©s, parlez, collaborez, partagez et amusez-vous.", + "name": "Nom" } diff --git a/models/Channel.ts b/models/Channel.ts index eb21334..4246744 100644 --- a/models/Channel.ts +++ b/models/Channel.ts @@ -6,7 +6,7 @@ export const types = [Type.Literal('text')] export const channelSchema = { id, - name: Type.String({ maxLength: 255 }), + name: Type.String({ minLength: 1, maxLength: 20 }), createdAt: date.createdAt, updatedAt: date.updatedAt, guildId: id diff --git a/models/Guild.ts b/models/Guild.ts index ee821fd..e1b6522 100644 --- a/models/Guild.ts +++ b/models/Guild.ts @@ -1,12 +1,24 @@ -import { Type } from '@sinclair/typebox' +import { Type, Static } from '@sinclair/typebox' +import { channelSchema } from './Channel' +import { memberSchema } from './Member' import { date, id } from './utils' export const guildSchema = { id, - name: Type.String({ minLength: 3, maxLength: 30 }), - icon: Type.String({ format: 'uri-reference' }), - description: Type.String({ maxLength: 160 }), + name: Type.String({ minLength: 1, maxLength: 30 }), + icon: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]), + description: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]), createdAt: date.createdAt, updatedAt: date.updatedAt } + +export const guildCompleteSchema = { + ...guildSchema, + channels: Type.Array(Type.Object(channelSchema)), + members: Type.Array(Type.Object(memberSchema)) +} + +export const guildCompleteObjectSchema = Type.Object(guildCompleteSchema) + +export type GuildComplete = Static diff --git a/models/Message.ts b/models/Message.ts index e45b15c..c8a0d7a 100644 --- a/models/Message.ts +++ b/models/Message.ts @@ -6,10 +6,13 @@ export const types = [Type.Literal('text'), Type.Literal('file')] export const messageSchema = { id, - value: Type.String(), + value: Type.String({ + minLength: 1, + maxLength: 20_000 + }), type: Type.Union(types, { default: 'text' }), mimetype: Type.String({ - maxLength: 255, + maxLength: 127, default: 'text/plain', format: 'mimetype' }), diff --git a/models/User.ts b/models/User.ts index 812837e..4884aa4 100644 --- a/models/User.ts +++ b/models/User.ts @@ -7,11 +7,11 @@ import { date, id } from './utils' export const userSchema = { id, name: Type.String({ minLength: 1, maxLength: 30 }), - email: Type.String({ minLength: 1, maxLength: 255, format: 'email' }), + email: Type.String({ minLength: 1, maxLength: 254, format: 'email' }), password: Type.String({ minLength: 1 }), - logo: Type.String({ format: 'uri-reference' }), - status: Type.String({ maxLength: 255 }), - biography: Type.String(), + logo: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]), + status: Type.Union([Type.String({ maxLength: 50 }), Type.Null()]), + biography: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]), website: Type.String({ maxLength: 255, format: 'uri-reference' }), isConfirmed: Type.Boolean({ default: false }), temporaryToken: Type.String(), @@ -20,32 +20,34 @@ export const userSchema = { updatedAt: date.updatedAt } -const userSchemaWithSettings = { - ...userSchema, - settings: Type.Object(userSettingsSchema) +export const userObjectSchema = Type.Object(userSchema) + +export const userPublicWithoutSettingsSchema = { + id, + name: userSchema.name, + email: Type.Union([userSchema.email, Type.Null()]), + logo: userSchema.logo, + status: userSchema.status, + biography: userSchema.biography, + website: Type.Union([userSchema.website, Type.Null()]), + isConfirmed: userSchema.isConfirmed, + createdAt: date.createdAt, + updatedAt: date.updatedAt } export const userPublicSchema = { - id, - name: userSchema.name, - email: Type.Optional(userSchema.email), - logo: Type.Optional(userSchema.logo), - status: Type.Optional(userSchema.status), - biography: Type.Optional(userSchema.biography), - website: Type.Optional(userSchema.website), - isConfirmed: userSchema.isConfirmed, - createdAt: date.createdAt, - updatedAt: date.updatedAt, - settings: Type.Optional(Type.Object(userSettingsSchema)) + ...userPublicWithoutSettingsSchema, + settings: Type.Object(userSettingsSchema) } export const userPublicObjectSchema = Type.Object(userPublicSchema) export const userCurrentSchema = Type.Object({ - ...userSchemaWithSettings, + ...userPublicSchema, currentStrategy: Type.Union([...strategiesTypebox]), strategies: Type.Array(Type.Union([...strategiesTypebox])) }) +export type User = Static export type UserPublic = Static export type UserCurrent = Static diff --git a/next.config.js b/next.config.js index 4cdf79a..c22e29c 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,12 @@ module.exports = nextTranslate( pwa: { disable: process.env.NODE_ENV !== 'production', dest: 'public' + }, + images: { + domains: [ + 'api.thream.divlo.fr', + ...(process.env.NODE_ENV === 'development' ? ['localhost'] : []) + ] } }) ) diff --git a/package-lock.json b/package-lock.json index 7690617..9591cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sinclair/typebox": "0.20.5", "ajv": "8.6.3", "ajv-formats": "2.1.1", + "axios": "0.23.0", "classnames": "2.3.1", "date-and-time": "2.0.1", "next": "11.1.2", @@ -8388,12 +8389,11 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dev": true, + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.14.4" } }, "node_modules/axobject-query": { @@ -16214,7 +16214,6 @@ "version": "1.14.4", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", - "dev": true, "funding": [ { "type": "individual", @@ -37731,6 +37730,15 @@ "node": ">=10.0.0" } }, + "node_modules/wait-on/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/wait-on/node_modules/rxjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz", @@ -45698,12 +45706,11 @@ "dev": true }, "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dev": true, + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", "requires": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.14.4" } }, "axobject-query": { @@ -51976,8 +51983,7 @@ "follow-redirects": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", - "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", - "dev": true + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==" }, "for-in": { "version": "1.0.2", @@ -68666,6 +68672,15 @@ "rxjs": "^7.1.0" }, "dependencies": { + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.0" + } + }, "rxjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz", diff --git a/package.json b/package.json index 966e90a..a0e7661 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@sinclair/typebox": "0.20.5", "ajv": "8.6.3", "ajv-formats": "2.1.1", + "axios": "0.23.0", "classnames": "2.3.1", "date-and-time": "2.0.1", "next": "11.1.2", diff --git a/pages/application/guilds/create.tsx b/pages/application/guilds/create.tsx index 60182aa..663bd45 100644 --- a/pages/application/guilds/create.tsx +++ b/pages/application/guilds/create.tsx @@ -1,77 +1,19 @@ -import Image from 'next/image' -import { Form } from 'react-component-form' -import TextareaAutosize from 'react-textarea-autosize' - import { Head } from 'components/Head' import { Application } from 'components/Application' -import { Input } from 'components/design/Input' -import { Main } from 'components/design/Main' -import { Button } from 'components/design/Button' -import { FormState } from 'components/design/FormState' + import { authenticationFromServerSide, AuthenticationProvider, PagePropsWithAuthentication } from 'utils/authentication' +import { CreateGuild } from 'components/Application/CreateGuild' -const CreateGuild: React.FC = (props) => { +const CreateGuildPage: React.FC = (props) => { return ( -
-
-
-
- -
- -
- -
-
- - - -
-
- -
-
- - -
-
- - -
-
+
) @@ -81,4 +23,4 @@ export const getServerSideProps = authenticationFromServerSide({ shouldBeAuthenticated: true }) -export default CreateGuild +export default CreateGuildPage diff --git a/pages/application/users/[userId].tsx b/pages/application/users/[userId].tsx index 3245364..5ca68a9 100644 --- a/pages/application/users/[userId].tsx +++ b/pages/application/users/[userId].tsx @@ -5,8 +5,7 @@ import { AuthenticationProvider, PagePropsWithAuthentication } from 'utils/authentication' - -import { UserProfile } from 'components/UserProfile' +import { UserProfile } from 'components/Application/UserProfile' const UserProfilePage: React.FC = (props) => { return ( diff --git a/pages/authentication/forgot-password.tsx b/pages/authentication/forgot-password.tsx index 255a271..7344a30 100644 --- a/pages/authentication/forgot-password.tsx +++ b/pages/authentication/forgot-password.tsx @@ -1,10 +1,7 @@ -import { useState, useMemo } from 'react' import Link from 'next/link' import { AuthenticationForm } from 'components/Authentication' import useTranslation from 'next-translate/useTranslation' -import { HandleForm } from 'react-component-form' import axios from 'axios' -import { Type } from '@sinclair/typebox' import { Head } from 'components/Head' import { Header } from 'components/Header' @@ -13,54 +10,40 @@ import { Footer, FooterProps } from 'components/Footer' import { Input } from 'components/design/Input' import { Button } from 'components/design/Button' import { FormState } from 'components/design/FormState' -import { useFormState } from 'hooks/useFormState' import { authenticationFromServerSide } from 'utils/authentication' import { ScrollableBody } from 'components/ScrollableBody' +import { userSchema } from 'models/User' import { api } from 'utils/api' -import { userSchema } from '../../models/User' -import { ajv } from '../../utils/ajv' +import { HandleSubmitCallback, useForm } from 'hooks/useForm' const ForgotPassword: React.FC = (props) => { const { t } = useTranslation() const { version } = props - const [formState, setFormState] = useFormState() - const [messageTranslationKey, setMessageTranslationKey] = useState< - string | undefined - >(undefined) - const validateSchema = useMemo(() => { - return Type.Object({ - email: userSchema.email - }) - }, []) + const { formState, message, errors, getErrorTranslation, handleSubmit } = + useForm({ validateSchemaObject: { email: userSchema.email } }) - const validate = useMemo(() => { - return ajv.compile(validateSchema) - }, [validateSchema]) - - const handleSubmit: HandleForm = async (formData, formElement) => { - const isValid = validate(formData) - if (!isValid) { - setFormState('error') - setMessageTranslationKey('errors:email') - } else { - setFormState('loading') - try { - await api.post( - `/users/reset-password?redirectURI=${window.location.origin}/authentication/reset-password`, - formData - ) - formElement.reset() - setFormState('success') - setMessageTranslationKey('authentication:success-forgot-password') - } catch (error) { - setFormState('error') - if (axios.isAxiosError(error) && error.response?.status === 400) { - setMessageTranslationKey('errors:email') - } else { - setMessageTranslationKey('errors:server-error') + const onSubmit: HandleSubmitCallback = async (formData) => { + try { + await api.post( + `/users/reset-password?redirectURI=${window.location.origin}/authentication/reset-password`, + formData + ) + return { + type: 'success', + value: 'authentication:success-forgot-password' + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + return { + type: 'error', + value: 'errors:email' } } + return { + type: 'error', + value: 'errors:server-error' + } } } @@ -69,7 +52,7 @@ const ForgotPassword: React.FC = (props) => {
- +
diff --git a/pages/authentication/reset-password.tsx b/pages/authentication/reset-password.tsx index 6aabc68..8d21710 100644 --- a/pages/authentication/reset-password.tsx +++ b/pages/authentication/reset-password.tsx @@ -1,9 +1,6 @@ -import { useState, useMemo } from 'react' import { useRouter } from 'next/router' import useTranslation from 'next-translate/useTranslation' -import { HandleForm } from 'react-component-form' import axios from 'axios' -import { Type } from '@sinclair/typebox' import { Head } from 'components/Head' import { Header } from 'components/Header' @@ -12,54 +9,40 @@ import { Footer, FooterProps } from 'components/Footer' import { Input } from 'components/design/Input' import { Button } from 'components/design/Button' import { FormState } from 'components/design/FormState' -import { useFormState } from 'hooks/useFormState' import { authenticationFromServerSide } from 'utils/authentication' import { AuthenticationForm } from 'components/Authentication' import { ScrollableBody } from 'components/ScrollableBody/ScrollableBody' import { api } from 'utils/api' import { userSchema } from '../../models/User' -import { ajv } from '../../utils/ajv' +import { HandleSubmitCallback, useForm } from 'hooks/useForm' const ResetPassword: React.FC = (props) => { const { t } = useTranslation() const router = useRouter() const { version } = props - const [formState, setFormState] = useFormState() - const [messageTranslationKey, setMessageTranslationKey] = useState< - string | undefined - >(undefined) - const validateSchema = useMemo(() => { - return Type.Object({ - password: userSchema.password - }) - }, []) + const { formState, message, errors, getErrorTranslation, handleSubmit } = + useForm({ validateSchemaObject: { password: userSchema.password } }) - const validate = useMemo(() => { - return ajv.compile(validateSchema) - }, [validateSchema]) - - const handleSubmit: HandleForm = async (formData, formElement) => { - const isValid = validate(formData) - if (!isValid) { - setFormState('error') - setMessageTranslationKey('errors:invalid') - } else { - setFormState('loading') - try { - await api.put(`/users/reset-password`, { - ...formData, - temporaryToken: router.query.temporaryToken - }) - await router.push('/authentication/signin') - } catch (error) { - setFormState('error') - if (axios.isAxiosError(error) && error.response?.status === 400) { - setMessageTranslationKey('errors:invalid') - } else { - setMessageTranslationKey('errors:server-error') + const onSubmit: HandleSubmitCallback = async (formData) => { + try { + await api.put(`/users/reset-password`, { + ...formData, + temporaryToken: router.query.temporaryToken + }) + await router.push('/authentication/signin') + return null + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 400) { + return { + type: 'error', + value: 'errors:invalid' } } + return { + type: 'error', + value: 'errors:server-error' + } } } @@ -68,7 +51,7 @@ const ResetPassword: React.FC = (props) => {
- + = (props) => { id='message' state={formState} message={ - messageTranslationKey != null ? t(messageTranslationKey) : null + message != null ? message : getErrorTranslation(errors.password) } />
diff --git a/public/images/data/divlo.png b/public/images/data/divlo.png deleted file mode 100644 index 06bb347..0000000 Binary files a/public/images/data/divlo.png and /dev/null differ diff --git a/public/images/data/user-default.png b/public/images/data/user-default.png new file mode 100644 index 0000000..d3d6fbe Binary files /dev/null and b/public/images/data/user-default.png differ diff --git a/utils/authentication/Authentication.ts b/utils/authentication/Authentication.ts index 8ecbfa6..3c19450 100644 --- a/utils/authentication/Authentication.ts +++ b/utils/authentication/Authentication.ts @@ -1,8 +1,8 @@ import axios, { AxiosInstance } from 'axios' import { io, Socket } from 'socket.io-client' -import { API_URL } from 'utils/api' -import { cookies } from 'utils/cookies' +import { API_URL } from '../api' +import { cookies } from '../cookies' import { Tokens } from './' import { fetchRefreshToken } from './authenticationFromServerSide' @@ -46,6 +46,7 @@ export class Authentication { ) this.setAccessToken(accessToken) } + config.headers = config.headers == null ? {} : config.headers config.headers.Authorization = `${this.tokens.type} ${this.tokens.accessToken}` return config }, diff --git a/utils/authentication/AuthenticationContext.tsx b/utils/authentication/AuthenticationContext.tsx index e27ec54..abac6c3 100644 --- a/utils/authentication/AuthenticationContext.tsx +++ b/utils/authentication/AuthenticationContext.tsx @@ -1,10 +1,10 @@ import { createContext, useState, useEffect, useMemo, useContext } from 'react' +import { useTheme } from 'next-themes' import useTranslation from 'next-translate/useTranslation' import setLanguage from 'next-translate/setLanguage' import { Authentication, PagePropsWithAuthentication } from './' -import { useTheme } from 'next-themes' -import { UserCurrent } from 'models/User' +import { UserCurrent } from '../../models/User' export interface AuthenticationValue { authentication: Authentication diff --git a/utils/authentication/authenticationFromServerSide.ts b/utils/authentication/authenticationFromServerSide.ts index be9bf9f..08dec58 100644 --- a/utils/authentication/authenticationFromServerSide.ts +++ b/utils/authentication/authenticationFromServerSide.ts @@ -1,10 +1,11 @@ -import { AxiosInstance } from 'axios' +import { AxiosInstance, AxiosResponse } from 'axios' import { GetServerSideProps, GetServerSidePropsContext, Redirect } from 'next' -import { api } from 'utils/api' -import { Cookies } from 'utils/cookies' +import { api } from '../api' +import { Cookies } from '../cookies' import { RefreshTokenResponse, Tokens } from './index' import { Authentication } from './Authentication' +import { UserCurrent } from '../../models/User' export const fetchRefreshToken = async ( refreshToken: string @@ -70,9 +71,10 @@ export const authenticationFromServerSide = ( } else { let data: Redirect | any = {} const authentication = new Authentication(tokens) - const { data: currentUser } = await authentication.api.get( - '/users/current' - ) + const { data: currentUser } = await authentication.api.get< + unknown, + AxiosResponse + >('/users/current') if (fetchData != null) { data = await fetchData(context, authentication.api) } diff --git a/utils/authentication/index.ts b/utils/authentication/index.ts index a37f023..291fe82 100644 --- a/utils/authentication/index.ts +++ b/utils/authentication/index.ts @@ -1,4 +1,4 @@ -import { UserCurrent } from 'models/User' +import { UserCurrent } from '../../models/User' export interface RefreshTokenResponse { accessToken: string