mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05:00
fix(frontend): refactor api error handling
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
e93144eb40
commit
57bfca7b15
44 changed files with 387 additions and 465 deletions
|
@ -828,11 +828,6 @@ components:
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
format: password
|
format: password
|
||||||
OpenIdLogin:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
openId:
|
|
||||||
type: string
|
|
||||||
ServerStatus:
|
ServerStatus:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
|
@ -23,6 +23,9 @@ describe('Delete note', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays an error notification if something goes wrong', () => {
|
it('displays an error notification if something goes wrong', () => {
|
||||||
|
cy.intercept('DELETE', `api/private/notes/${testNoteId}`, {
|
||||||
|
statusCode: 400
|
||||||
|
})
|
||||||
cy.getByCypressId('sidebar.deleteNote.button').click()
|
cy.getByCypressId('sidebar.deleteNote.button').click()
|
||||||
cy.getByCypressId('sidebar.deleteNote.modal').should('be.visible')
|
cy.getByCypressId('sidebar.deleteNote.modal').should('be.visible')
|
||||||
cy.getByCypressId('sidebar.deleteNote.modal.noteTitle').should('be.visible').text().should('eq', '')
|
cy.getByCypressId('sidebar.deleteNote.modal.noteTitle').should('be.visible').text().should('eq', '')
|
||||||
|
|
|
@ -192,7 +192,6 @@
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"error": {
|
"error": {
|
||||||
"openIdLogin": "OpenID nicht korrekt",
|
|
||||||
"usernamePassword": "Benutzername oder Passwort nicht korrekt"
|
"usernamePassword": "Benutzername oder Passwort nicht korrekt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -545,7 +545,6 @@
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"error": {
|
"error": {
|
||||||
"openIdLogin": "Invalid OpenID provided",
|
|
||||||
"usernamePassword": "Invalid username or password",
|
"usernamePassword": "Invalid username or password",
|
||||||
"loginDisabled": "The login is disabled",
|
"loginDisabled": "The login is disabled",
|
||||||
"other": "There was an error logging you in."
|
"other": "There was an error logging you in."
|
||||||
|
|
|
@ -17,7 +17,7 @@ import type { Alias, NewAliasDto, PrimaryAliasDto } from './types'
|
||||||
* @throws {Error} when the api request wasn't successfull
|
* @throws {Error} when the api request wasn't successfull
|
||||||
*/
|
*/
|
||||||
export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise<Alias> => {
|
export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise<Alias> => {
|
||||||
const response = await new PostApiRequestBuilder<Alias, NewAliasDto>('alias')
|
const response = await new PostApiRequestBuilder<Alias, NewAliasDto>('alias', 'alias')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
noteIdOrAlias,
|
noteIdOrAlias,
|
||||||
newAlias
|
newAlias
|
||||||
|
@ -35,7 +35,7 @@ export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise
|
||||||
* @throws {Error} when the api request wasn't successfull
|
* @throws {Error} when the api request wasn't successfull
|
||||||
*/
|
*/
|
||||||
export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
|
export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
|
||||||
const response = await new PutApiRequestBuilder<Alias, PrimaryAliasDto>('alias/' + alias)
|
const response = await new PutApiRequestBuilder<Alias, PrimaryAliasDto>('alias/' + alias, 'alias')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
primaryAlias: true
|
primaryAlias: true
|
||||||
})
|
})
|
||||||
|
@ -50,5 +50,5 @@ export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteAlias = async (alias: string): Promise<void> => {
|
export const deleteAlias = async (alias: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('alias/' + alias).sendRequest()
|
await new DeleteApiRequestBuilder('alias/' + alias, 'alias').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,5 +11,5 @@ import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-ap
|
||||||
* @throws {Error} if logout is not possible.
|
* @throws {Error} if logout is not possible.
|
||||||
*/
|
*/
|
||||||
export const doLogout = async (): Promise<void> => {
|
export const doLogout = async (): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('auth/logout').sendRequest()
|
await new DeleteApiRequestBuilder('auth/logout', 'auth').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
*/
|
*/
|
||||||
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
import type { LoginDto } from './types'
|
import type { LoginDto } from './types'
|
||||||
import { AuthError } from './types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to log in a user via LDAP credentials.
|
* Requests to log in a user via LDAP credentials.
|
||||||
|
@ -13,17 +12,13 @@ import { AuthError } from './types'
|
||||||
* @param provider The identifier of the LDAP provider with which to login.
|
* @param provider The identifier of the LDAP provider with which to login.
|
||||||
* @param username The username with which to try the login.
|
* @param username The username with which to try the login.
|
||||||
* @param password The password of the user.
|
* @param password The password of the user.
|
||||||
* @throws {AuthError.INVALID_CREDENTIALS} if the LDAP provider denied the given credentials.
|
|
||||||
* @throws {Error} when the api request wasn't successfull
|
* @throws {Error} when the api request wasn't successfull
|
||||||
*/
|
*/
|
||||||
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
|
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
|
||||||
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
|
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider, 'auth')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password
|
||||||
})
|
})
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
401: AuthError.INVALID_CREDENTIALS
|
|
||||||
})
|
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
import type { ChangePasswordDto, LoginDto, RegisterDto } from './types'
|
import type { ChangePasswordDto, LoginDto, RegisterDto } from './types'
|
||||||
import { AuthError, RegisterError } from './types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to do a local login with a provided username and password.
|
* Requests to do a local login with a provided username and password.
|
||||||
|
@ -18,15 +17,11 @@ import { AuthError, RegisterError } from './types'
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const doLocalLogin = async (username: string, password: string): Promise<void> => {
|
export const doLocalLogin = async (username: string, password: string): Promise<void> => {
|
||||||
await new PostApiRequestBuilder<void, LoginDto>('auth/local/login')
|
await new PostApiRequestBuilder<void, LoginDto>('auth/local/login', 'auth')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
username,
|
username,
|
||||||
password
|
password
|
||||||
})
|
})
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: AuthError.LOGIN_DISABLED,
|
|
||||||
401: AuthError.INVALID_CREDENTIALS
|
|
||||||
})
|
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,17 +37,12 @@ export const doLocalLogin = async (username: string, password: string): Promise<
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
|
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
|
||||||
await new PostApiRequestBuilder<void, RegisterDto>('auth/local')
|
await new PostApiRequestBuilder<void, RegisterDto>('auth/local', 'auth')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
password
|
password
|
||||||
})
|
})
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: RegisterError.PASSWORD_TOO_WEAK,
|
|
||||||
403: RegisterError.REGISTRATION_DISABLED,
|
|
||||||
409: RegisterError.USERNAME_EXISTING
|
|
||||||
})
|
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,14 +54,10 @@ export const doLocalRegister = async (username: string, displayName: string, pas
|
||||||
* @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend.
|
* @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend.
|
||||||
*/
|
*/
|
||||||
export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise<void> => {
|
export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise<void> => {
|
||||||
await new PutApiRequestBuilder<void, ChangePasswordDto>('auth/local')
|
await new PutApiRequestBuilder<void, ChangePasswordDto>('auth/local', 'auth')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword
|
newPassword
|
||||||
})
|
})
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: AuthError.LOGIN_DISABLED,
|
|
||||||
401: AuthError.INVALID_CREDENTIALS
|
|
||||||
})
|
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,6 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
export enum AuthError {
|
|
||||||
INVALID_CREDENTIALS = 'invalidCredentials',
|
|
||||||
LOGIN_DISABLED = 'loginDisabled',
|
|
||||||
OPENID_ERROR = 'openIdError',
|
|
||||||
OTHER = 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RegisterError {
|
|
||||||
USERNAME_EXISTING = 'usernameExisting',
|
|
||||||
PASSWORD_TOO_WEAK = 'passwordTooWeak',
|
|
||||||
REGISTRATION_DISABLED = 'registrationDisabled',
|
|
||||||
OTHER = 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginDto {
|
export interface LoginDto {
|
||||||
username: string
|
username: string
|
||||||
|
|
10
frontend/src/api/common/api-error-response.ts
Normal file
10
frontend/src/api/common/api-error-response.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
message: string
|
||||||
|
error: string
|
||||||
|
}
|
16
frontend/src/api/common/api-error.ts
Normal file
16
frontend/src/api/common/api-error.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
statusText: string,
|
||||||
|
i18nNamespace: string,
|
||||||
|
public readonly apiErrorName: string | undefined
|
||||||
|
) {
|
||||||
|
super(`api.error.${i18nNamespace}.${statusText}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { ApiError } from '../api-error'
|
||||||
|
import type { ApiErrorResponse } from '../api-error-response'
|
||||||
import { ApiResponse } from '../api-response'
|
import { ApiResponse } from '../api-response'
|
||||||
import { defaultConfig, defaultHeaders } from '../default-config'
|
import { defaultConfig, defaultHeaders } from '../default-config'
|
||||||
import deepmerge from 'deepmerge'
|
import deepmerge from 'deepmerge'
|
||||||
|
@ -14,10 +16,8 @@ import deepmerge from 'deepmerge'
|
||||||
*/
|
*/
|
||||||
export abstract class ApiRequestBuilder<ResponseType> {
|
export abstract class ApiRequestBuilder<ResponseType> {
|
||||||
private readonly targetUrl: string
|
private readonly targetUrl: string
|
||||||
private overrideExpectedResponseStatus: number | undefined
|
|
||||||
private customRequestOptions = defaultConfig
|
private customRequestOptions = defaultConfig
|
||||||
private customRequestHeaders = new Headers(defaultHeaders)
|
private customRequestHeaders = new Headers(defaultHeaders)
|
||||||
private customStatusCodeErrorMapping: Record<number, string> | undefined
|
|
||||||
protected requestBody: BodyInit | undefined
|
protected requestBody: BodyInit | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,14 +25,11 @@ export abstract class ApiRequestBuilder<ResponseType> {
|
||||||
*
|
*
|
||||||
* @param endpoint The target endpoint without a leading slash.
|
* @param endpoint The target endpoint without a leading slash.
|
||||||
*/
|
*/
|
||||||
constructor(endpoint: string) {
|
constructor(endpoint: string, private apiI18nKey: string) {
|
||||||
this.targetUrl = `api/private/${endpoint}`
|
this.targetUrl = `api/private/${endpoint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async sendRequestAndVerifyResponse(
|
protected async sendRequestAndVerifyResponse(httpMethod: RequestInit['method']): Promise<ApiResponse<ResponseType>> {
|
||||||
httpMethod: RequestInit['method'],
|
|
||||||
defaultExpectedStatus: number
|
|
||||||
): Promise<ApiResponse<ResponseType>> {
|
|
||||||
const response = await fetch(this.targetUrl, {
|
const response = await fetch(this.targetUrl, {
|
||||||
...this.customRequestOptions,
|
...this.customRequestOptions,
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
|
@ -40,20 +37,19 @@ export abstract class ApiRequestBuilder<ResponseType> {
|
||||||
body: this.requestBody
|
body: this.requestBody
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.customStatusCodeErrorMapping && this.customStatusCodeErrorMapping[response.status]) {
|
if (response.status >= 400) {
|
||||||
throw new Error(this.customStatusCodeErrorMapping[response.status])
|
const apiErrorResponse = await this.readApiErrorResponseFromBody(response)
|
||||||
}
|
const statusText = response.status === 400 ? apiErrorResponse?.error ?? 'unknown' : response.statusText
|
||||||
|
throw new ApiError(response.status, statusText, this.apiI18nKey, apiErrorResponse?.error)
|
||||||
const expectedStatus = this.overrideExpectedResponseStatus
|
|
||||||
? this.overrideExpectedResponseStatus
|
|
||||||
: defaultExpectedStatus
|
|
||||||
if (response.status !== expectedStatus) {
|
|
||||||
throw new Error(`Expected response status code ${expectedStatus} but received ${response.status}.`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ApiResponse(response)
|
return new ApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readApiErrorResponseFromBody(response: Response): Promise<ApiErrorResponse | undefined> {
|
||||||
|
return response.json().catch(() => undefined) as Promise<ApiErrorResponse | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds an HTTP header to the API request. Previous headers with the same name will get overridden on subsequent calls
|
* Adds an HTTP header to the API request. Previous headers with the same name will get overridden on subsequent calls
|
||||||
* with the same name.
|
* with the same name.
|
||||||
|
@ -78,30 +74,6 @@ export abstract class ApiRequestBuilder<ResponseType> {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a mapping from response status codes to error messages. An error with the specified message will be thrown
|
|
||||||
* when the status code of the response matches one of the defined ones.
|
|
||||||
*
|
|
||||||
* @param mapping The mapping from response status codes to error messages.
|
|
||||||
* @return The API request instance itself for chaining.
|
|
||||||
*/
|
|
||||||
withStatusCodeErrorMapping(mapping: Record<number, string>): this {
|
|
||||||
this.customStatusCodeErrorMapping = mapping
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the expected status code of the response. Can be used to override the default expected status code.
|
|
||||||
* An error will be thrown when the status code of the response does not match the expected one.
|
|
||||||
*
|
|
||||||
* @param expectedCode The expected status code of the response.
|
|
||||||
* @return The API request instance itself for chaining.
|
|
||||||
*/
|
|
||||||
withExpectedStatusCode(expectedCode: number): this {
|
|
||||||
this.overrideExpectedResponseStatus = expectedCode
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the prepared API call as a GET request. A default status code of 200 is expected.
|
* Send the prepared API call as a GET request. A default status code of 200 is expected.
|
||||||
*
|
*
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { ApiError } from '../api-error'
|
||||||
|
import type { ApiErrorResponse } from '../api-error-response'
|
||||||
import { DeleteApiRequestBuilder } from './delete-api-request-builder'
|
import { DeleteApiRequestBuilder } from './delete-api-request-builder'
|
||||||
import { expectFetch } from './test-utils/expect-fetch'
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
|
||||||
|
@ -19,7 +21,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
describe('sendRequest without body', () => {
|
describe('sendRequest without body', () => {
|
||||||
it('without headers', async () => {
|
it('without headers', async () => {
|
||||||
expectFetch('api/private/test', 204, { method: 'DELETE' })
|
expectFetch('api/private/test', 204, { method: 'DELETE' })
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test').sendRequest()
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with single header', async () => {
|
it('with single header', async () => {
|
||||||
|
@ -29,7 +31,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test').withHeader('test', 'true').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with overriding single header', async () => {
|
it('with overriding single header', async () => {
|
||||||
|
@ -39,7 +41,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test', 'false')
|
.withHeader('test', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -53,7 +55,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test2', 'false')
|
.withHeader('test2', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -69,7 +71,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
headers: expectedHeaders,
|
headers: expectedHeaders,
|
||||||
body: '{"test":true,"foo":"bar"}'
|
body: '{"test":true,"foo":"bar"}'
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder('test')
|
await new DeleteApiRequestBuilder('test', 'test')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
test: true,
|
test: true,
|
||||||
foo: 'bar'
|
foo: 'bar'
|
||||||
|
@ -82,12 +84,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: 'HedgeDoc'
|
body: 'HedgeDoc'
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
await new DeleteApiRequestBuilder('test', 'test').withBody('HedgeDoc').sendRequest()
|
||||||
})
|
|
||||||
|
|
||||||
it('sendRequest with expected status code', async () => {
|
|
||||||
expectFetch('api/private/test', 200, { method: 'DELETE' })
|
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom options', () => {
|
describe('sendRequest with custom options', () => {
|
||||||
|
@ -96,7 +93,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -108,7 +105,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -124,7 +121,7 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
})
|
})
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
await new DeleteApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
|
@ -133,37 +130,29 @@ describe('DeleteApiRequestBuilder', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom error map', () => {
|
describe('failing sendRequest', () => {
|
||||||
it('for valid status code', async () => {
|
it('with bad request without api error name', async () => {
|
||||||
expectFetch('api/private/test', 204, { method: 'DELETE' })
|
|
||||||
await new DeleteApiRequestBuilder<string, undefined>('test')
|
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('for invalid status code 1', async () => {
|
|
||||||
expectFetch('api/private/test', 400, { method: 'DELETE' })
|
expectFetch('api/private/test', 400, { method: 'DELETE' })
|
||||||
const request = new DeleteApiRequestBuilder<string, undefined>('test')
|
const request = new DeleteApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
.withStatusCodeErrorMapping({
|
await expect(request).rejects.toEqual(new ApiError(400, 'unknown', 'test', 'testExplosion'))
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
await expect(request).rejects.toThrow('noooooo')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('for invalid status code 2', async () => {
|
it('with bad request with api error name', async () => {
|
||||||
expectFetch('api/private/test', 401, { method: 'DELETE' })
|
expectFetch('api/private/test', 400, { method: 'DELETE' }, {
|
||||||
const request = new DeleteApiRequestBuilder<string, undefined>('test')
|
message: 'The API has exploded!',
|
||||||
.withStatusCodeErrorMapping({
|
error: 'testExplosion'
|
||||||
400: 'noooooo',
|
} as ApiErrorResponse)
|
||||||
401: 'not you!'
|
const request = new DeleteApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
})
|
await expect(request).rejects.toEqual(new ApiError(400, 'testExplosion', 'test', 'testExplosion'))
|
||||||
.sendRequest()
|
})
|
||||||
await expect(request).rejects.toThrow('not you!')
|
|
||||||
|
it('with non bad request error', async () => {
|
||||||
|
expectFetch('api/private/test', 401, { method: 'DELETE' }, {
|
||||||
|
message: 'The API has exploded!',
|
||||||
|
error: 'testExplosion'
|
||||||
|
} as ApiErrorResponse)
|
||||||
|
const request = new DeleteApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
|
await expect(request).rejects.toEqual(new ApiError(401, 'forbidden', 'test', 'testExplosion'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,6 +21,6 @@ export class DeleteApiRequestBuilder<ResponseType = void, RequestBodyType = unkn
|
||||||
* @see ApiRequestBuilder#sendRequest
|
* @see ApiRequestBuilder#sendRequest
|
||||||
*/
|
*/
|
||||||
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
return this.sendRequestAndVerifyResponse('DELETE', 204)
|
return this.sendRequestAndVerifyResponse('DELETE')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { ApiError } from '../api-error'
|
||||||
|
import type { ApiErrorResponse } from '../api-error-response'
|
||||||
import { GetApiRequestBuilder } from './get-api-request-builder'
|
import { GetApiRequestBuilder } from './get-api-request-builder'
|
||||||
import { expectFetch } from './test-utils/expect-fetch'
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@ describe('GetApiRequestBuilder', () => {
|
||||||
describe('sendRequest', () => {
|
describe('sendRequest', () => {
|
||||||
it('without headers', async () => {
|
it('without headers', async () => {
|
||||||
expectFetch('api/private/test', 200, { method: 'GET' })
|
expectFetch('api/private/test', 200, { method: 'GET' })
|
||||||
await new GetApiRequestBuilder<string>('test').sendRequest()
|
await new GetApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with single header', async () => {
|
it('with single header', async () => {
|
||||||
|
@ -30,7 +32,7 @@ describe('GetApiRequestBuilder', () => {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test').withHeader('test', 'true').sendRequest()
|
await new GetApiRequestBuilder<string>('test', 'test').withHeader('test', 'true').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with overriding single header', async () => {
|
it('with overriding single header', async () => {
|
||||||
|
@ -40,7 +42,7 @@ describe('GetApiRequestBuilder', () => {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test')
|
await new GetApiRequestBuilder<string>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test', 'false')
|
.withHeader('test', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -54,25 +56,20 @@ describe('GetApiRequestBuilder', () => {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test')
|
await new GetApiRequestBuilder<string>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test2', 'false')
|
.withHeader('test2', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sendRequest with expected status code', async () => {
|
|
||||||
expectFetch('api/private/test', 200, { method: 'GET' })
|
|
||||||
await new GetApiRequestBuilder<string>('test').withExpectedStatusCode(200).sendRequest()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sendRequest with custom options', () => {
|
describe('sendRequest with custom options', () => {
|
||||||
it('with one option', async () => {
|
it('with one option', async () => {
|
||||||
expectFetch('api/private/test', 200, {
|
expectFetch('api/private/test', 200, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test')
|
await new GetApiRequestBuilder<string>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -84,7 +81,7 @@ describe('GetApiRequestBuilder', () => {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test')
|
await new GetApiRequestBuilder<string>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -100,7 +97,7 @@ describe('GetApiRequestBuilder', () => {
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
})
|
})
|
||||||
await new GetApiRequestBuilder<string>('test')
|
await new GetApiRequestBuilder<string>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
|
@ -109,37 +106,29 @@ describe('GetApiRequestBuilder', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom error map', () => {
|
describe('failing sendRequest', () => {
|
||||||
it('for valid status code', async () => {
|
it('with bad request without api error name', async () => {
|
||||||
expectFetch('api/private/test', 200, { method: 'GET' })
|
|
||||||
await new GetApiRequestBuilder<string>('test')
|
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('for invalid status code 1', async () => {
|
|
||||||
expectFetch('api/private/test', 400, { method: 'GET' })
|
expectFetch('api/private/test', 400, { method: 'GET' })
|
||||||
const request = new GetApiRequestBuilder<string>('test')
|
const request = new GetApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
.withStatusCodeErrorMapping({
|
await expect(request).rejects.toEqual(new ApiError(400, 'unknown', 'test', 'testExplosion'))
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
await expect(request).rejects.toThrow('noooooo')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('for invalid status code 2', async () => {
|
it('with bad request with api error name', async () => {
|
||||||
expectFetch('api/private/test', 401, { method: 'GET' })
|
expectFetch('api/private/test', 400, { method: 'GET' }, {
|
||||||
const request = new GetApiRequestBuilder<string>('test')
|
message: 'The API has exploded!',
|
||||||
.withStatusCodeErrorMapping({
|
error: 'testExplosion'
|
||||||
400: 'noooooo',
|
} as ApiErrorResponse)
|
||||||
401: 'not you!'
|
const request = new GetApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
})
|
await expect(request).rejects.toEqual(new ApiError(400, 'testExplosion', 'test', 'testExplosion'))
|
||||||
.sendRequest()
|
})
|
||||||
await expect(request).rejects.toThrow('not you!')
|
|
||||||
|
it('with non bad request error', async () => {
|
||||||
|
expectFetch('api/private/test', 401, { method: 'GET' }, {
|
||||||
|
message: 'The API has exploded!',
|
||||||
|
error: 'testExplosion'
|
||||||
|
} as ApiErrorResponse)
|
||||||
|
const request = new GetApiRequestBuilder<string>('test', 'test').sendRequest()
|
||||||
|
await expect(request).rejects.toEqual(new ApiError(401, 'forbidden', 'test', 'testExplosion'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -17,6 +17,6 @@ export class GetApiRequestBuilder<ResponseType> extends ApiRequestBuilder<Respon
|
||||||
* @see ApiRequestBuilder#sendRequest
|
* @see ApiRequestBuilder#sendRequest
|
||||||
*/
|
*/
|
||||||
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
return this.sendRequestAndVerifyResponse('GET', 200)
|
return this.sendRequestAndVerifyResponse('GET')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { ApiError } from '../api-error'
|
||||||
|
import type { ApiErrorResponse } from '../api-error-response'
|
||||||
import { PostApiRequestBuilder } from './post-api-request-builder'
|
import { PostApiRequestBuilder } from './post-api-request-builder'
|
||||||
import { expectFetch } from './test-utils/expect-fetch'
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
describe('sendRequest without body', () => {
|
describe('sendRequest without body', () => {
|
||||||
it('without headers', async () => {
|
it('without headers', async () => {
|
||||||
expectFetch('api/private/test', 201, { method: 'POST' })
|
expectFetch('api/private/test', 201, { method: 'POST' })
|
||||||
await new PostApiRequestBuilder<string, undefined>('test').sendRequest()
|
await new PostApiRequestBuilder<string, undefined>('test', 'test').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with single header', async () => {
|
it('with single header', async () => {
|
||||||
|
@ -30,7 +32,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
await new PostApiRequestBuilder<string, undefined>('test', 'test').withHeader('test', 'true').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with overriding single header', async () => {
|
it('with overriding single header', async () => {
|
||||||
|
@ -40,7 +42,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
await new PostApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test', 'false')
|
.withHeader('test', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -54,7 +56,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
await new PostApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test2', 'false')
|
.withHeader('test2', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -70,7 +72,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
headers: expectedHeaders,
|
headers: expectedHeaders,
|
||||||
body: '{"test":true,"foo":"bar"}'
|
body: '{"test":true,"foo":"bar"}'
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder('test')
|
await new PostApiRequestBuilder('test', 'test')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
test: true,
|
test: true,
|
||||||
foo: 'bar'
|
foo: 'bar'
|
||||||
|
@ -83,12 +85,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'HedgeDoc'
|
body: 'HedgeDoc'
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
await new PostApiRequestBuilder('test', 'test').withBody('HedgeDoc').sendRequest()
|
||||||
})
|
|
||||||
|
|
||||||
it('sendRequest with expected status code', async () => {
|
|
||||||
expectFetch('api/private/test', 200, { method: 'POST' })
|
|
||||||
await new PostApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom options', () => {
|
describe('sendRequest with custom options', () => {
|
||||||
|
@ -97,7 +94,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
await new PostApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -109,7 +106,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
await new PostApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -125,7 +122,7 @@ describe('PostApiRequestBuilder', () => {
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
})
|
})
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
await new PostApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
|
@ -134,37 +131,29 @@ describe('PostApiRequestBuilder', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom error map', () => {
|
describe('failing sendRequest', () => {
|
||||||
it('for valid status code', async () => {
|
it('with bad request without api error name', async () => {
|
||||||
expectFetch('api/private/test', 201, { method: 'POST' })
|
|
||||||
await new PostApiRequestBuilder<string, undefined>('test')
|
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('for invalid status code 1', async () => {
|
|
||||||
expectFetch('api/private/test', 400, { method: 'POST' })
|
expectFetch('api/private/test', 400, { method: 'POST' })
|
||||||
const request = new PostApiRequestBuilder<string, undefined>('test')
|
const request = new PostApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
.withStatusCodeErrorMapping({
|
await expect(request).rejects.toEqual(new ApiError(400, 'unknown', 'test', 'testExplosion'))
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
await expect(request).rejects.toThrow('noooooo')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('for invalid status code 2', async () => {
|
it('with bad request with api error name', async () => {
|
||||||
expectFetch('api/private/test', 401, { method: 'POST' })
|
expectFetch('api/private/test', 400, { method: 'POST' }, {
|
||||||
const request = new PostApiRequestBuilder<string, undefined>('test')
|
message: 'The API has exploded!',
|
||||||
.withStatusCodeErrorMapping({
|
error: 'testExplosion'
|
||||||
400: 'noooooo',
|
} as ApiErrorResponse)
|
||||||
401: 'not you!'
|
const request = new PostApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
})
|
await expect(request).rejects.toEqual(new ApiError(400, 'testExplosion', 'test', 'testExplosion'))
|
||||||
.sendRequest()
|
})
|
||||||
await expect(request).rejects.toThrow('not you!')
|
|
||||||
|
it('with non bad request error', async () => {
|
||||||
|
expectFetch('api/private/test', 401, { method: 'POST' }, {
|
||||||
|
message: 'The API has exploded!',
|
||||||
|
error: 'testExplosion'
|
||||||
|
} as ApiErrorResponse)
|
||||||
|
const request = new PostApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
|
await expect(request).rejects.toEqual(new ApiError(401, 'forbidden', 'test', 'testExplosion'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,6 +21,6 @@ export class PostApiRequestBuilder<ResponseType, RequestBodyType> extends ApiReq
|
||||||
* @see ApiRequestBuilder#sendRequest
|
* @see ApiRequestBuilder#sendRequest
|
||||||
*/
|
*/
|
||||||
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
return this.sendRequestAndVerifyResponse('POST', 201)
|
return this.sendRequestAndVerifyResponse('POST')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { ApiError } from '../api-error'
|
||||||
|
import type { ApiErrorResponse } from '../api-error-response'
|
||||||
import { PutApiRequestBuilder } from './put-api-request-builder'
|
import { PutApiRequestBuilder } from './put-api-request-builder'
|
||||||
import { expectFetch } from './test-utils/expect-fetch'
|
import { expectFetch } from './test-utils/expect-fetch'
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
describe('sendRequest without body', () => {
|
describe('sendRequest without body', () => {
|
||||||
it('without headers', async () => {
|
it('without headers', async () => {
|
||||||
expectFetch('api/private/test', 200, { method: 'PUT' })
|
expectFetch('api/private/test', 200, { method: 'PUT' })
|
||||||
await new PutApiRequestBuilder<string, undefined>('test').sendRequest()
|
await new PutApiRequestBuilder<string, undefined>('test', 'test').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with single header', async () => {
|
it('with single header', async () => {
|
||||||
|
@ -30,7 +32,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
|
await new PutApiRequestBuilder<string, undefined>('test', 'test').withHeader('test', 'true').sendRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('with overriding single header', async () => {
|
it('with overriding single header', async () => {
|
||||||
|
@ -40,7 +42,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
await new PutApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test', 'false')
|
.withHeader('test', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -54,7 +56,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: expectedHeaders
|
headers: expectedHeaders
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
await new PutApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withHeader('test', 'true')
|
.withHeader('test', 'true')
|
||||||
.withHeader('test2', 'false')
|
.withHeader('test2', 'false')
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -70,7 +72,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
headers: expectedHeaders,
|
headers: expectedHeaders,
|
||||||
body: '{"test":true,"foo":"bar"}'
|
body: '{"test":true,"foo":"bar"}'
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder('test')
|
await new PutApiRequestBuilder('test', 'test')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
test: true,
|
test: true,
|
||||||
foo: 'bar'
|
foo: 'bar'
|
||||||
|
@ -83,12 +85,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: 'HedgeDoc'
|
body: 'HedgeDoc'
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
|
await new PutApiRequestBuilder('test', 'test').withBody('HedgeDoc').sendRequest()
|
||||||
})
|
|
||||||
|
|
||||||
it('sendRequest with expected status code', async () => {
|
|
||||||
expectFetch('api/private/test', 200, { method: 'PUT' })
|
|
||||||
await new PutApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom options', () => {
|
describe('sendRequest with custom options', () => {
|
||||||
|
@ -97,7 +94,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
await new PutApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -109,7 +106,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
await new PutApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache'
|
cache: 'force-cache'
|
||||||
})
|
})
|
||||||
|
@ -125,7 +122,7 @@ describe('PutApiRequestBuilder', () => {
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
})
|
})
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
await new PutApiRequestBuilder<string, undefined>('test', 'test')
|
||||||
.withCustomOptions({
|
.withCustomOptions({
|
||||||
cache: 'force-cache',
|
cache: 'force-cache',
|
||||||
integrity: 'test'
|
integrity: 'test'
|
||||||
|
@ -134,37 +131,29 @@ describe('PutApiRequestBuilder', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sendRequest with custom error map', () => {
|
describe('failing sendRequest', () => {
|
||||||
it('for valid status code', async () => {
|
it('with bad request without api error name', async () => {
|
||||||
expectFetch('api/private/test', 200, { method: 'PUT' })
|
|
||||||
await new PutApiRequestBuilder<string, undefined>('test')
|
|
||||||
.withStatusCodeErrorMapping({
|
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('for invalid status code 1', async () => {
|
|
||||||
expectFetch('api/private/test', 400, { method: 'PUT' })
|
expectFetch('api/private/test', 400, { method: 'PUT' })
|
||||||
const request = new PutApiRequestBuilder<string, undefined>('test')
|
const request = new PutApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
.withStatusCodeErrorMapping({
|
await expect(request).rejects.toEqual(new ApiError(400, 'unknown', 'test', 'testExplosion'))
|
||||||
400: 'noooooo',
|
|
||||||
401: 'not you!'
|
|
||||||
})
|
|
||||||
.sendRequest()
|
|
||||||
await expect(request).rejects.toThrow('noooooo')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('for invalid status code 2', async () => {
|
it('with bad request with api error name', async () => {
|
||||||
expectFetch('api/private/test', 401, { method: 'PUT' })
|
expectFetch('api/private/test', 400, { method: 'PUT' }, {
|
||||||
const request = new PutApiRequestBuilder<string, undefined>('test')
|
message: 'The API has exploded!',
|
||||||
.withStatusCodeErrorMapping({
|
error: 'testExplosion'
|
||||||
400: 'noooooo',
|
} as ApiErrorResponse)
|
||||||
401: 'not you!'
|
const request = new PutApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
})
|
await expect(request).rejects.toEqual(new ApiError(400, 'testExplosion', 'test', 'testExplosion'))
|
||||||
.sendRequest()
|
})
|
||||||
await expect(request).rejects.toThrow('not you!')
|
|
||||||
|
it('with non bad request error', async () => {
|
||||||
|
expectFetch('api/private/test', 401, { method: 'PUT' }, {
|
||||||
|
message: 'The API has exploded!',
|
||||||
|
error: 'testExplosion'
|
||||||
|
} as ApiErrorResponse)
|
||||||
|
const request = new PutApiRequestBuilder<string, string>('test', 'test').sendRequest()
|
||||||
|
await expect(request).rejects.toEqual(new ApiError(401, 'forbidden', 'test', 'testExplosion'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,6 +21,6 @@ export class PutApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequ
|
||||||
* @see ApiRequestBuilder#sendRequest
|
* @see ApiRequestBuilder#sendRequest
|
||||||
*/
|
*/
|
||||||
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
sendRequest(): Promise<ApiResponse<ResponseType>> {
|
||||||
return this.sendRequestAndVerifyResponse('PUT', 200)
|
return this.sendRequestAndVerifyResponse('PUT')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,12 @@ import { Mock } from 'ts-mockery'
|
||||||
* @param requestStatusCode the status code the mocked request should return
|
* @param requestStatusCode the status code the mocked request should return
|
||||||
* @param expectedOptions additional options
|
* @param expectedOptions additional options
|
||||||
*/
|
*/
|
||||||
export const expectFetch = (expectedUrl: string, requestStatusCode: number, expectedOptions: RequestInit): void => {
|
export const expectFetch = (
|
||||||
|
expectedUrl: string,
|
||||||
|
requestStatusCode: number,
|
||||||
|
expectedOptions: RequestInit,
|
||||||
|
responseBody?: unknown
|
||||||
|
): void => {
|
||||||
global.fetch = jest.fn((fetchUrl: RequestInfo | URL, fetchOptions?: RequestInit): Promise<Response> => {
|
global.fetch = jest.fn((fetchUrl: RequestInfo | URL, fetchOptions?: RequestInit): Promise<Response> => {
|
||||||
expect(fetchUrl).toEqual(expectedUrl)
|
expect(fetchUrl).toEqual(expectedUrl)
|
||||||
expect(fetchOptions).toStrictEqual({
|
expect(fetchOptions).toStrictEqual({
|
||||||
|
@ -25,8 +30,20 @@ export const expectFetch = (expectedUrl: string, requestStatusCode: number, expe
|
||||||
})
|
})
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
Mock.of<Response>({
|
Mock.of<Response>({
|
||||||
status: requestStatusCode
|
status: requestStatusCode,
|
||||||
|
statusText: mapCodeToText(requestStatusCode),
|
||||||
|
json: jest.fn(() => (responseBody ? Promise.resolve(responseBody) : Promise.reject()))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const mapCodeToText = (code: number): string => {
|
||||||
|
switch (code) {
|
||||||
|
case 400:
|
||||||
|
return 'bad_request'
|
||||||
|
case 401:
|
||||||
|
return 'forbidden'
|
||||||
|
default:
|
||||||
|
return 'unknown_code'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,17 +13,6 @@ describe('ApiResponse', () => {
|
||||||
expect(responseObj.getResponse()).toEqual(mockResponse)
|
expect(responseObj.getResponse()).toEqual(mockResponse)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('asBlob', async () => {
|
|
||||||
const mockBlob = Mock.of<Blob>()
|
|
||||||
const mockResponse = Mock.of<Response>({
|
|
||||||
blob(): Promise<Blob> {
|
|
||||||
return Promise.resolve(mockBlob)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const responseObj = new ApiResponse(mockResponse)
|
|
||||||
await expect(responseObj.asBlob()).resolves.toEqual(mockBlob)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('asParsedJsonObject with', () => {
|
describe('asParsedJsonObject with', () => {
|
||||||
it('invalid header', async () => {
|
it('invalid header', async () => {
|
||||||
const mockHeaders = new Headers()
|
const mockHeaders = new Headers()
|
||||||
|
|
|
@ -28,6 +28,14 @@ export class ApiResponse<ResponseType> {
|
||||||
return this.response
|
return this.response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isSuccessfulResponse(response: Response): boolean {
|
||||||
|
return response.status >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
isSuccessful(): boolean {
|
||||||
|
return ApiResponse.isSuccessfulResponse(this.response)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded.
|
* Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded.
|
||||||
*
|
*
|
||||||
|
@ -42,13 +50,4 @@ export class ApiResponse<ResponseType> {
|
||||||
// see https://github.com/hedgedoc/react-client/issues/1219
|
// see https://github.com/hedgedoc/react-client/issues/1219
|
||||||
return (await this.response.json()) as ResponseType
|
return (await this.response.json()) as ResponseType
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the response as a Blob.
|
|
||||||
*
|
|
||||||
* @return The response body as a blob.
|
|
||||||
*/
|
|
||||||
async asBlob(): Promise<Blob> {
|
|
||||||
return await this.response.blob()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
46
frontend/src/api/common/error-to-i18n-key-mapper.ts
Normal file
46
frontend/src/api/common/error-to-i18n-key-mapper.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { ApiError } from './api-error'
|
||||||
|
|
||||||
|
export class ErrorToI18nKeyMapper {
|
||||||
|
private foundI18nKey: string | undefined = undefined
|
||||||
|
|
||||||
|
constructor(private apiError: Error, private i18nNamespace?: string) {}
|
||||||
|
|
||||||
|
public withHttpCode(code: number, i18nKey: string): this {
|
||||||
|
if (this.foundI18nKey === undefined && this.apiError instanceof ApiError && this.apiError.statusCode === code) {
|
||||||
|
this.foundI18nKey = i18nKey
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public withBackendErrorName(errorName: string, i18nKey: string): this {
|
||||||
|
if (
|
||||||
|
this.foundI18nKey === undefined &&
|
||||||
|
this.apiError instanceof ApiError &&
|
||||||
|
this.apiError.apiErrorName === errorName
|
||||||
|
) {
|
||||||
|
this.foundI18nKey = i18nKey
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public withErrorMessage(message: string, i18nKey: string): this {
|
||||||
|
if (this.foundI18nKey === undefined && this.apiError.message === message) {
|
||||||
|
this.foundI18nKey = i18nKey
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public orFallbackI18nKey(fallback?: string): typeof fallback {
|
||||||
|
const foundValue = this.foundI18nKey ?? fallback
|
||||||
|
if (foundValue !== undefined && this.i18nNamespace !== undefined) {
|
||||||
|
return `${this.i18nNamespace}.${foundValue}`
|
||||||
|
} else {
|
||||||
|
return foundValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,6 @@ import type { Config } from './types'
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getConfig = async (): Promise<Config> => {
|
export const getConfig = async (): Promise<Config> => {
|
||||||
const response = await new GetApiRequestBuilder<Config>('config').sendRequest()
|
const response = await new GetApiRequestBuilder<Config>('config', 'config').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,6 @@ import type { GroupInfo } from './types'
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getGroup = async (groupName: string): Promise<GroupInfo> => {
|
export const getGroup = async (groupName: string): Promise<GroupInfo> => {
|
||||||
const response = await new GetApiRequestBuilder<GroupInfo>('groups/' + groupName).sendRequest()
|
const response = await new GetApiRequestBuilder<GroupInfo>('groups/' + groupName, 'group').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import type { ChangePinStatusDto, HistoryEntry, HistoryEntryPutDto } from './typ
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
||||||
const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history').sendRequest()
|
const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history', 'history').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,9 @@ export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
|
||||||
await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history').withJsonBody(entries).sendRequest()
|
await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history', 'history')
|
||||||
|
.withJsonBody(entries)
|
||||||
|
.sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,7 +43,10 @@ export const updateRemoteHistoryEntryPinStatus = async (
|
||||||
noteIdOrAlias: string,
|
noteIdOrAlias: string,
|
||||||
pinStatus: boolean
|
pinStatus: boolean
|
||||||
): Promise<HistoryEntry> => {
|
): Promise<HistoryEntry> => {
|
||||||
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>('me/history/' + noteIdOrAlias)
|
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>(
|
||||||
|
'me/history/' + noteIdOrAlias,
|
||||||
|
'history'
|
||||||
|
)
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
pinStatus
|
pinStatus
|
||||||
})
|
})
|
||||||
|
@ -56,7 +61,7 @@ export const updateRemoteHistoryEntryPinStatus = async (
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
|
export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest()
|
await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias, 'history').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,5 +70,5 @@ export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<v
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteRemoteHistory = async (): Promise<void> => {
|
export const deleteRemoteHistory = async (): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('me/history').sendRequest()
|
await new DeleteApiRequestBuilder('me/history', 'history').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import type { ChangeDisplayNameDto, LoginUserInfo } from './types'
|
||||||
* @throws {Error} when the user is not signed-in.
|
* @throws {Error} when the user is not signed-in.
|
||||||
*/
|
*/
|
||||||
export const getMe = async (): Promise<LoginUserInfo> => {
|
export const getMe = async (): Promise<LoginUserInfo> => {
|
||||||
const response = await new GetApiRequestBuilder<LoginUserInfo>('me').sendRequest()
|
const response = await new GetApiRequestBuilder<LoginUserInfo>('me', 'me').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ export const getMe = async (): Promise<LoginUserInfo> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteUser = async (): Promise<void> => {
|
export const deleteUser = async (): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('me').sendRequest()
|
await new DeleteApiRequestBuilder('me', 'me').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -36,7 +36,7 @@ export const deleteUser = async (): Promise<void> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const updateDisplayName = async (displayName: string): Promise<void> => {
|
export const updateDisplayName = async (displayName: string): Promise<void> => {
|
||||||
await new PostApiRequestBuilder<void, ChangeDisplayNameDto>('me/profile')
|
await new PostApiRequestBuilder<void, ChangeDisplayNameDto>('me/profile', 'me')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
displayName
|
displayName
|
||||||
})
|
})
|
||||||
|
@ -50,6 +50,6 @@ export const updateDisplayName = async (displayName: string): Promise<void> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getMyMedia = async (): Promise<MediaUpload[]> => {
|
export const getMyMedia = async (): Promise<MediaUpload[]> => {
|
||||||
const response = await new GetApiRequestBuilder<MediaUpload[]>('me/media').sendRequest()
|
const response = await new GetApiRequestBuilder<MediaUpload[]>('me/media', 'me').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import type { ImageProxyRequestDto, ImageProxyResponse, MediaUpload } from './ty
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
|
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
|
||||||
const response = await new PostApiRequestBuilder<ImageProxyResponse, ImageProxyRequestDto>('media/proxy')
|
const response = await new PostApiRequestBuilder<ImageProxyResponse, ImageProxyRequestDto>('media/proxy', 'media')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
url: imageUrl
|
url: imageUrl
|
||||||
})
|
})
|
||||||
|
@ -34,7 +34,7 @@ export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyRespons
|
||||||
export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<MediaUpload> => {
|
export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<MediaUpload> => {
|
||||||
const postData = new FormData()
|
const postData = new FormData()
|
||||||
postData.append('file', media)
|
postData.append('file', media)
|
||||||
const response = await new PostApiRequestBuilder<MediaUpload, void>('media')
|
const response = await new PostApiRequestBuilder<MediaUpload, void>('media', 'media')
|
||||||
.withHeader('HedgeDoc-Note', noteIdOrAlias)
|
.withHeader('HedgeDoc-Note', noteIdOrAlias)
|
||||||
.withBody(postData)
|
.withBody(postData)
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -48,5 +48,5 @@ export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<Me
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteUploadedMedia = async (mediaId: string): Promise<void> => {
|
export const deleteUploadedMedia = async (mediaId: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('media/' + mediaId).sendRequest()
|
await new DeleteApiRequestBuilder('media/' + mediaId, 'media').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,9 +17,7 @@ import type { Note, NoteDeletionOptions, NoteMetadata } from './types'
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
||||||
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias)
|
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias, 'note').sendRequest()
|
||||||
.withStatusCodeErrorMapping({ 404: 'api.note.notFound', 403: 'api.note.forbidden' })
|
|
||||||
.sendRequest()
|
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +28,7 @@ export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
||||||
* @return Metadata of the specified note.
|
* @return Metadata of the specified note.
|
||||||
*/
|
*/
|
||||||
export const getNoteMetadata = async (noteIdOrAlias: string): Promise<NoteMetadata> => {
|
export const getNoteMetadata = async (noteIdOrAlias: string): Promise<NoteMetadata> => {
|
||||||
const response = await new GetApiRequestBuilder<NoteMetadata>(`notes/${noteIdOrAlias}/metadata`).sendRequest()
|
const response = await new GetApiRequestBuilder<NoteMetadata>(`notes/${noteIdOrAlias}/metadata`, 'note').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +40,7 @@ export const getNoteMetadata = async (noteIdOrAlias: string): Promise<NoteMetada
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUpload[]> => {
|
export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUpload[]> => {
|
||||||
const response = await new GetApiRequestBuilder<MediaUpload[]>(`notes/${noteIdOrAlias}/media`).sendRequest()
|
const response = await new GetApiRequestBuilder<MediaUpload[]>(`notes/${noteIdOrAlias}/media`, 'note').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +52,7 @@ export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUploa
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const createNote = async (markdown: string): Promise<Note> => {
|
export const createNote = async (markdown: string): Promise<Note> => {
|
||||||
const response = await new PostApiRequestBuilder<Note, void>('notes')
|
const response = await new PostApiRequestBuilder<Note, void>('notes', 'note')
|
||||||
.withHeader('Content-Type', 'text/markdown')
|
.withHeader('Content-Type', 'text/markdown')
|
||||||
.withBody(markdown)
|
.withBody(markdown)
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -70,7 +68,7 @@ export const createNote = async (markdown: string): Promise<Note> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: string): Promise<Note> => {
|
export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: string): Promise<Note> => {
|
||||||
const response = await new PostApiRequestBuilder<Note, void>('notes/' + primaryAlias)
|
const response = await new PostApiRequestBuilder<Note, void>('notes/' + primaryAlias, 'note')
|
||||||
.withHeader('Content-Type', 'text/markdown')
|
.withHeader('Content-Type', 'text/markdown')
|
||||||
.withBody(markdown)
|
.withBody(markdown)
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
@ -84,7 +82,7 @@ export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias:
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => {
|
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder<void, NoteDeletionOptions>('notes/' + noteIdOrAlias)
|
await new DeleteApiRequestBuilder<void, NoteDeletionOptions>('notes/' + noteIdOrAlias, 'note')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
keepMedia: false
|
keepMedia: false
|
||||||
// TODO Ask whether the user wants to keep the media uploaded to the note.
|
// TODO Ask whether the user wants to keep the media uploaded to the note.
|
||||||
|
|
|
@ -18,7 +18,8 @@ import type { OwnerChangeDto, PermissionSetDto } from './types'
|
||||||
*/
|
*/
|
||||||
export const setNoteOwner = async (noteId: string, owner: string): Promise<NotePermissions> => {
|
export const setNoteOwner = async (noteId: string, owner: string): Promise<NotePermissions> => {
|
||||||
const response = await new PutApiRequestBuilder<NotePermissions, OwnerChangeDto>(
|
const response = await new PutApiRequestBuilder<NotePermissions, OwnerChangeDto>(
|
||||||
`notes/${noteId}/metadata/permissions/owner`
|
`notes/${noteId}/metadata/permissions/owner`,
|
||||||
|
'permission'
|
||||||
)
|
)
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
owner
|
owner
|
||||||
|
@ -42,7 +43,8 @@ export const setUserPermission = async (
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
): Promise<NotePermissions> => {
|
): Promise<NotePermissions> => {
|
||||||
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
||||||
`notes/${noteId}/metadata/permissions/users/${username}`
|
`notes/${noteId}/metadata/permissions/users/${username}`,
|
||||||
|
'permission'
|
||||||
)
|
)
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
canEdit
|
canEdit
|
||||||
|
@ -66,7 +68,8 @@ export const setGroupPermission = async (
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
): Promise<NotePermissions> => {
|
): Promise<NotePermissions> => {
|
||||||
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
|
||||||
`notes/${noteId}/metadata/permissions/groups/${groupName}`
|
`notes/${noteId}/metadata/permissions/groups/${groupName}`,
|
||||||
|
'permission'
|
||||||
)
|
)
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
canEdit
|
canEdit
|
||||||
|
@ -85,10 +88,9 @@ export const setGroupPermission = async (
|
||||||
*/
|
*/
|
||||||
export const removeUserPermission = async (noteId: string, username: string): Promise<NotePermissions> => {
|
export const removeUserPermission = async (noteId: string, username: string): Promise<NotePermissions> => {
|
||||||
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
||||||
`notes/${noteId}/metadata/permissions/users/${username}`
|
`notes/${noteId}/metadata/permissions/users/${username}`,
|
||||||
)
|
'permission'
|
||||||
.withExpectedStatusCode(200)
|
).sendRequest()
|
||||||
.sendRequest()
|
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,9 +104,8 @@ export const removeUserPermission = async (noteId: string, username: string): Pr
|
||||||
*/
|
*/
|
||||||
export const removeGroupPermission = async (noteId: string, groupName: string): Promise<NotePermissions> => {
|
export const removeGroupPermission = async (noteId: string, groupName: string): Promise<NotePermissions> => {
|
||||||
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
const response = await new DeleteApiRequestBuilder<NotePermissions>(
|
||||||
`notes/${noteId}/metadata/permissions/groups/${groupName}`
|
`notes/${noteId}/metadata/permissions/groups/${groupName}`,
|
||||||
)
|
'permission'
|
||||||
.withExpectedStatusCode(200)
|
).sendRequest()
|
||||||
.sendRequest()
|
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,8 @@ import type { RevisionDetails, RevisionMetadata } from './types'
|
||||||
*/
|
*/
|
||||||
export const getRevision = async (noteId: string, revisionId: number): Promise<RevisionDetails> => {
|
export const getRevision = async (noteId: string, revisionId: number): Promise<RevisionDetails> => {
|
||||||
const response = await new GetApiRequestBuilder<RevisionDetails>(
|
const response = await new GetApiRequestBuilder<RevisionDetails>(
|
||||||
`notes/${noteId}/revisions/${revisionId}`
|
`notes/${noteId}/revisions/${revisionId}`,
|
||||||
|
'revisions'
|
||||||
).sendRequest()
|
).sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
@ -30,7 +31,10 @@ export const getRevision = async (noteId: string, revisionId: number): Promise<R
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[]> => {
|
export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[]> => {
|
||||||
const response = await new GetApiRequestBuilder<RevisionMetadata[]>(`notes/${noteId}/revisions`).sendRequest()
|
const response = await new GetApiRequestBuilder<RevisionMetadata[]>(
|
||||||
|
`notes/${noteId}/revisions`,
|
||||||
|
'revisions'
|
||||||
|
).sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,5 +45,5 @@ export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteRevisionsForNote = async (noteIdOrAlias: string): Promise<void> => {
|
export const deleteRevisionsForNote = async (noteIdOrAlias: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`).sendRequest()
|
await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`, 'revisions').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import type { AccessToken, AccessTokenWithSecret, CreateAccessTokenDto } from '.
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
||||||
const response = await new GetApiRequestBuilder<AccessToken[]>('tokens').sendRequest()
|
const response = await new GetApiRequestBuilder<AccessToken[]>('tokens', 'tokens').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const postNewAccessToken = async (label: string, validUntil: number): Promise<AccessTokenWithSecret> => {
|
export const postNewAccessToken = async (label: string, validUntil: number): Promise<AccessTokenWithSecret> => {
|
||||||
const response = await new PostApiRequestBuilder<AccessTokenWithSecret, CreateAccessTokenDto>('tokens')
|
const response = await new PostApiRequestBuilder<AccessTokenWithSecret, CreateAccessTokenDto>('tokens', 'tokens')
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
label,
|
label,
|
||||||
validUntil
|
validUntil
|
||||||
|
@ -44,5 +44,5 @@ export const postNewAccessToken = async (label: string, validUntil: number): Pro
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const deleteAccessToken = async (keyId: string): Promise<void> => {
|
export const deleteAccessToken = async (keyId: string): Promise<void> => {
|
||||||
await new DeleteApiRequestBuilder('tokens/' + keyId).sendRequest()
|
await new DeleteApiRequestBuilder('tokens/' + keyId, 'tokens').sendRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,6 @@ import type { UserInfo } from './types'
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getUser = async (username: string): Promise<UserInfo> => {
|
export const getUser = async (username: string): Promise<UserInfo> => {
|
||||||
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
|
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username, 'users').sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,9 +27,6 @@ exports[`Note loading boundary shows an error 1`] = `
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
children:
|
children:
|
||||||
<span>
|
|
||||||
This is a mock for CreateNonExistingNoteHint
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) =
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<CommonErrorPage titleI18nKey={`${error.message}.title`} descriptionI18nKey={`${error.message}.description`}>
|
<CommonErrorPage titleI18nKey={`${error.message}.title`} descriptionI18nKey={`${error.message}.description`}>
|
||||||
<ShowIf condition={error.message === 'api.note.notFound'}>
|
<ShowIf condition={error.message === 'api.error.note.not_found'}>
|
||||||
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
|
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
|
||||||
</ShowIf>
|
</ShowIf>
|
||||||
</CommonErrorPage>
|
</CommonErrorPage>
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
import { AuthError as AuthErrorType } from '../../../../api/auth/types'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { Alert } from 'react-bootstrap'
|
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export interface AuthErrorProps {
|
|
||||||
error?: AuthErrorType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders an error message for auth fields when an error is present.
|
|
||||||
*
|
|
||||||
* @param error The error to render. Can be {@link undefined} when no error should be rendered.
|
|
||||||
*/
|
|
||||||
export const AuthError: React.FC<AuthErrorProps> = ({ error }) => {
|
|
||||||
useTranslation()
|
|
||||||
|
|
||||||
const errorMessageI18nKey = useMemo(() => {
|
|
||||||
switch (error) {
|
|
||||||
case AuthErrorType.INVALID_CREDENTIALS:
|
|
||||||
return 'login.auth.error.usernamePassword'
|
|
||||||
case AuthErrorType.LOGIN_DISABLED:
|
|
||||||
return 'login.auth.error.loginDisabled'
|
|
||||||
case AuthErrorType.OPENID_ERROR:
|
|
||||||
return 'login.auth.error.openIdLogin'
|
|
||||||
default:
|
|
||||||
return 'login.auth.error.other'
|
|
||||||
}
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert className='small' show={!!error} variant='danger'>
|
|
||||||
<Trans i18nKey={errorMessageI18nKey} />
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -4,15 +4,13 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { doLdapLogin } from '../../../api/auth/ldap'
|
import { doLdapLogin } from '../../../api/auth/ldap'
|
||||||
import { AuthError as AuthErrorType } from '../../../api/auth/types'
|
|
||||||
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||||
import { AuthError } from './auth-error/auth-error'
|
|
||||||
import { PasswordField } from './fields/password-field'
|
import { PasswordField } from './fields/password-field'
|
||||||
import { UsernameField } from './fields/username-field'
|
import { UsernameField } from './fields/username-field'
|
||||||
import { fetchAndSetUser } from './utils'
|
import { fetchAndSetUser } from './utils'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { Button, Card, Form } from 'react-bootstrap'
|
import { Alert, Button, Card, Form } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface ViaLdapProps {
|
export interface ViaLdapProps {
|
||||||
|
@ -28,19 +26,13 @@ export const ViaLdap: React.FC<ViaLdapProps> = ({ providerName, identifier }) =>
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<AuthErrorType>()
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
const onLoginSubmit = useCallback(
|
const onLoginSubmit = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
doLdapLogin(identifier, username, password)
|
doLdapLogin(identifier, username, password)
|
||||||
.then(() => fetchAndSetUser())
|
.then(() => fetchAndSetUser())
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => setError(error.message))
|
||||||
setError(
|
|
||||||
Object.values(AuthErrorType).includes(error.message as AuthErrorType)
|
|
||||||
? (error.message as AuthErrorType)
|
|
||||||
: AuthErrorType.OTHER
|
|
||||||
)
|
|
||||||
})
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
},
|
},
|
||||||
[username, password, identifier]
|
[username, password, identifier]
|
||||||
|
@ -58,7 +50,9 @@ export const ViaLdap: React.FC<ViaLdapProps> = ({ providerName, identifier }) =>
|
||||||
<Form onSubmit={onLoginSubmit}>
|
<Form onSubmit={onLoginSubmit}>
|
||||||
<UsernameField onChange={onUsernameChange} invalid={!!error} />
|
<UsernameField onChange={onUsernameChange} invalid={!!error} />
|
||||||
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
||||||
<AuthError error={error} />
|
<Alert className='small' show={!!error} variant='danger'>
|
||||||
|
<Trans i18nKey={error} />
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Button type='submit' variant='primary'>
|
<Button type='submit' variant='primary'>
|
||||||
<Trans i18nKey='login.signIn' />
|
<Trans i18nKey='login.signIn' />
|
||||||
|
|
|
@ -4,18 +4,16 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { doLocalLogin } from '../../../api/auth/local'
|
import { doLocalLogin } from '../../../api/auth/local'
|
||||||
import { AuthError as AuthErrorType } from '../../../api/auth/types'
|
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||||
import { ShowIf } from '../../common/show-if/show-if'
|
import { ShowIf } from '../../common/show-if/show-if'
|
||||||
import { AuthError } from './auth-error/auth-error'
|
|
||||||
import { PasswordField } from './fields/password-field'
|
import { PasswordField } from './fields/password-field'
|
||||||
import { UsernameField } from './fields/username-field'
|
import { UsernameField } from './fields/username-field'
|
||||||
import { fetchAndSetUser } from './utils'
|
import { fetchAndSetUser } from './utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { Button, Card, Form } from 'react-bootstrap'
|
import { Alert, Button, Card, Form } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,20 +23,14 @@ export const ViaLocal: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<AuthErrorType>()
|
const [error, setError] = useState<string>()
|
||||||
const allowRegister = useApplicationState((state) => state.config.allowRegister)
|
const allowRegister = useApplicationState((state) => state.config.allowRegister)
|
||||||
|
|
||||||
const onLoginSubmit = useCallback(
|
const onLoginSubmit = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
doLocalLogin(username, password)
|
doLocalLogin(username, password)
|
||||||
.then(() => fetchAndSetUser())
|
.then(() => fetchAndSetUser())
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => setError(error.message))
|
||||||
setError(
|
|
||||||
Object.values(AuthErrorType).includes(error.message as AuthErrorType)
|
|
||||||
? (error.message as AuthErrorType)
|
|
||||||
: AuthErrorType.OTHER
|
|
||||||
)
|
|
||||||
})
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
},
|
},
|
||||||
[username, password]
|
[username, password]
|
||||||
|
@ -56,7 +48,9 @@ export const ViaLocal: React.FC = () => {
|
||||||
<Form onSubmit={onLoginSubmit}>
|
<Form onSubmit={onLoginSubmit}>
|
||||||
<UsernameField onChange={onUsernameChange} invalid={!!error} />
|
<UsernameField onChange={onUsernameChange} invalid={!!error} />
|
||||||
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
||||||
<AuthError error={error} />
|
<Alert className='small' show={!!error} variant='danger'>
|
||||||
|
<Trans i18nKey={error} />
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className='flex flex-row' dir='auto'>
|
<div className='flex flex-row' dir='auto'>
|
||||||
<Button type='submit' variant='primary' className='mx-2'>
|
<Button type='submit' variant='primary' className='mx-2'>
|
||||||
|
|
|
@ -4,22 +4,22 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { doLocalPasswordChange } from '../../../api/auth/local'
|
import { doLocalPasswordChange } from '../../../api/auth/local'
|
||||||
|
import { ErrorToI18nKeyMapper } from '../../../api/common/error-to-i18n-key-mapper'
|
||||||
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||||
import { CurrentPasswordField } from '../../common/fields/current-password-field'
|
import { CurrentPasswordField } from '../../common/fields/current-password-field'
|
||||||
import { NewPasswordField } from '../../common/fields/new-password-field'
|
import { NewPasswordField } from '../../common/fields/new-password-field'
|
||||||
import { PasswordAgainField } from '../../common/fields/password-again-field'
|
import { PasswordAgainField } from '../../common/fields/password-again-field'
|
||||||
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Card, Form } from 'react-bootstrap'
|
import { Alert, Button, Card, Form } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import { useAsyncFn } from 'react-use'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile page section for changing the password when using internal login.
|
* Profile page section for changing the password when using internal login.
|
||||||
*/
|
*/
|
||||||
export const ProfileChangePassword: React.FC = () => {
|
export const ProfileChangePassword: React.FC = () => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
|
|
||||||
const [oldPassword, setOldPassword] = useState('')
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [newPasswordAgain, setNewPasswordAgain] = useState('')
|
const [newPasswordAgain, setNewPasswordAgain] = useState('')
|
||||||
|
@ -30,36 +30,44 @@ export const ProfileChangePassword: React.FC = () => {
|
||||||
const onChangeNewPassword = useOnInputChange(setNewPassword)
|
const onChangeNewPassword = useOnInputChange(setNewPassword)
|
||||||
const onChangeNewPasswordAgain = useOnInputChange(setNewPasswordAgain)
|
const onChangeNewPasswordAgain = useOnInputChange(setNewPasswordAgain)
|
||||||
|
|
||||||
|
const [{ error, loading, value: changeSucceeded }, doRequest] = useAsyncFn(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await doLocalPasswordChange(oldPassword, newPassword)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const foundI18nKey = new ErrorToI18nKeyMapper(error as Error, 'login.auth.error')
|
||||||
|
.withHttpCode(401, 'invalidCredentials')
|
||||||
|
.withBackendErrorName('loginDisabled', 'loginDisabled')
|
||||||
|
.withBackendErrorName('passwordTooWeak', 'passwordTooWeak')
|
||||||
|
.orFallbackI18nKey('other')
|
||||||
|
return Promise.reject(foundI18nKey)
|
||||||
|
} finally {
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.reset()
|
||||||
|
}
|
||||||
|
setOldPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setNewPasswordAgain('')
|
||||||
|
}
|
||||||
|
}, [oldPassword, newPassword])
|
||||||
|
|
||||||
const onSubmitPasswordChange = useCallback(
|
const onSubmitPasswordChange = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
doLocalPasswordChange(oldPassword, newPassword)
|
void doRequest()
|
||||||
.then(() =>
|
|
||||||
dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', {
|
|
||||||
icon: 'check'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch(showErrorNotification('profile.changePassword.failed'))
|
|
||||||
.finally(() => {
|
|
||||||
if (formRef.current) {
|
|
||||||
formRef.current.reset()
|
|
||||||
}
|
|
||||||
setOldPassword('')
|
|
||||||
setNewPassword('')
|
|
||||||
setNewPasswordAgain('')
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[oldPassword, newPassword, showErrorNotification, dispatchUiNotification]
|
[doRequest]
|
||||||
)
|
)
|
||||||
|
|
||||||
const ready = useMemo(() => {
|
const ready = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
!loading &&
|
||||||
oldPassword.trim() !== '' &&
|
oldPassword.trim() !== '' &&
|
||||||
newPassword.trim() !== '' &&
|
newPassword.trim() !== '' &&
|
||||||
newPasswordAgain.trim() !== '' &&
|
newPasswordAgain.trim() !== '' &&
|
||||||
newPassword === newPasswordAgain
|
newPassword === newPasswordAgain
|
||||||
)
|
)
|
||||||
}, [oldPassword, newPassword, newPasswordAgain])
|
}, [loading, oldPassword, newPassword, newPasswordAgain])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='bg-dark mb-4'>
|
<Card className='bg-dark mb-4'>
|
||||||
|
@ -71,6 +79,12 @@ export const ProfileChangePassword: React.FC = () => {
|
||||||
<CurrentPasswordField onChange={onChangeOldPassword} value={oldPassword} />
|
<CurrentPasswordField onChange={onChangeOldPassword} value={oldPassword} />
|
||||||
<NewPasswordField onChange={onChangeNewPassword} value={newPassword} />
|
<NewPasswordField onChange={onChangeNewPassword} value={newPassword} />
|
||||||
<PasswordAgainField password={newPassword} onChange={onChangeNewPasswordAgain} value={newPasswordAgain} />
|
<PasswordAgainField password={newPassword} onChange={onChangeNewPasswordAgain} value={newPasswordAgain} />
|
||||||
|
<Alert className='small' show={!!error && !loading} variant={'danger'}>
|
||||||
|
<Trans i18nKey={error?.message} />
|
||||||
|
</Alert>
|
||||||
|
<Alert className='small' show={changeSucceeded && !loading} variant={'success'}>
|
||||||
|
<Trans i18nKey={'profile.changePassword.successText'} />
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Button type='submit' variant='primary' disabled={!ready}>
|
<Button type='submit' variant='primary' disabled={!ready}>
|
||||||
<Trans i18nKey='common.save' />
|
<Trans i18nKey='common.save' />
|
||||||
|
|
34
frontend/src/components/register-page/register-error.tsx
Normal file
34
frontend/src/components/register-page/register-error.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { ErrorToI18nKeyMapper } from '../../api/common/error-to-i18n-key-mapper'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { Alert } from 'react-bootstrap'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface RegisterErrorProps {
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegisterError: React.FC<RegisterErrorProps> = ({ error }) => {
|
||||||
|
useTranslation()
|
||||||
|
|
||||||
|
const errorI18nKey = useMemo(() => {
|
||||||
|
if (!error) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return new ErrorToI18nKeyMapper(error, 'login.register.error')
|
||||||
|
.withHttpCode(409, 'usernameExisting')
|
||||||
|
.withBackendErrorName('registrationDisabled', 'registrationDisabled')
|
||||||
|
.withBackendErrorName('passwordTooWeak', 'passwordTooWeak')
|
||||||
|
.orFallbackI18nKey('other')
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className='small' show={!!errorI18nKey} variant='danger'>
|
||||||
|
<Trans i18nKey={errorI18nKey} />
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
import { RegisterError as RegisterErrorType } from '../../../api/auth/types'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { Alert } from 'react-bootstrap'
|
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export interface RegisterErrorProps {
|
|
||||||
error?: RegisterErrorType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders an error message for registration fields when an error is present.
|
|
||||||
*
|
|
||||||
* @param error The error to render. Can be {@link undefined} when no error should be rendered.
|
|
||||||
*/
|
|
||||||
export const RegisterError: React.FC<RegisterErrorProps> = ({ error }) => {
|
|
||||||
useTranslation()
|
|
||||||
|
|
||||||
const errorMessageI18nKey = useMemo(() => {
|
|
||||||
switch (error) {
|
|
||||||
case RegisterErrorType.PASSWORD_TOO_WEAK:
|
|
||||||
return 'login.register.error.passwordTooWeak'
|
|
||||||
case RegisterErrorType.REGISTRATION_DISABLED:
|
|
||||||
return 'login.register.error.registrationDisabled'
|
|
||||||
case RegisterErrorType.USERNAME_EXISTING:
|
|
||||||
return 'login.register.error.usernameExisting'
|
|
||||||
default:
|
|
||||||
return 'login.register.error.other'
|
|
||||||
}
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert className='small' show={!!error} variant='danger'>
|
|
||||||
<Trans i18nKey={errorMessageI18nKey} />
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../hooks/common/use-application-state'
|
||||||
import { TranslatedExternalLink } from '../../common/links/translated-external-link'
|
import { TranslatedExternalLink } from '../common/links/translated-external-link'
|
||||||
import { ShowIf } from '../../common/show-if/show-if'
|
import { ShowIf } from '../common/show-if/show-if'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { doLocalRegister } from '../api/auth/local'
|
import { doLocalRegister } from '../api/auth/local'
|
||||||
import { RegisterError as RegisterErrorType } from '../api/auth/types'
|
import type { ApiError } from '../api/common/api-error'
|
||||||
import { DisplayNameField } from '../components/common/fields/display-name-field'
|
import { DisplayNameField } from '../components/common/fields/display-name-field'
|
||||||
import { NewPasswordField } from '../components/common/fields/new-password-field'
|
import { NewPasswordField } from '../components/common/fields/new-password-field'
|
||||||
import { PasswordAgainField } from '../components/common/fields/password-again-field'
|
import { PasswordAgainField } from '../components/common/fields/password-again-field'
|
||||||
|
@ -13,8 +13,8 @@ import { Redirect } from '../components/common/redirect'
|
||||||
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
import { LandingLayout } from '../components/landing-layout/landing-layout'
|
||||||
import { fetchAndSetUser } from '../components/login-page/auth/utils'
|
import { fetchAndSetUser } from '../components/login-page/auth/utils'
|
||||||
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
|
||||||
import { RegisterError } from '../components/register-page/register-error/register-error'
|
import { RegisterError } from '../components/register-page/register-error'
|
||||||
import { RegisterInfos } from '../components/register-page/register-infos/register-infos'
|
import { RegisterInfos } from '../components/register-page/register-infos'
|
||||||
import { useApplicationState } from '../hooks/common/use-application-state'
|
import { useApplicationState } from '../hooks/common/use-application-state'
|
||||||
import { useOnInputChange } from '../hooks/common/use-on-input-change'
|
import { useOnInputChange } from '../hooks/common/use-on-input-change'
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
|
@ -37,7 +37,7 @@ export const RegisterPage: NextPage = () => {
|
||||||
const [displayName, setDisplayName] = useState('')
|
const [displayName, setDisplayName] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [passwordAgain, setPasswordAgain] = useState('')
|
const [passwordAgain, setPasswordAgain] = useState('')
|
||||||
const [error, setError] = useState<RegisterErrorType>()
|
const [error, setError] = useState<ApiError>()
|
||||||
|
|
||||||
const { dispatchUiNotification } = useUiNotifications()
|
const { dispatchUiNotification } = useUiNotifications()
|
||||||
|
|
||||||
|
@ -47,13 +47,7 @@ export const RegisterPage: NextPage = () => {
|
||||||
.then(() => fetchAndSetUser())
|
.then(() => fetchAndSetUser())
|
||||||
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
|
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
|
||||||
.then(() => router.push('/'))
|
.then(() => router.push('/'))
|
||||||
.catch((error: Error) => {
|
.catch((error: ApiError) => setError(error))
|
||||||
setError(
|
|
||||||
Object.values(RegisterErrorType).includes(error.message as RegisterErrorType)
|
|
||||||
? (error.message as RegisterErrorType)
|
|
||||||
: RegisterErrorType.OTHER
|
|
||||||
)
|
|
||||||
})
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
},
|
},
|
||||||
[username, displayName, password, dispatchUiNotification, router]
|
[username, displayName, password, dispatchUiNotification, router]
|
||||||
|
@ -70,7 +64,7 @@ export const RegisterPage: NextPage = () => {
|
||||||
}, [username, password, displayName, passwordAgain])
|
}, [username, password, displayName, passwordAgain])
|
||||||
|
|
||||||
const isWeakPassword = useMemo(() => {
|
const isWeakPassword = useMemo(() => {
|
||||||
return error === RegisterErrorType.PASSWORD_TOO_WEAK
|
return error?.apiErrorName === 'passwordTooWeak'
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const onUsernameChange = useOnInputChange(setUsername)
|
const onUsernameChange = useOnInputChange(setUsername)
|
||||||
|
|
Loading…
Reference in a new issue