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 { useState, useEffect, useMemo } from 'react'
import Image from 'next/image' import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation'
import { import {
CogIcon, CogIcon,
PlusIcon, PlusIcon,
@ -19,6 +20,7 @@ import { Guilds } from './Guilds/Guilds'
import { Divider } from '../design/Divider' import { Divider } from '../design/Divider'
import { Members } from './Members' import { Members } from './Members'
import { useAuthentication } from 'utils/authentication' import { useAuthentication } from 'utils/authentication'
import { API_URL } from 'utils/api'
export interface GuildsChannelsPath { export interface GuildsChannelsPath {
guildId: number guildId: number
@ -37,6 +39,7 @@ export interface ApplicationProps {
export const Application: React.FC<ApplicationProps> = (props) => { export const Application: React.FC<ApplicationProps> = (props) => {
const { children, path } = props const { children, path } = props
const { t } = useTranslation()
const { user } = useAuthentication() const { user } = useAuthentication()
const [visibleSidebars, setVisibleSidebars] = useState({ const [visibleSidebars, setVisibleSidebars] = useState({
@ -138,10 +141,10 @@ export const Application: React.FC<ApplicationProps> = (props) => {
return 'Join a Guild' return 'Join a Guild'
} }
if (path === '/application/guilds/create') { if (path === '/application/guilds/create') {
return 'Create a Guild' return t('application:create-a-guild')
} }
return 'Application' return 'Application'
}, [path]) }, [path, t])
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
@ -193,7 +196,11 @@ export const Application: React.FC<ApplicationProps> = (props) => {
> >
<Image <Image
className='rounded-full' className='rounded-full'
src='/images/data/divlo.png' src={
user.logo == null
? '/images/data/user-default.png'
: API_URL + user.logo
}
alt='logo' alt='logo'
width={48} width={48}
height={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 { 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' import { UserProfile as Component, UserProfileProps } from './UserProfile'

View File

@ -1,6 +1,6 @@
import { render } from '@testing-library/react' 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' import { UserProfile } from './UserProfile'

View File

@ -4,7 +4,7 @@ import classNames from 'classnames'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import date from 'date-and-time' import date from 'date-and-time'
import { UserPublic } from '../../models/User' import { UserPublic } from '../../../models/User'
export interface UserProfileProps { export interface UserProfileProps {
className?: string className?: string
@ -15,8 +15,6 @@ export interface UserProfileProps {
export const UserProfile: React.FC<UserProfileProps> = (props) => { export const UserProfile: React.FC<UserProfileProps> = (props) => {
const { user, isOwner = false } = props const { user, isOwner = false } = props
console.log(user)
const { t } = useTranslation() const { t } = useTranslation()
const handleSubmitChanges = ( const handleSubmitChanges = (

View File

@ -1,11 +1,7 @@
import { useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import { useTheme } from 'next-themes' 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 axios from 'axios'
import { SocialMediaButton } from '../design/SocialMediaButton' import { SocialMediaButton } from '../design/SocialMediaButton'
@ -13,26 +9,14 @@ import { Main } from '../design/Main'
import { Input } from '../design/Input' import { Input } from '../design/Input'
import { Button } from '../design/Button' import { Button } from '../design/Button'
import { FormState } from '../design/FormState' import { FormState } from '../design/FormState'
import { useFormState } from '../../hooks/useFormState'
import { AuthenticationForm } from './' import { AuthenticationForm } from './'
import { userSchema } from '../../models/User' import { userSchema } from '../../models/User'
import { ajv } from '../../utils/ajv'
import { api } from 'utils/api' import { api } from 'utils/api'
import { import {
Tokens, Tokens,
Authentication as AuthenticationClass Authentication as AuthenticationClass
} from '../../utils/authentication' } from '../../utils/authentication'
import { getErrorTranslationKey } from './getErrorTranslationKey' import { useForm, HandleSubmitCallback } from '../../hooks/useForm'
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 interface AuthenticationProps { export interface AuthenticationProps {
mode: 'signup' | 'signin' mode: 'signup' | 'signin'
@ -44,84 +28,57 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
const router = useRouter() const router = useRouter()
const { lang, t } = useTranslation() const { lang, t } = useTranslation()
const { theme } = useTheme() 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(() => { const { errors, formState, message, getErrorTranslation, handleSubmit } =
return Type.Object({ useForm({
...(mode === 'signup' && { name: userSchema.name }), validateSchemaObject: {
email: userSchema.email, ...(mode === 'signup' && { name: userSchema.name }),
password: userSchema.password email: userSchema.email,
password: userSchema.password
}
}) })
}, [mode])
const validate = useMemo(() => { const onSubmit: HandleSubmitCallback = async (formData) => {
return ajv.compile(validateSchema) if (mode === 'signup') {
}, [validateSchema]) try {
await api.post(
const getErrorTranslation = (error?: ErrorObject | null): string | null => { `/users/signup?redirectURI=${window.location.origin}/authentication/signin`,
if (error != null) { { ...formData, language: lang, theme }
return t(getErrorTranslationKey(error)).replace( )
'{expected}', return {
error?.params?.limit type: 'success',
) value: 'authentication:success-signup'
} }
return null } catch (error) {
} if (axios.isAxiosError(error) && error.response?.status === 400) {
return {
const handleSubmit: HandleForm = async (formData, formElement) => { type: 'error',
const isValid = validate(formData) value: 'authentication:alreadyUsed'
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')
} }
} }
} else { return {
try { type: 'error',
const { data } = await api.post<Tokens>('/users/signin', formData) value: 'errors:server-error'
const authentication = new AuthenticationClass(data) }
authentication.signin() }
await router.push('/application') } else {
} catch (error) { try {
setFormState('error') const { data } = await api.post<Tokens>('/users/signin', formData)
if (axios.isAxiosError(error) && error.response?.status === 400) { const authentication = new AuthenticationClass(data)
setMessageTranslationKey('authentication:wrong-credentials') authentication.signin()
} else { await router.push('/application')
setMessageTranslationKey('errors:server-error') 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<AuthenticationProps> = (props) => {
<section className='text-center text-lg font-paragraph pt-8'> <section className='text-center text-lg font-paragraph pt-8'>
{t('authentication:or')} {t('authentication:or')}
</section> </section>
<AuthenticationForm onSubmit={handleSubmit}> <AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
{mode === 'signup' && ( {mode === 'signup' && (
<Input <Input
type='text' type='text'
placeholder={t('authentication:name')} placeholder={t('common:name')}
name='name' name='name'
label={t('authentication:name')} label={t('common:name')}
error={getErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
/> />
)} )}
@ -182,13 +139,7 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
</Link> </Link>
</p> </p>
</AuthenticationForm> </AuthenticationForm>
<FormState <FormState id='message' state={formState} message={message} />
id='message'
state={formState}
message={
messageTranslationKey != null ? t(messageTranslationKey) : null
}
/>
</Main> </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 { UserSettings } from '../../../models/UserSettings'
import { UserPublic } from '../../../models/User' import { User } from '../../../models/User'
export const user: UserPublic = { export const user: User = {
id: 1, id: 1,
name: 'Divlo', name: 'Divlo',
email: 'contact@divlo.fr', email: 'contact@divlo.fr',
logo: undefined, password: 'somepassword',
status: undefined, logo: null,
biography: undefined, status: null,
biography: null,
website: 'https://divlo.fr', website: 'https://divlo.fr',
isConfirmed: true, isConfirmed: true,
temporaryToken: 'temporaryUUIDtoken',
temporaryExpirationToken: '2021-10-20T20:59:08.485Z',
createdAt: '2021-10-20T20:30:51.595Z', createdAt: '2021-10-20T20:30:51.595Z',
updatedAt: '2021-10-20T20:59:08.485Z' 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.visit('/authentication/reset-password')
cy.get('#message').should('not.exist') cy.get('#message').should('not.exist')
cy.get('[data-cy=submit]').click() 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/reset-password": ["authentication", "errors"],
"/authentication/signup": ["authentication", "errors"], "/authentication/signup": ["authentication", "errors"],
"/authentication/signin": ["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", "or": "OR",
"password": "Password", "password": "Password",
"name": "Name",
"already-have-an-account": "Already have an account?", "already-have-an-account": "Already have an account?",
"dont-have-an-account": "Don't have an account?", "dont-have-an-account": "Don't have an account?",
"submit": "Submit", "submit": "Submit",

View File

@ -2,5 +2,6 @@
"english": "English", "english": "English",
"french": "French", "french": "French",
"all-rights-reserved": "All rights reserved", "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", "or": "OU",
"password": "Mot de passe", "password": "Mot de passe",
"name": "Nom",
"already-have-an-account": "Vous avez déjà un compte ?", "already-have-an-account": "Vous avez déjà un compte ?",
"dont-have-an-account": "Vous n'avez pas de compte ?", "dont-have-an-account": "Vous n'avez pas de compte ?",
"submit": "Soumettre", "submit": "Soumettre",

View File

@ -2,5 +2,6 @@
"english": "Anglais", "english": "Anglais",
"french": "Français", "french": "Français",
"all-rights-reserved": "Tous droits réservés", "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 = { export const channelSchema = {
id, id,
name: Type.String({ maxLength: 255 }), name: Type.String({ minLength: 1, maxLength: 20 }),
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt, updatedAt: date.updatedAt,
guildId: id 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' import { date, id } from './utils'
export const guildSchema = { export const guildSchema = {
id, id,
name: Type.String({ minLength: 3, maxLength: 30 }), name: Type.String({ minLength: 1, maxLength: 30 }),
icon: Type.String({ format: 'uri-reference' }), icon: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]),
description: Type.String({ maxLength: 160 }), description: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]),
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt 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 = { export const messageSchema = {
id, id,
value: Type.String(), value: Type.String({
minLength: 1,
maxLength: 20_000
}),
type: Type.Union(types, { default: 'text' }), type: Type.Union(types, { default: 'text' }),
mimetype: Type.String({ mimetype: Type.String({
maxLength: 255, maxLength: 127,
default: 'text/plain', default: 'text/plain',
format: 'mimetype' format: 'mimetype'
}), }),

View File

@ -7,11 +7,11 @@ import { date, id } from './utils'
export const userSchema = { export const userSchema = {
id, id,
name: Type.String({ minLength: 1, maxLength: 30 }), 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 }), password: Type.String({ minLength: 1 }),
logo: Type.String({ format: 'uri-reference' }), logo: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]),
status: Type.String({ maxLength: 255 }), status: Type.Union([Type.String({ maxLength: 50 }), Type.Null()]),
biography: Type.String(), biography: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]),
website: Type.String({ maxLength: 255, format: 'uri-reference' }), website: Type.String({ maxLength: 255, format: 'uri-reference' }),
isConfirmed: Type.Boolean({ default: false }), isConfirmed: Type.Boolean({ default: false }),
temporaryToken: Type.String(), temporaryToken: Type.String(),
@ -20,32 +20,34 @@ export const userSchema = {
updatedAt: date.updatedAt updatedAt: date.updatedAt
} }
const userSchemaWithSettings = { export const userObjectSchema = Type.Object(userSchema)
...userSchema,
settings: Type.Object(userSettingsSchema) 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 = { export const userPublicSchema = {
id, ...userPublicWithoutSettingsSchema,
name: userSchema.name, settings: Type.Object(userSettingsSchema)
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))
} }
export const userPublicObjectSchema = Type.Object(userPublicSchema) export const userPublicObjectSchema = Type.Object(userPublicSchema)
export const userCurrentSchema = Type.Object({ export const userCurrentSchema = Type.Object({
...userSchemaWithSettings, ...userPublicSchema,
currentStrategy: Type.Union([...strategiesTypebox]), currentStrategy: Type.Union([...strategiesTypebox]),
strategies: Type.Array(Type.Union([...strategiesTypebox])) strategies: Type.Array(Type.Union([...strategiesTypebox]))
}) })
export type User = Static<typeof userObjectSchema>
export type UserPublic = Static<typeof userPublicObjectSchema> export type UserPublic = Static<typeof userPublicObjectSchema>
export type UserCurrent = Static<typeof userCurrentSchema> export type UserCurrent = Static<typeof userCurrentSchema>

View File

@ -6,6 +6,12 @@ module.exports = nextTranslate(
pwa: { pwa: {
disable: process.env.NODE_ENV !== 'production', disable: process.env.NODE_ENV !== 'production',
dest: 'public' 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", "@sinclair/typebox": "0.20.5",
"ajv": "8.6.3", "ajv": "8.6.3",
"ajv-formats": "2.1.1", "ajv-formats": "2.1.1",
"axios": "0.23.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"date-and-time": "2.0.1", "date-and-time": "2.0.1",
"next": "11.1.2", "next": "11.1.2",
@ -8388,12 +8389,11 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "0.21.4", "version": "0.23.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==",
"dev": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.14.0" "follow-redirects": "^1.14.4"
} }
}, },
"node_modules/axobject-query": { "node_modules/axobject-query": {
@ -16214,7 +16214,6 @@
"version": "1.14.4", "version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -37731,6 +37730,15 @@
"node": ">=10.0.0" "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": { "node_modules/wait-on/node_modules/rxjs": {
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz",
@ -45698,12 +45706,11 @@
"dev": true "dev": true
}, },
"axios": { "axios": {
"version": "0.21.4", "version": "0.23.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==",
"dev": true,
"requires": { "requires": {
"follow-redirects": "^1.14.0" "follow-redirects": "^1.14.4"
} }
}, },
"axobject-query": { "axobject-query": {
@ -51976,8 +51983,7 @@
"follow-redirects": { "follow-redirects": {
"version": "1.14.4", "version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
"dev": true
}, },
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
@ -68666,6 +68672,15 @@
"rxjs": "^7.1.0" "rxjs": "^7.1.0"
}, },
"dependencies": { "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": { "rxjs": {
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz",

View File

@ -40,6 +40,7 @@
"@sinclair/typebox": "0.20.5", "@sinclair/typebox": "0.20.5",
"ajv": "8.6.3", "ajv": "8.6.3",
"ajv-formats": "2.1.1", "ajv-formats": "2.1.1",
"axios": "0.23.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"date-and-time": "2.0.1", "date-and-time": "2.0.1",
"next": "11.1.2", "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 { Head } from 'components/Head'
import { Application } from 'components/Application' 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 { import {
authenticationFromServerSide, authenticationFromServerSide,
AuthenticationProvider, AuthenticationProvider,
PagePropsWithAuthentication PagePropsWithAuthentication
} from 'utils/authentication' } from 'utils/authentication'
import { CreateGuild } from 'components/Application/CreateGuild'
const CreateGuild: React.FC<PagePropsWithAuthentication> = (props) => { const CreateGuildPage: React.FC<PagePropsWithAuthentication> = (props) => {
return ( return (
<AuthenticationProvider authentication={props.authentication}> <AuthenticationProvider authentication={props.authentication}>
<Head title='Thream | Create a Guild' /> <Head title='Thream | Create a Guild' />
<Application path='/application/guilds/create'> <Application path='/application/guilds/create'>
<Main> <CreateGuild />
<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>
</Application> </Application>
</AuthenticationProvider> </AuthenticationProvider>
) )
@ -81,4 +23,4 @@ export const getServerSideProps = authenticationFromServerSide({
shouldBeAuthenticated: true shouldBeAuthenticated: true
}) })
export default CreateGuild export default CreateGuildPage

View File

@ -5,8 +5,7 @@ import {
AuthenticationProvider, AuthenticationProvider,
PagePropsWithAuthentication PagePropsWithAuthentication
} from 'utils/authentication' } from 'utils/authentication'
import { UserProfile } from 'components/Application/UserProfile'
import { UserProfile } from 'components/UserProfile'
const UserProfilePage: React.FC<PagePropsWithAuthentication> = (props) => { const UserProfilePage: React.FC<PagePropsWithAuthentication> = (props) => {
return ( return (

View File

@ -1,10 +1,7 @@
import { useState, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { AuthenticationForm } from 'components/Authentication' import { AuthenticationForm } from 'components/Authentication'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import { HandleForm } from 'react-component-form'
import axios from 'axios' import axios from 'axios'
import { Type } from '@sinclair/typebox'
import { Head } from 'components/Head' import { Head } from 'components/Head'
import { Header } from 'components/Header' import { Header } from 'components/Header'
@ -13,54 +10,40 @@ import { Footer, FooterProps } from 'components/Footer'
import { Input } from 'components/design/Input' import { Input } from 'components/design/Input'
import { Button } from 'components/design/Button' import { Button } from 'components/design/Button'
import { FormState } from 'components/design/FormState' import { FormState } from 'components/design/FormState'
import { useFormState } from 'hooks/useFormState'
import { authenticationFromServerSide } from 'utils/authentication' import { authenticationFromServerSide } from 'utils/authentication'
import { ScrollableBody } from 'components/ScrollableBody' import { ScrollableBody } from 'components/ScrollableBody'
import { userSchema } from 'models/User'
import { api } from 'utils/api' import { api } from 'utils/api'
import { userSchema } from '../../models/User' import { HandleSubmitCallback, useForm } from 'hooks/useForm'
import { ajv } from '../../utils/ajv'
const ForgotPassword: React.FC<FooterProps> = (props) => { const ForgotPassword: React.FC<FooterProps> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { version } = props const { version } = props
const [formState, setFormState] = useFormState()
const [messageTranslationKey, setMessageTranslationKey] = useState<
string | undefined
>(undefined)
const validateSchema = useMemo(() => { const { formState, message, errors, getErrorTranslation, handleSubmit } =
return Type.Object({ useForm({ validateSchemaObject: { email: userSchema.email } })
email: userSchema.email
})
}, [])
const validate = useMemo(() => { const onSubmit: HandleSubmitCallback = async (formData) => {
return ajv.compile(validateSchema) try {
}, [validateSchema]) await api.post(
`/users/reset-password?redirectURI=${window.location.origin}/authentication/reset-password`,
const handleSubmit: HandleForm = async (formData, formElement) => { formData
const isValid = validate(formData) )
if (!isValid) { return {
setFormState('error') type: 'success',
setMessageTranslationKey('errors:email') value: 'authentication:success-forgot-password'
} else { }
setFormState('loading') } catch (error) {
try { if (axios.isAxiosError(error) && error.response?.status === 400) {
await api.post( return {
`/users/reset-password?redirectURI=${window.location.origin}/authentication/reset-password`, type: 'error',
formData value: 'errors:email'
)
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: 'error',
value: 'errors:server-error'
}
} }
} }
@ -69,7 +52,7 @@ const ForgotPassword: React.FC<FooterProps> = (props) => {
<Head title={`Thream | ${t('authentication:forgot-password')}`} /> <Head title={`Thream | ${t('authentication:forgot-password')}`} />
<Header /> <Header />
<Main> <Main>
<AuthenticationForm onSubmit={handleSubmit}> <AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
<Input type='email' placeholder='Email' name='email' label='Email' /> <Input type='email' placeholder='Email' name='email' label='Email' />
<Button data-cy='submit' className='w-full mt-6' type='submit'> <Button data-cy='submit' className='w-full mt-6' type='submit'>
{t('authentication:submit')} {t('authentication:submit')}
@ -84,7 +67,7 @@ const ForgotPassword: React.FC<FooterProps> = (props) => {
id='message' id='message'
state={formState} state={formState}
message={ message={
messageTranslationKey != null ? t(messageTranslationKey) : null message != null ? message : getErrorTranslation(errors.email)
} }
/> />
</Main> </Main>

View File

@ -1,9 +1,6 @@
import { useState, useMemo } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import { HandleForm } from 'react-component-form'
import axios from 'axios' import axios from 'axios'
import { Type } from '@sinclair/typebox'
import { Head } from 'components/Head' import { Head } from 'components/Head'
import { Header } from 'components/Header' import { Header } from 'components/Header'
@ -12,54 +9,40 @@ import { Footer, FooterProps } from 'components/Footer'
import { Input } from 'components/design/Input' import { Input } from 'components/design/Input'
import { Button } from 'components/design/Button' import { Button } from 'components/design/Button'
import { FormState } from 'components/design/FormState' import { FormState } from 'components/design/FormState'
import { useFormState } from 'hooks/useFormState'
import { authenticationFromServerSide } from 'utils/authentication' import { authenticationFromServerSide } from 'utils/authentication'
import { AuthenticationForm } from 'components/Authentication' import { AuthenticationForm } from 'components/Authentication'
import { ScrollableBody } from 'components/ScrollableBody/ScrollableBody' import { ScrollableBody } from 'components/ScrollableBody/ScrollableBody'
import { api } from 'utils/api' import { api } from 'utils/api'
import { userSchema } from '../../models/User' import { userSchema } from '../../models/User'
import { ajv } from '../../utils/ajv' import { HandleSubmitCallback, useForm } from 'hooks/useForm'
const ResetPassword: React.FC<FooterProps> = (props) => { const ResetPassword: React.FC<FooterProps> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const { version } = props const { version } = props
const [formState, setFormState] = useFormState()
const [messageTranslationKey, setMessageTranslationKey] = useState<
string | undefined
>(undefined)
const validateSchema = useMemo(() => { const { formState, message, errors, getErrorTranslation, handleSubmit } =
return Type.Object({ useForm({ validateSchemaObject: { password: userSchema.password } })
password: userSchema.password
})
}, [])
const validate = useMemo(() => { const onSubmit: HandleSubmitCallback = async (formData) => {
return ajv.compile(validateSchema) try {
}, [validateSchema]) await api.put(`/users/reset-password`, {
...formData,
const handleSubmit: HandleForm = async (formData, formElement) => { temporaryToken: router.query.temporaryToken
const isValid = validate(formData) })
if (!isValid) { await router.push('/authentication/signin')
setFormState('error') return null
setMessageTranslationKey('errors:invalid') } catch (error) {
} else { if (axios.isAxiosError(error) && error.response?.status === 400) {
setFormState('loading') return {
try { type: 'error',
await api.put(`/users/reset-password`, { value: 'errors:invalid'
...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')
} }
} }
return {
type: 'error',
value: 'errors:server-error'
}
} }
} }
@ -68,7 +51,7 @@ const ResetPassword: React.FC<FooterProps> = (props) => {
<Head title={`Thream | ${t('authentication:reset-password')}`} /> <Head title={`Thream | ${t('authentication:reset-password')}`} />
<Header /> <Header />
<Main> <Main>
<AuthenticationForm onSubmit={handleSubmit}> <AuthenticationForm onSubmit={handleSubmit(onSubmit)}>
<Input <Input
type='password' type='password'
placeholder='Password' placeholder='Password'
@ -83,7 +66,7 @@ const ResetPassword: React.FC<FooterProps> = (props) => {
id='message' id='message'
state={formState} state={formState}
message={ message={
messageTranslationKey != null ? t(messageTranslationKey) : null message != null ? message : getErrorTranslation(errors.password)
} }
/> />
</Main> </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 axios, { AxiosInstance } from 'axios'
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { API_URL } from 'utils/api' import { API_URL } from '../api'
import { cookies } from 'utils/cookies' import { cookies } from '../cookies'
import { Tokens } from './' import { Tokens } from './'
import { fetchRefreshToken } from './authenticationFromServerSide' import { fetchRefreshToken } from './authenticationFromServerSide'
@ -46,6 +46,7 @@ export class Authentication {
) )
this.setAccessToken(accessToken) this.setAccessToken(accessToken)
} }
config.headers = config.headers == null ? {} : config.headers
config.headers.Authorization = `${this.tokens.type} ${this.tokens.accessToken}` config.headers.Authorization = `${this.tokens.type} ${this.tokens.accessToken}`
return config return config
}, },

View File

@ -1,10 +1,10 @@
import { createContext, useState, useEffect, useMemo, useContext } from 'react' import { createContext, useState, useEffect, useMemo, useContext } from 'react'
import { useTheme } from 'next-themes'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage' import setLanguage from 'next-translate/setLanguage'
import { Authentication, PagePropsWithAuthentication } from './' import { Authentication, PagePropsWithAuthentication } from './'
import { useTheme } from 'next-themes' import { UserCurrent } from '../../models/User'
import { UserCurrent } from 'models/User'
export interface AuthenticationValue { export interface AuthenticationValue {
authentication: Authentication authentication: Authentication

View File

@ -1,10 +1,11 @@
import { AxiosInstance } from 'axios' import { AxiosInstance, AxiosResponse } from 'axios'
import { GetServerSideProps, GetServerSidePropsContext, Redirect } from 'next' import { GetServerSideProps, GetServerSidePropsContext, Redirect } from 'next'
import { api } from 'utils/api' import { api } from '../api'
import { Cookies } from 'utils/cookies' import { Cookies } from '../cookies'
import { RefreshTokenResponse, Tokens } from './index' import { RefreshTokenResponse, Tokens } from './index'
import { Authentication } from './Authentication' import { Authentication } from './Authentication'
import { UserCurrent } from '../../models/User'
export const fetchRefreshToken = async ( export const fetchRefreshToken = async (
refreshToken: string refreshToken: string
@ -70,9 +71,10 @@ export const authenticationFromServerSide = (
} else { } else {
let data: Redirect | any = {} let data: Redirect | any = {}
const authentication = new Authentication(tokens) const authentication = new Authentication(tokens)
const { data: currentUser } = await authentication.api.get( const { data: currentUser } = await authentication.api.get<
'/users/current' unknown,
) AxiosResponse<UserCurrent>
>('/users/current')
if (fetchData != null) { if (fetchData != null) {
data = await fetchData(context, authentication.api) 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 { export interface RefreshTokenResponse {
accessToken: string accessToken: string