feat: create a guild (#1)

This commit is contained in:
Divlo 2021-10-26 16:38:55 +02:00 committed by GitHub
parent a0fa66e8f5
commit d8fab08585
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 530 additions and 315 deletions

View File

@ -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<ApplicationProps> = (props) => {
const { children, path } = props
const { t } = useTranslation()
const { user } = useAuthentication()
const [visibleSidebars, setVisibleSidebars] = useState({
@ -138,10 +141,10 @@ export const Application: React.FC<ApplicationProps> = (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<ApplicationProps> = (props) => {
>
<Image
className='rounded-full'
src='/images/data/divlo.png'
src={
user.logo == null
? '/images/data/user-default.png'
: API_URL + user.logo
}
alt='logo'
width={48}
height={48}

View File

@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { CreateGuild as Component } from './CreateGuild'
const Stories: Meta = {
title: 'CreateGuild',
component: Component
}
export default Stories
export const CreateGuild: Story = (arguments_) => {
return <Component {...arguments_} />
}
CreateGuild.args = {}

View File

@ -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 (
<Main>
<Form className='w-4/6 max-w-xs' onSubmit={handleSubmit(onSubmit)}>
<Input
type='text'
placeholder={t('common:name')}
name='name'
label={t('common:name')}
error={getErrorTranslation(errors.name)}
/>
<div className='flex flex-col'>
<div className='flex justify-between mt-6 mb-2'>
<label className='pl-1' htmlFor='description'>
Description
</label>
</div>
<div className='mt-0 relative'>
<TextareaAutosize
className='p-3 rounded-lg bg-[#f1f1f1] text-[#2a2a2a] caret-green-600 font-paragraph w-full focus:border focus:outline-none resize-none focus:shadow-green'
placeholder='Description...'
id='description'
name='description'
wrap='soft'
></TextareaAutosize>
</div>
</div>
<Button className='w-full mt-6' type='submit' data-cy='submit'>
{t('application:create')}
</Button>
</Form>
<FormState id='message' state={formState} message={message} />
</Main>
)
}

View File

@ -0,0 +1 @@
export * from './CreateGuild'

View File

@ -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'

View File

@ -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'

View File

@ -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<UserProfileProps> = (props) => {
const { user, isOwner = false } = props
console.log(user)
const { t } = useTranslation()
const handleSubmitChanges = (

View File

@ -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<string, any> | 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,68 +28,37 @@ export const Authentication: React.FC<AuthenticationProps> = (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<Errors>({
name: null,
email: null,
password: null
})
const validateSchema = useMemo(() => {
return Type.Object({
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')
const onSubmit: HandleSubmitCallback = async (formData) => {
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')
return {
type: 'success',
value: 'authentication:success-signup'
}
} catch (error) {
setFormState('error')
if (axios.isAxiosError(error) && error.response?.status === 400) {
setMessageTranslationKey('authentication:alreadyUsed')
} else {
setMessageTranslationKey('errors:server-error')
return {
type: 'error',
value: 'authentication:alreadyUsed'
}
}
return {
type: 'error',
value: 'errors:server-error'
}
}
} else {
@ -114,14 +67,18 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
const authentication = new AuthenticationClass(data)
authentication.signin()
await router.push('/application')
return null
} 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: 'authentication:wrong-credentials'
}
}
return {
type: 'error',
value: 'errors:server-error'
}
}
}
}
@ -138,13 +95,13 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
<section className='text-center text-lg font-paragraph pt-8'>
{t('authentication:or')}
</section>
<AuthenticationForm onSubmit={handleSubmit}>
<AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
{mode === 'signup' && (
<Input
type='text'
placeholder={t('authentication:name')}
placeholder={t('common:name')}
name='name'
label={t('authentication:name')}
label={t('common:name')}
error={getErrorTranslation(errors.name)}
/>
)}
@ -182,13 +139,7 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
</Link>
</p>
</AuthenticationForm>
<FormState
id='message'
state={formState}
message={
messageTranslationKey != null ? t(messageTranslationKey) : null
}
/>
<FormState id='message' state={formState} message={message} />
</Main>
)
}

View File

@ -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()
}

View File

@ -0,0 +1,8 @@
export const guild = {
id: 1,
name: 'GuildExample',
description: 'guild example.',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}

View File

@ -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]
}
}
}
}

View File

@ -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
}

View File

@ -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'
}

View File

@ -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.')
})
})

View File

@ -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 🙈.'
)
})
})

1
hooks/useForm/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './useForm'

108
hooks/useForm/useForm.ts Normal file
View File

@ -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<string, any> | 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<Message | null>
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<Errors>({})
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
}
}

View File

@ -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"]
}
}

View File

@ -1,3 +1,5 @@
{
"website": "Website"
"website": "Website",
"create": "Create",
"create-a-guild": "Create a Guild"
}

View File

@ -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",

View File

@ -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"
}

View File

@ -1,3 +1,5 @@
{
"website": "Site web"
"website": "Site web",
"create": "Crée",
"create-a-guild": "Crée une Guilde"
}

View File

@ -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",

View File

@ -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"
}

View File

@ -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

View File

@ -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<typeof guildCompleteObjectSchema>

View File

@ -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'
}),

View File

@ -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<typeof userObjectSchema>
export type UserPublic = Static<typeof userPublicObjectSchema>
export type UserCurrent = Static<typeof userCurrentSchema>

View File

@ -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'] : [])
]
}
})
)

41
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<PagePropsWithAuthentication> = (props) => {
const CreateGuildPage: React.FC<PagePropsWithAuthentication> = (props) => {
return (
<AuthenticationProvider authentication={props.authentication}>
<Head title='Thream | Create a Guild' />
<Application path='/application/guilds/create'>
<Main>
<Form className='w-4/6 max-w-xs'>
<div className='flex flex-col'>
<div className='flex justify-between mt-6 mb-2'>
<label className='pl-1' htmlFor='icon'>
Icon
</label>
</div>
<div className='mt-2 relative flex flex-col items-center'>
<button className='relative w-14 h-14 flex items-center overflow-hidden justify-center text-green-800 dark:text-green-400 transform scale-125 hover:scale-150'>
<Image
src='/images/data/guild-default.png'
alt='logo'
width={56}
height={56}
className='w-14 h-14 rounded-full'
/>
<input
name='icon'
id='icon'
type='file'
className='absolute w-22 h-20 -top-8 -left-8 opacity-0 cursor-pointer'
/>
</button>
</div>
</div>
<Input type='text' placeholder='Name' name='name' label='Name' />
<div className='flex flex-col'>
<div className='flex justify-between mt-6 mb-2'>
<label className='pl-1' htmlFor='description'>
Description
</label>
</div>
<div className='mt-0 relative'>
<TextareaAutosize
className='p-3 rounded-lg bg-[#f1f1f1] text-[#2a2a2a] caret-green-600 font-paragraph w-full focus:border focus:outline-none resize-none focus:shadow-green'
placeholder='Description...'
id='description'
name='description'
wrap='soft'
></TextareaAutosize>
<FormState state='idle' />
</div>
</div>
<Button className='w-full mt-6' type='submit'>
Create
</Button>
</Form>
</Main>
<CreateGuild />
</Application>
</AuthenticationProvider>
)
@ -81,4 +23,4 @@ export const getServerSideProps = authenticationFromServerSide({
shouldBeAuthenticated: true
})
export default CreateGuild
export default CreateGuildPage

View File

@ -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<PagePropsWithAuthentication> = (props) => {
return (

View File

@ -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,53 +10,39 @@ 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<FooterProps> = (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')
const onSubmit: HandleSubmitCallback = async (formData) => {
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')
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<FooterProps> = (props) => {
<Head title={`Thream | ${t('authentication:forgot-password')}`} />
<Header />
<Main>
<AuthenticationForm onSubmit={handleSubmit}>
<AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
<Input type='email' placeholder='Email' name='email' label='Email' />
<Button data-cy='submit' className='w-full mt-6' type='submit'>
{t('authentication:submit')}
@ -84,7 +67,7 @@ const ForgotPassword: React.FC<FooterProps> = (props) => {
id='message'
state={formState}
message={
messageTranslationKey != null ? t(messageTranslationKey) : null
message != null ? message : getErrorTranslation(errors.email)
}
/>
</Main>

View File

@ -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<FooterProps> = (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')
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) {
setFormState('error')
if (axios.isAxiosError(error) && error.response?.status === 400) {
setMessageTranslationKey('errors:invalid')
} else {
setMessageTranslationKey('errors:server-error')
return {
type: 'error',
value: 'errors:invalid'
}
}
return {
type: 'error',
value: 'errors:server-error'
}
}
}
@ -68,7 +51,7 @@ const ResetPassword: React.FC<FooterProps> = (props) => {
<Head title={`Thream | ${t('authentication:reset-password')}`} />
<Header />
<Main>
<AuthenticationForm onSubmit={handleSubmit}>
<AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
<Input
type='password'
placeholder='Password'
@ -83,7 +66,7 @@ const ResetPassword: React.FC<FooterProps> = (props) => {
id='message'
state={formState}
message={
messageTranslationKey != null ? t(messageTranslationKey) : null
message != null ? message : getErrorTranslation(errors.password)
}
/>
</Main>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -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
},

View File

@ -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

View File

@ -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<UserCurrent>
>('/users/current')
if (fetchData != null) {
data = await fetchData(context, authentication.api)
}

View File

@ -1,4 +1,4 @@
import { UserCurrent } from 'models/User'
import { UserCurrent } from '../../models/User'
export interface RefreshTokenResponse {
accessToken: string