Adapt react-client to use the real backend API (#1545)

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Erik Michelson 2022-04-15 23:03:15 +02:00 committed by GitHub
parent 3399ed2023
commit 26f90505ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
227 changed files with 4726 additions and 2310 deletions

View file

@ -3,11 +3,7 @@ Upstream-Name: react-client
Upstream-Contact: The HedgeDoc developers <license@hedgedoc.org> Upstream-Contact: The HedgeDoc developers <license@hedgedoc.org>
Source: https://github.com/hedgedoc/react-client Source: https://github.com/hedgedoc/react-client
Files: public/mock-backend/public/* Files: public/mock-public/*
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
License: CC0-1.0
Files: public/mock-backend/api/*
Copyright: 2021 The HedgeDoc developers (see AUTHORS file) Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
License: CC0-1.0 License: CC0-1.0
@ -19,14 +15,10 @@ Files: locales/*
Copyright: 2021 The HedgeDoc developers (see AUTHORS file) Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
License: CC-BY-SA-4.0 License: CC-BY-SA-4.0
Files: public/mock-backend/public/img/highres.jpg Files: public/mock-public/img/highres.jpg
Copyright: Vincent van Gogh Copyright: Vincent van Gogh
License: CC0-1.0 License: CC0-1.0
Files: public/index.html
Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
License: CC0-1.0
Files: public/robots.txt Files: public/robots.txt
Copyright: 2021 The HedgeDoc developers (see AUTHORS file) Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
License: CC0-1.0 License: CC0-1.0

View file

@ -4,14 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { testNoteId } from '../support/visit-test-editor'
describe('Delete note', () => { describe('Delete note', () => {
beforeEach(() => { beforeEach(() => {
cy.visitTestNote() cy.visitTestNote()
}) })
it('correctly deletes a note', () => { it('correctly deletes a note', () => {
cy.intercept('DELETE', '/mock-backend/api/private/notes/mock_note_id', { cy.intercept('DELETE', `/api/mock-backend/private/notes/${testNoteId}`, {
statusCode: 200 statusCode: 204
}) })
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')

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -16,13 +16,13 @@ describe('File upload', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept( cy.intercept(
{ {
method: 'GET', method: 'POST',
url: '/mock-backend/api/private/media/upload-post' url: '/api/mock-backend/private/media'
}, },
{ {
statusCode: 200, statusCode: 201,
body: { body: {
link: imageUrl url: imageUrl
} }
} }
) )
@ -69,8 +69,8 @@ describe('File upload', () => {
it('fails', () => { it('fails', () => {
cy.intercept( cy.intercept(
{ {
method: 'GET', method: 'POST',
url: '/mock-backend/api/private/media/upload-post' url: '/api/mock-backend/private/media'
}, },
{ {
statusCode: 400 statusCode: 400

View file

@ -24,7 +24,7 @@ describe('History', () => {
describe('is as given when not empty', () => { describe('is as given when not empty', () => {
beforeEach(() => { beforeEach(() => {
cy.clearLocalStorage('history') cy.clearLocalStorage('history')
cy.intercept('GET', '/mock-backend/api/private/me/history', { cy.intercept('GET', '/api/mock-backend/private/me/history', {
body: [ body: [
{ {
identifier: 'cypress', identifier: 'cypress',
@ -51,7 +51,7 @@ describe('History', () => {
describe('is untitled when not empty', () => { describe('is untitled when not empty', () => {
beforeEach(() => { beforeEach(() => {
cy.clearLocalStorage('history') cy.clearLocalStorage('history')
cy.intercept('GET', '/mock-backend/api/private/me/history', { cy.intercept('GET', '/api/mock-backend/private/me/history', {
body: [ body: [
{ {
identifier: 'cypress-no-title', identifier: 'cypress-no-title',
@ -84,7 +84,7 @@ describe('History', () => {
describe('working', () => { describe('working', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('PUT', '/mock-backend/api/private/me/history/features', (req) => { cy.intercept('PUT', '/api/mock-backend/private/me/history/features', (req) => {
req.reply(200, req.body) req.reply(200, req.body)
}) })
}) })
@ -106,7 +106,7 @@ describe('History', () => {
describe('failing', () => { describe('failing', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('PUT', '/mock-backend/api/private/me/history/features', { cy.intercept('PUT', '/api/mock-backend/private/me/history/features', {
statusCode: 401 statusCode: 401
}) })
}) })
@ -128,7 +128,7 @@ describe('History', () => {
describe('Import', () => { describe('Import', () => {
beforeEach(() => { beforeEach(() => {
cy.clearLocalStorage('history') cy.clearLocalStorage('history')
cy.intercept('GET', '/mock-backend/api/private/me/history', { cy.intercept('GET', '/api/mock-backend/private/me/history', {
body: [] body: []
}) })
cy.visitHistory() cy.visitHistory()

View file

@ -7,7 +7,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
describe('Intro page', () => { describe('Intro page', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('/mock-backend/public/intro.md', 'test content') cy.intercept('/mock-public/intro.md', 'test content')
cy.visitHome() cy.visitHome()
}) })
@ -17,7 +17,7 @@ describe('Intro page', () => {
}) })
it("won't show anything if no content was found", () => { it("won't show anything if no content was found", () => {
cy.intercept('/mock-backend/public/intro.md', { cy.intercept('/mock-public/intro.md', {
statusCode: 404 statusCode: 404
}) })
cy.visitHome() cy.visitHome()

View file

@ -11,13 +11,13 @@ const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
describe('Motd', () => { describe('Motd', () => {
const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => { const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => {
cy.intercept('GET', '/mock-backend/public/motd.md', { cy.intercept('GET', '/mock-public/motd.md', {
statusCode: 200, statusCode: 200,
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }, headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED },
body: content body: content
}) })
cy.intercept('HEAD', '/mock-backend/public/motd.md', { cy.intercept('HEAD', '/mock-public/motd.md', {
statusCode: 200, statusCode: 200,
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED } headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }
}) })

View file

@ -8,7 +8,7 @@ describe('profile page', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept( cy.intercept(
{ {
url: '/mock-backend/api/private/tokens', url: '/api/mock-backend/private/tokens',
method: 'GET' method: 'GET'
}, },
{ {
@ -25,7 +25,7 @@ describe('profile page', () => {
) )
cy.intercept( cy.intercept(
{ {
url: '/mock-backend/api/private/tokens', url: '/api/mock-backend/private/tokens',
method: 'POST' method: 'POST'
}, },
{ {
@ -36,16 +36,18 @@ describe('profile page', () => {
createdAt: '2021-11-21T01:11:12+01:00', createdAt: '2021-11-21T01:11:12+01:00',
lastUsed: '2021-11-21T01:11:12+01:00', lastUsed: '2021-11-21T01:11:12+01:00',
validUntil: '2023-11-21' validUntil: '2023-11-21'
} },
statusCode: 201
} }
) )
cy.intercept( cy.intercept(
{ {
url: '/mock-backend/api/private/tokens/cypress', url: '/api/mock-backend/private/tokens/cypress',
method: 'DELETE' method: 'DELETE'
}, },
{ {
body: [] body: [],
statusCode: 204
} }
) )
cy.visit('/profile', { retryOnNetworkFailure: true }) cy.visit('/profile', { retryOnNetworkFailure: true })

View file

@ -4,28 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
const authProvidersDisabled = { import type { AuthProvider } from '../../src/api/config/types'
facebook: false, import { AuthProviderType } from '../../src/api/config/types'
github: false,
twitter: false,
gitlab: false,
dropbox: false,
ldap: false,
google: false,
saml: false,
oauth2: false,
local: false
}
const initLoggedOutTestWithCustomAuthProviders = ( const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: AuthProvider[]) => {
cy: Cypress.cy,
enabledProviders: Partial<typeof authProvidersDisabled>
) => {
cy.loadConfig({ cy.loadConfig({
authProviders: { authProviders: enabledProviders
...authProvidersDisabled,
...enabledProviders
}
}) })
cy.visitHome() cy.visitHome()
cy.logout() cy.logout()
@ -41,55 +25,71 @@ describe('When logged-in, ', () => {
describe('When logged-out ', () => { describe('When logged-out ', () => {
describe('and no auth-provider is enabled, ', () => { describe('and no auth-provider is enabled, ', () => {
it('sign-in button is hidden', () => { it('sign-in button is hidden', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {}) initLoggedOutTestWithCustomAuthProviders(cy, [])
cy.getByCypressId('sign-in-button').should('not.exist') cy.getByCypressId('sign-in-button').should('not.exist')
}) })
}) })
describe('and an interactive auth-provider is enabled, ', () => { describe('and an interactive auth-provider is enabled, ', () => {
it('sign-in button points to login route: internal', () => { it('sign-in button points to login route: internal', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, [
local: true {
}) type: AuthProviderType.LOCAL
}
])
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
}) })
it('sign-in button points to login route: ldap', () => { it('sign-in button points to login route: ldap', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, [
ldap: true {
}) type: AuthProviderType.LDAP,
identifier: 'cy-ldap',
providerName: 'cy LDAP'
}
])
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
}) })
}) })
describe('and only one one-click auth-provider is enabled, ', () => { describe('and only one one-click auth-provider is enabled, ', () => {
it('sign-in button points to auth-provider', () => { it('sign-in button points to auth-provider', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, [
saml: true {
}) type: AuthProviderType.GITHUB
}
])
cy.getByCypressId('sign-in-button') cy.getByCypressId('sign-in-button')
.should('be.visible') .should('be.visible')
// The absolute URL is used because it is defined as API base URL absolute. // The absolute URL is used because it is defined as API base URL absolute.
.should('have.attr', 'href', '/mock-backend/auth/saml') .should('have.attr', 'href', '/auth/github')
}) })
}) })
describe('and multiple one-click auth-providers are enabled, ', () => { describe('and multiple one-click auth-providers are enabled, ', () => {
it('sign-in button points to login route', () => { it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, [
saml: true, {
github: true type: AuthProviderType.GITHUB
}) },
{
type: AuthProviderType.GOOGLE
}
])
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
}) })
}) })
describe('and one-click- as well as interactive auth-providers are enabled, ', () => { describe('and one-click- as well as interactive auth-providers are enabled, ', () => {
it('sign-in button points to login route', () => { it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, [
saml: true, {
local: true type: AuthProviderType.GITHUB
}) },
{
type: AuthProviderType.LOCAL
}
])
cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') cy.getByCypressId('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
}) })
}) })

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { AuthProviderType } from '../../src/api/config/types'
declare namespace Cypress { declare namespace Cypress {
interface Chainable { interface Chainable {
loadConfig(): Chainable<Window> loadConfig(): Chainable<Window>
@ -12,43 +14,70 @@ declare namespace Cypress {
export const branding = { export const branding = {
name: 'DEMO Corp', name: 'DEMO Corp',
logo: '/mock-backend/public/img/demo.png' logo: '/mock-public/img/demo.png'
} }
export const authProviders = { export const authProviders = [
facebook: true, {
github: true, type: AuthProviderType.FACEBOOK
twitter: true, },
gitlab: true, {
dropbox: true, type: AuthProviderType.GITHUB
ldap: true, },
google: true, {
saml: true, type: AuthProviderType.TWITTER
oauth2: true, },
local: true {
} type: AuthProviderType.DROPBOX
},
{
type: AuthProviderType.GOOGLE
},
{
type: AuthProviderType.LOCAL
},
{
type: AuthProviderType.LDAP,
identifier: 'test-ldap',
providerName: 'Test LDAP'
},
{
type: AuthProviderType.OAUTH2,
identifier: 'test-oauth2',
providerName: 'Test OAuth2'
},
{
type: AuthProviderType.SAML,
identifier: 'test-saml',
providerName: 'Test SAML'
},
{
type: AuthProviderType.GITLAB,
identifier: 'test-gitlab',
providerName: 'Test GitLab'
}
]
export const config = { export const config = {
allowAnonymous: true, allowAnonymous: true,
allowRegister: true,
authProviders: authProviders, authProviders: authProviders,
branding: branding, branding: branding,
customAuthNames: { useImageProxy: false,
ldap: 'FooBar',
oauth2: 'Olaf2',
saml: 'aufSAMLn.de'
},
maxDocumentLength: 200,
specialUrls: { specialUrls: {
privacy: 'https://example.com/privacy', privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse', termsOfUse: 'https://example.com/termsOfUse',
imprint: 'https://example.com/imprint' imprint: 'https://example.com/imprint'
}, },
plantumlServer: 'http://mock-plantuml.local',
version: { version: {
version: 'mock', major: 0,
sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', minor: 0,
issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' patch: 0,
preRelease: '',
commit: 'MOCK'
}, },
plantumlServer: 'http://mock-plantuml.local',
maxDocumentLength: 200,
iframeCommunication: { iframeCommunication: {
editorOrigin: 'http://127.0.0.1:3001/', editorOrigin: 'http://127.0.0.1:3001/',
rendererOrigin: 'http://127.0.0.1:3001/' rendererOrigin: 'http://127.0.0.1:3001/'
@ -56,7 +85,7 @@ export const config = {
} }
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => { Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
return cy.intercept('/mock-backend/api/private/config', { return cy.intercept('/api/mock-backend/private/config', {
statusCode: 200, statusCode: 200,
body: { body: {
...config, ...config,
@ -68,11 +97,11 @@ Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) =
beforeEach(() => { beforeEach(() => {
cy.loadConfig() cy.loadConfig()
cy.intercept('GET', '/mock-backend/public/motd.md', { cy.intercept('GET', '/mock-public/motd.md', {
body: '404 Not Found!', body: '404 Not Found!',
statusCode: 404 statusCode: 404
}) })
cy.intercept('HEAD', '/mock-backend/public/motd.md', { cy.intercept('HEAD', '/mock-public/motd.md', {
statusCode: 404 statusCode: 404
}) })
}) })

View file

@ -25,6 +25,6 @@ import './config'
import './fill' import './fill'
import './get-by-id' import './get-by-id'
import './get-iframe-content' import './get-iframe-content'
import './login' import './logout'
import './visit-test-editor' import './visit-test-editor'
import './visit' import './visit'

View file

@ -3,26 +3,30 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export const testNoteId = 'test' export const testNoteId = 'test'
beforeEach(() => { beforeEach(() => {
cy.intercept(`/mock-backend/api/private/notes/${testNoteId}-get`, { cy.intercept(`/api/mock-backend/private/notes/${testNoteId}`, {
content: '', content: '',
metadata: { metadata: {
id: 'mock_note_id', id: testNoteId,
alias: 'mockNote', alias: ['mock-note'],
version: 2, primaryAlias: 'mock-note',
viewCount: 0, title: 'Mock Note',
description: 'Mocked note for testing',
tags: ['test', 'mock', 'cypress'],
updateTime: '2021-04-24T09:27:51.000Z', updateTime: '2021-04-24T09:27:51.000Z',
updateUser: { updateUser: null,
userName: 'test', viewCount: 0,
displayName: 'Testy', version: 2,
photo: '',
email: ''
},
createTime: '2021-04-24T09:27:51.000Z', createTime: '2021-04-24T09:27:51.000Z',
editedBy: [] editedBy: [],
permissions: {
owner: null,
sharedToUsers: [],
sharedToGroups: []
} }
},
editedByAtPosition: []
}) })
}) })

View file

@ -163,7 +163,9 @@
"new": "New password", "new": "New password",
"newAgain": "New password again", "newAgain": "New password again",
"info": "Your new password should contain at least 6 characters.", "info": "Your new password should contain at least 6 characters.",
"failed": "Changing your password failed. Check your old password and try again." "failed": "Changing your password failed. Check your old password and try again.",
"successTitle": "Password changed",
"successText": "Your password has been changed successfully."
}, },
"changeDisplayNameFailed": "There was an error changing your display name.", "changeDisplayNameFailed": "There was an error changing your display name.",
"accountManagement": "Account management", "accountManagement": "Account management",
@ -178,6 +180,7 @@
"label": "Token label", "label": "Token label",
"created": "created {{time}}", "created": "created {{time}}",
"lastUsed": "last used {{time}}", "lastUsed": "last used {{time}}",
"neverUsed": "never used",
"loadingFailed": "Fetching your access tokens has failed. Try reloading this page.", "loadingFailed": "Fetching your access tokens has failed. Try reloading this page.",
"creationFailed": "Creating the access token failed.", "creationFailed": "Creating the access token failed.",
"expiry": "Expiry date" "expiry": "Expiry date"
@ -375,7 +378,7 @@
}, },
"documentInfo": { "documentInfo": {
"title": "Document info", "title": "Document info",
"created": "<0></0> created this note <1></1>", "created": "This note was created <0></0>",
"edited": "<0></0> was the last editor <1></1>", "edited": "<0></0> was the last editor <1></1>",
"usersContributed": "<0></0> users contributed to this document", "usersContributed": "<0></0> users contributed to this document",
"revisions": "<0></0> revisions are saved", "revisions": "<0></0> revisions are saved",
@ -393,6 +396,7 @@
"title": "Revisions", "title": "Revisions",
"revertButton": "Revert", "revertButton": "Revert",
"error": "An error occurred while fetching the revisions of this note.", "error": "An error occurred while fetching the revisions of this note.",
"errorUser": "An error occurred while fetching the user information for this revision.",
"length": "Length", "length": "Length",
"download": "Download selected revision" "download": "Download selected revision"
}, },
@ -410,18 +414,25 @@
"title": "Permissions", "title": "Permissions",
"owner": "Owner", "owner": "Owner",
"sharedWithUsers": "Shared with users", "sharedWithUsers": "Shared with users",
"sharedWithGroups": "Also share with…", "sharedWithGroups": "Shared with groups",
"sharedWithElse": "Shared with else...",
"editUser": "Change {{name}}'s permissions to view and edit", "editUser": "Change {{name}}'s permissions to view and edit",
"viewOnlyUser": "Change {{name}}'s permissions to view only", "viewOnlyUser": "Change {{name}}'s permissions to view only",
"removeUser": "Remove {{name}}'s permissions", "removeUser": "Remove {{name}}'s permissions",
"addUser": "Add user", "addUser": "Add user",
"editGroup": "Change permissions of group \"{{name}}\" to view & edit", "editGroup": "Change permissions of group \"{{name}}\" to view & edit",
"viewOnlyGroup": "Change permissions of group \"{{name}}\" to view only", "viewOnlyGroup": "Change permissions of group \"{{name}}\" to view only",
"removeGroup": "Remove permissions of group \"{{name}}\"",
"denyGroup": "Deny access to group \"{{name}}\"", "denyGroup": "Deny access to group \"{{name}}\"",
"addGroup": "Add group", "addGroup": "Add group",
"allUser": "Everyone", "allUser": "Everyone",
"allLoggedInUser": "All logged-in users", "allLoggedInUser": "All logged-in users",
"error": "An error occurred while fetching the user information of this note." "error": "An error occurred while updating the permissions of this note.",
"ownerChange": {
"error": "There was an error changing the owner of this note.",
"placeholder": "Enter username of new note owner",
"button": "Change the owner of this note"
}
}, },
"shareLink": { "shareLink": {
"title": "Share link", "title": "Share link",
@ -499,9 +510,10 @@
"avatarOf": "avatar of '{{name}}'", "avatarOf": "avatar of '{{name}}'",
"why": "Why?", "why": "Why?",
"loading": "Loading ...", "loading": "Loading ...",
"errorOccurred": "An error occurred",
"errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", "errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
"readForMoreInfo": "Read here for more information" "errorOccurred": "An error occurred",
"readForMoreInfo": "Read here for more information",
"guestUser": "Guest user"
}, },
"copyOverlay": { "copyOverlay": {
"error": "Error while copying!", "error": "Error while copying!",

View file

@ -2,6 +2,6 @@
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved. What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
::: :::
![HedgeDoc Screenshot](/mock-backend/public/screenshot.png) ![HedgeDoc Screenshot](/mock-public/screenshot.png)
[![Deployed using netlify](https://www.netlify.com/img/global/badges/netlify-color-accent.svg)](https://www.netlify.com) [![Deployed using netlify](https://www.netlify.com/img/global/badges/netlify-color-accent.svg)](https://www.netlify.com)

View file

@ -1,14 +1,16 @@
#!/bin/bash #!/bin/bash
# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) #
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
# #
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
#
set -e set -e
echo 'Patch intro.md to include netlify banner.' echo 'Patch intro.md to include netlify banner.'
cp netlify/intro.md public/mock-backend/public/intro.md cp netlify/intro.md public/mock-public/intro.md
echo 'Patch motd.md to include privacy policy.' echo 'Patch motd.md to include privacy policy.'
cp netlify/motd.md public/mock-backend/public/motd.md cp netlify/motd.md public/mock-public/motd.md
echo 'Patch version.json to include git hash' echo 'Patch version.json to include git hash'
jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json
mv src/_version.json src/version.json mv src/_version.json src/version.json

View file

@ -16,6 +16,7 @@
"lint": "eslint --max-warnings=0 --ext .ts,.tsx src", "lint": "eslint --max-warnings=0 --ext .ts,.tsx src",
"lint:fix": "eslint --fix --ext .ts,.tsx src", "lint:fix": "eslint --fix --ext .ts,.tsx src",
"start": "cross-env PORT=3001 next start", "start": "cross-env PORT=3001 next start",
"start:mock": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=true next start",
"start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start", "start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start",
"cy:open": "cypress open", "cy:open": "cypress open",
"cy:run:chrome": "cypress run --browser chrome", "cy:run:chrome": "cypress run --browser chrome",
@ -53,6 +54,7 @@
"copy-webpack-plugin": "10.2.4", "copy-webpack-plugin": "10.2.4",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"d3-graphviz": "4.1.1", "d3-graphviz": "4.1.1",
"deepmerge": "4.2.2",
"diff": "5.0.0", "diff": "5.0.0",
"dompurify": "2.3.6", "dompurify": "2.3.6",
"emoji-picker-element": "1.11.2", "emoji-picker-element": "1.11.2",

View file

@ -1,43 +0,0 @@
{
"allowAnonymous": true,
"authProviders": {
"facebook": true,
"github": true,
"twitter": true,
"gitlab": true,
"dropbox": true,
"ldap": true,
"google": true,
"saml": true,
"oauth2": true,
"local": true
},
"allowRegister": true,
"branding": {
"name": "DEMO Corp",
"logo": "/mock-backend/public/img/demo.png"
},
"customAuthNames": {
"ldap": "FooBar",
"oauth2": "Olaf2",
"saml": "aufSAMLn.de"
},
"maxDocumentLength": 100000,
"useImageProxy": false,
"plantumlServer": "https://www.plantuml.com/plantuml",
"specialUrls": {
"privacy": "https://example.com/privacy",
"termsOfUse": "https://example.com/termsOfUse",
"imprint": "https://example.com/imprint"
},
"version": {
"major": -1,
"minor": -1,
"patch": -1,
"commit": "mock"
},
"iframeCommunication": {
"editorOrigin": "http://localhost:3001/",
"rendererOrigin": "http://127.0.0.1:3001/"
}
}

View file

@ -1,6 +0,0 @@
{
"username": "mockUser",
"photo": "/mock-backend/public/img/avatar.png",
"displayName": "Test",
"email": "mock@hedgedoc.dev"
}

View file

@ -1,52 +0,0 @@
[
{
"identifier": "29QLD0AmT-adevdOPECtqg",
"title": "",
"lastVisited": "2020-05-16T22:26:56.547Z",
"pinStatus": false,
"tags": [
"empty title",
"should be untitled"
]
},
{
"identifier": "slide-example",
"title": "Slide example",
"lastVisited": "2020-05-30T15:20:36.088Z",
"pinStatus": true,
"tags": [
"features",
"cool",
"updated"
]
},
{
"identifier": "features",
"title": "Features",
"lastVisited": "2020-05-31T15:20:36.088Z",
"pinStatus": true,
"tags": [
"features",
"cool",
"updated"
]
},
{
"identifier": "ODakLc2MQkyyFc_Xmb53sg",
"title": "HedgeDoc V2 API",
"lastVisited": "2020-05-25T19:48:14.025Z",
"pinStatus": false,
"tags": []
},
{
"identifier": "l8JuWxApTR6Fqa0LCrpnLg",
"title": "Community call - Lets meet! (2020-06-06 18:00 UTC / 20:00 CEST)",
"lastVisited": "2020-05-24T16:04:36.433Z",
"pinStatus": false,
"tags": [
"agenda",
"HedgeDoc community",
"community call"
]
}
]

View file

@ -1,3 +0,0 @@
{
"link": "/mock-backend/public/img/avatar.png"
}

View file

@ -1,12 +0,0 @@
[
{
"timestamp": 1598390307,
"length": 2788,
"authors": ["dermolly", "mrdrogdrog"]
},
{
"timestamp": 1598389571,
"length": 2782,
"authors": ["dermolly", "mrdrogdrog", "emcrx"]
}
]

View file

@ -1,5 +0,0 @@
{
"content": "---\ntitle: Features\ndescription: Many features, such wow!\nrobots: noindex\ntags: hedgedoc, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=KgMpKsp23yY\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : hello --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is <back:cadetblue><size:18>displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n <u:red>This<\/u> is <color #118888>displayed<\/color>\n **<color purple>left of<\/color> <s:red>Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n <w:#FF33FF>This is hosted<\/w> by <img sourceforge.jpg>\nend note\n@enduml\n```\n\n",
"timestamp": 1598389571,
"authors": ["mrdrogdrog", "dermolly", "emcrx"]
}

View file

@ -1,5 +0,0 @@
{
"content": "---\ntitle: Features\ndescription: Many more features, such wow!\nrobots: noindex\ntags: hedgedoc, demo, react\nopengraph:\n title: Features\n---\n# Embedding demo\n[TOC]\n\n## some plain text\n\nLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magnus aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetezur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam _et_ justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.\n\n## MathJax\nYou can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com](https:\/\/math.stackexchange.com\/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\nx = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.\n$$\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> More information about **LaTeX** mathematical expressions [here](https:\/\/meta.math.stackexchange.com\/questions\/5020\/mathjax-basic-tutorial-and-quick-reference).\n\n## Blockquote\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\n> [color=red] [name=John Doe] [time=2020-06-21 22:50]\n\n## Slideshare\n{%slideshare mazlan1\/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps:\/\/gist.github.com\/schacon\/1\n\n## YouTube\nhttps:\/\/www.youtube.com\/watch?v=zHAIuE5BQWk\n\n## Vimeo\nhttps:\/\/vimeo.com\/23237102\n\n## PDF\n{%pdf https:\/\/www.w3.org\/WAI\/ER\/tests\/xhtml\/testfiles\/resources\/pdf\/dummy.pdf %}\n\n## Code highlighting\n```javascript=\n\nlet a = 1\n```\n\n## PlantUML\n```plantuml\n@startuml\nparticipant Alice\nparticipant \"The **Famous** Bob\" as Bob\n\nAlice -> Bob : bye --there--\n... Some ~~long delay~~ ...\nBob -> Alice : ok\nnote left\n This is **bold**\n This is \/\/italics\/\/\n This is \"\"monospaced\"\"\n This is --stroked--\n This is __underlined__\n This is ~~waved~~\nend note\n\nAlice -> Bob : A \/\/well formatted\/\/ message\nnote right of Alice\n This is <back:cadetblue><size:18>displayed<\/size><\/back>\n __left of__ Alice.\nend note\nnote left of Bob\n <u:red>This<\/u> is <color #118888>displayed<\/color>\n **<color purple>left of<\/color> <s:red>Alice<\/strike> Bob**.\nend note\nnote over Alice, Bob\n <w:#FF33FF>This is hosted<\/w> by <img sourceforge.jpg>\nend note\n@enduml\n```\n\n",
"timestamp": 1598390307,
"authors": ["mrdrogdrog", "dermolly"]
}

File diff suppressed because one or more lines are too long

View file

@ -1,18 +0,0 @@
{
"content": "test123",
"metadata": {
"id": "ABC3",
"alias": "old",
"version": 1,
"viewCount": 0,
"updateTime": "2021-04-24T09:27:51.000Z",
"updateUser": {
"userName": "test",
"displayName": "Testy",
"photo": "",
"email": ""
},
"createTime": "2021-04-24T09:27:51.000Z",
"editedBy": []
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,17 +0,0 @@
[
{
"label": "Demo-App",
"keyId": "demo",
"createdAt": "2021-11-20T23:54:13+01:00",
"lastUsed": "2021-11-20T23:54:13+01:00",
"validUntil": "2022-11-20"
},
{
"label": "CLI @ Test-PC",
"keyId": "cli",
"createdAt": "2021-11-20T23:54:13+01:00",
"lastUsed": "2021-11-20T23:54:13+01:00",
"validUntil": "2021-11-20"
}
]

View file

@ -1,7 +0,0 @@
{
"id": "dermolly",
"photo": "/mock-backend/public/img/avatar.png",
"name": "Philip",
"status": "ok",
"provider": "internal"
}

View file

@ -1,7 +0,0 @@
{
"id": "emcrx",
"photo": "/mock-backend/public/img/avatar.png",
"name": "Erik",
"status": "ok",
"provider": "internal"
}

View file

@ -1,7 +0,0 @@
{
"id": "mrdrogdrog",
"photo": "/mock-backend/public/img/avatar.png",
"name": "Tilman",
"status": "ok",
"provider": "internal"
}

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

View file

@ -2,4 +2,4 @@
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved. What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
::: :::
![HedgeDoc Screenshot](/mock-backend/public/screenshot.png) ![HedgeDoc Screenshot](/mock-public/screenshot.png)

View file

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

48
src/api/alias/index.ts Normal file
View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Alias, NewAliasDto, PrimaryAliasDto } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
/**
* Adds an alias to an existing note.
* @param noteIdOrAlias The note id or an existing alias for a note.
* @param newAlias The new alias.
* @return Information about the newly created alias.
*/
export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise<Alias> => {
const response = await new PostApiRequestBuilder<Alias, NewAliasDto>('alias')
.withJsonBody({
noteIdOrAlias,
newAlias
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Marks a given alias as the primary one for a note.
* The former primary alias should be marked as non-primary by the backend automatically.
* @param alias The alias to mark as primary for its corresponding note.
* @return The updated information about the alias.
*/
export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
const response = await new PutApiRequestBuilder<Alias, PrimaryAliasDto>('alias/' + alias)
.withJsonBody({
primaryAlias: true
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes a given alias from its corresponding note.
* @param alias The alias to remove from its note.
*/
export const deleteAlias = async (alias: string): Promise<void> => {
await new DeleteApiRequestBuilder('alias/' + alias).sendRequest()
}

19
src/api/alias/types.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Alias {
name: string
primaryAlias: boolean
noteId: string
}
export interface NewAliasDto {
noteIdOrAlias: string
newAlias: string
}
export interface PrimaryAliasDto {
primaryAlias: boolean
}

View file

@ -1,34 +1,14 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
export const INTERACTIVE_LOGIN_METHODS = ['local', 'ldap']
export enum AuthError {
INVALID_CREDENTIALS = 'invalidCredentials',
LOGIN_DISABLED = 'loginDisabled',
OPENID_ERROR = 'openIdError',
OTHER = 'other'
}
export enum RegisterError {
USERNAME_EXISTING = 'usernameExisting',
REGISTRATION_DISABLED = 'registrationDisabled',
OTHER = 'other'
}
/** /**
* Requests to logout the current user. * Requests to log out the current user.
* @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> => {
const response = await fetch(getApiUrl() + 'auth/logout', { await new DeleteApiRequestBuilder('auth/logout').sendRequest()
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
} }

View file

@ -1,25 +1,28 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import type { LoginDto } from './types'
import { AuthError } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
/** /**
* Requests to login a user via LDAP credentials. * Requests to login a user via LDAP credentials.
* @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.
*/ */
export const doLdapLogin = async (username: string, password: string): Promise<void> => { export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/ldap', { await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
...defaultFetchConfig, .withJsonBody({
method: 'POST',
body: JSON.stringify({
username: username, username: username,
password: password password: password
}) })
.withStatusCodeErrorMapping({
401: AuthError.INVALID_CREDENTIALS
}) })
.sendRequest()
expectResponseCode(response)
} }

View file

@ -1,38 +1,31 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ChangePasswordDto, LoginDto, RegisterDto } from './types'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { AuthError, RegisterError } from './types'
import { AuthError, RegisterError } from './index' import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
/** /**
* Requests to do a local login with a provided username and password. * Requests to do a local login with a provided username and password.
* @param username The username for which the login should be tried. * @param username The username for which the login should be tried.
* @param password The password which should be used to login. * @param password The password which should be used to log in.
* @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong. * @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong.
* @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend. * @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend.
*/ */
export const doLocalLogin = async (username: string, password: string): Promise<void> => { export const doLocalLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/local/login', { await new PostApiRequestBuilder<void, LoginDto>('auth/local/login')
...defaultFetchConfig, .withJsonBody({
method: 'POST',
body: JSON.stringify({
username, username,
password password
}) })
.withStatusCodeErrorMapping({
400: AuthError.LOGIN_DISABLED,
401: AuthError.INVALID_CREDENTIALS
}) })
.sendRequest()
if (response.status === 400) {
throw new Error(AuthError.LOGIN_DISABLED)
}
if (response.status === 401) {
throw new Error(AuthError.INVALID_CREDENTIALS)
}
expectResponseCode(response, 201)
} }
/** /**
@ -40,29 +33,21 @@ export const doLocalLogin = async (username: string, password: string): Promise<
* @param username The username of the new user. * @param username The username of the new user.
* @param displayName The display name of the new user. * @param displayName The display name of the new user.
* @param password The password of the new user. * @param password The password of the new user.
* @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same user name. * @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same username.
* @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend. * @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend.
*/ */
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => { export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/local', { await new PostApiRequestBuilder<void, RegisterDto>('auth/local')
...defaultFetchConfig, .withJsonBody({
method: 'POST',
body: JSON.stringify({
username, username,
displayName, displayName,
password password
}) })
.withStatusCodeErrorMapping({
400: RegisterError.REGISTRATION_DISABLED,
409: RegisterError.USERNAME_EXISTING
}) })
.sendRequest()
if (response.status === 409) {
throw new Error(RegisterError.USERNAME_EXISTING)
}
if (response.status === 400) {
throw new Error(RegisterError.REGISTRATION_DISABLED)
}
expectResponseCode(response)
} }
/** /**
@ -73,22 +58,14 @@ 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> => {
const response = await fetch(getApiUrl() + 'auth/local', { await new PutApiRequestBuilder<void, ChangePasswordDto>('auth/local')
...defaultFetchConfig, .withJsonBody({
method: 'PUT',
body: JSON.stringify({
currentPassword, currentPassword,
newPassword newPassword
}) })
.withStatusCodeErrorMapping({
400: AuthError.LOGIN_DISABLED,
401: AuthError.INVALID_CREDENTIALS
}) })
.sendRequest()
if (response.status === 401) {
throw new Error(AuthError.INVALID_CREDENTIALS)
}
if (response.status === 400) {
throw new Error(AuthError.LOGIN_DISABLED)
}
expectResponseCode(response)
} }

33
src/api/auth/types.ts Normal file
View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* 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',
REGISTRATION_DISABLED = 'registrationDisabled',
OTHER = 'other'
}
export interface LoginDto {
username: string
password: string
}
export interface RegisterDto {
username: string
password: string
displayName: string
}
export interface ChangePasswordDto {
currentPassword: string
newPassword: string
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiRequestBuilder } from './api-request-builder'
/**
* Builder to construct and execute a call to the HTTP API that contains a body payload.
*
* @param RequestBodyType The type of the request body if applicable.
*/
export abstract class ApiRequestBuilderWithBody<ResponseType, RequestBodyType> extends ApiRequestBuilder<ResponseType> {
/**
* Adds a body part to the API request. If this is called multiple times, only the body of the last invocation will be
* used during the execution of the request.
*
* @param bodyData The data to use as request body.
* @return The API request instance itself for chaining.
*/
withBody(bodyData: BodyInit): this {
this.requestBody = bodyData
return this
}
/**
* Adds a JSON-encoded body part to the API request. This method will set the content-type header appropriately.
*
* @param bodyData The data to use as request body. Will get stringified to JSON.
* @return The API request instance itself for chaining.
* @see {withBody}
*/
withJsonBody(bodyData: RequestBodyType): this {
this.withHeader('Content-Type', 'application/json')
return this.withBody(JSON.stringify(bodyData))
}
}

View file

@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { apiUrl } from '../../../utils/api-url'
import deepmerge from 'deepmerge'
import { defaultConfig, defaultHeaders } from '../default-config'
import { ApiResponse } from '../api-response'
/**
* Builder to construct and execute a call to the HTTP API.
*
* @param ResponseType The type of the response if applicable.
*/
export abstract class ApiRequestBuilder<ResponseType> {
private readonly targetUrl: string
private overrideExpectedResponseStatus: number | undefined
private customRequestOptions = defaultConfig
private customRequestHeaders = new Headers(defaultHeaders)
private customStatusCodeErrorMapping: Record<number, string> | undefined
protected requestBody: BodyInit | undefined
/**
* Initializes a new API call with the default request options.
*
* @param endpoint The target endpoint without a leading slash.
*/
constructor(endpoint: string) {
this.targetUrl = apiUrl + endpoint
}
protected async sendRequestAndVerifyResponse(
httpMethod: RequestInit['method'],
defaultExpectedStatus: number
): Promise<ApiResponse<ResponseType>> {
const response = await fetch(this.targetUrl, {
...this.customRequestOptions,
method: httpMethod,
headers: this.customRequestHeaders,
body: this.requestBody
})
if (this.customStatusCodeErrorMapping && this.customStatusCodeErrorMapping[response.status]) {
throw new Error(this.customStatusCodeErrorMapping[response.status])
}
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)
}
/**
* Adds an HTTP header to the API request. Previous headers with the same name will get overridden on subsequent calls
* with the same name.
*
* @param name The name of the HTTP header to add. Example: 'Content-Type'
* @param value The value of the HTTP header to add. Example: 'text/markdown'
* @return The API request instance itself for chaining.
*/
withHeader(name: string, value: string): this {
this.customRequestHeaders.set(name, value)
return this
}
/**
* Adds custom request options for the underlying fetch request by merging them with the existing options.
*
* @param options The options to set for the fetch request.
* @return The API request instance itself for chaining.
*/
withCustomOptions(options: Partial<Omit<RequestInit, 'method' | 'headers' | 'body'>>): this {
this.customRequestOptions = deepmerge(this.customRequestOptions, options)
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.
*
* @return The API response.
* @throws Error when the status code does not match the expected one or is defined as in the custom status code
* error mapping.
*/
abstract sendRequest(): Promise<ApiResponse<ResponseType>>
}

View file

@ -0,0 +1,170 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { DeleteApiRequestBuilder } from './delete-api-request-builder'
describe('DeleteApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 204, { method: 'DELETE' })
await new DeleteApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new DeleteApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
body: 'HedgeDoc'
})
await new DeleteApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'DELETE' })
await new DeleteApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'force-cache'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'no-store'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'force-cache',
integrity: 'test'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/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/mock-backend/private/test', 400, { method: 'DELETE' })
const request = new DeleteApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'DELETE' })
const request = new DeleteApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a DELETE request to the API.
*
* @param ResponseType The type of the expected response. Defaults to no response body.
* @param RequestBodyType The type of the request body. Defaults to no request body.
* @see {ApiRequestBuilder}
*/
export class DeleteApiRequestBuilder<ResponseType = void, RequestBodyType = unknown> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('DELETE', 204)
}
}

View file

@ -0,0 +1,146 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { GetApiRequestBuilder } from './get-api-request-builder'
describe('GetApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
await new GetApiRequestBuilder<string>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
await new GetApiRequestBuilder<string>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'force-cache'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'no-store'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'force-cache',
integrity: 'test'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/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/mock-backend/private/test', 400, { method: 'GET' })
const request = new GetApiRequestBuilder<string>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'GET' })
const request = new GetApiRequestBuilder<string>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiRequestBuilder } from './api-request-builder'
import type { ApiResponse } from '../api-response'
/**
* Builder to construct a GET request to the API.
*
* @param ResponseType The type of the expected response.
* @see {ApiRequestBuilder}
*/
export class GetApiRequestBuilder<ResponseType> extends ApiRequestBuilder<ResponseType> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('GET', 200)
}
}

View file

@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PostApiRequestBuilder } from './post-api-request-builder'
import { expectFetch } from './test-utils/expect-fetch'
describe('PostApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 201, { method: 'POST' })
await new PostApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new PostApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
body: 'HedgeDoc'
})
await new PostApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'POST' })
await new PostApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'force-cache'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'no-store'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'force-cache',
integrity: 'test'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/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/mock-backend/private/test', 400, { method: 'POST' })
const request = new PostApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'POST' })
const request = new PostApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a POST request to the API.
*
* @param ResponseType The type of the expected response.
* @param RequestBodyType The type of the request body
* @see {ApiRequestBuilder}
*/
export class PostApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('POST', 201)
}
}

View file

@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { PutApiRequestBuilder } from './put-api-request-builder'
describe('PutApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
await new PutApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new PutApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
body: 'HedgeDoc'
})
await new PutApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
await new PutApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'force-cache'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'no-store'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'force-cache',
integrity: 'test'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/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/mock-backend/private/test', 400, { method: 'PUT' })
const request = new PutApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'PUT' })
const request = new PutApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a PUT request to the API.
*
* @param ResponseType The type of the expected response.
* @param RequestBodyType The type of the request body
* @see {ApiRequestBuilder}
*/
export class PutApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('PUT', 200)
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultConfig } from '../../default-config'
import { Mock } from 'ts-mockery'
export const expectFetch = (expectedUrl: string, expectedStatusCode: number, expectedOptions: RequestInit): void => {
global.fetch = jest.fn((fetchUrl: RequestInfo, fetchOptions?: RequestInit): Promise<Response> => {
expect(fetchUrl).toEqual(expectedUrl)
expect(fetchOptions).toStrictEqual({
...defaultConfig,
body: undefined,
headers: new Headers(),
...expectedOptions
})
return Promise.resolve(
Mock.of<Response>({
status: expectedStatusCode
})
)
})
}

View file

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Mock } from 'ts-mockery'
import { ApiResponse } from './api-response'
describe('ApiResponse', () => {
it('getResponse returns input response', () => {
const mockResponse = Mock.of<Response>()
const responseObj = new ApiResponse(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', () => {
it('invalid header', async () => {
const mockHeaders = new Headers()
mockHeaders.set('Content-Type', 'text/invalid')
const mockResponse = Mock.of<Response>({
headers: mockHeaders
})
const responseObj = new ApiResponse(mockResponse)
await expect(responseObj.asParsedJsonObject()).rejects.toThrow('Response body does not seem to be JSON encoded')
})
it('valid header', async () => {
const mockHeaders = new Headers()
mockHeaders.set('Content-Type', 'application/json')
const mockBody = {
Hedgy: '🦔'
}
const mockResponse = Mock.of<Response>({
headers: mockHeaders,
json(): Promise<typeof mockBody> {
return Promise.resolve(mockBody)
}
})
const responseObj = new ApiResponse(mockResponse)
await expect(responseObj.asParsedJsonObject()).resolves.toEqual(mockBody)
})
})
})

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Class that represents the response of an {@link ApiRequestBuilder}.
*/
export class ApiResponse<ResponseType> {
private readonly response: Response
/**
* Initializes a new API response instance based on an HTTP response.
* @param response The HTTP response from the fetch call.
*/
constructor(response: Response) {
this.response = response
}
/**
* Returns the raw response from the fetch call.
*
* @return The response from the fetch call.
*/
getResponse(): Response {
return this.response
}
/**
* Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded.
*
* @return The parsed JSON response.
* @throws Error if the response is not JSON encoded.
*/
async asParsedJsonObject(): Promise<ResponseType> {
if (!this.response.headers.get('Content-Type')?.startsWith('application/json')) {
throw new Error('Response body does not seem to be JSON encoded.')
}
// TODO Responses should better be type validated
// see https://github.com/hedgedoc/react-client/issues/1219
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()
}
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const defaultHeaders: HeadersInit = {}
export const defaultConfig: Partial<RequestInit> = {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
redirect: 'follow',
referrerPolicy: 'no-referrer'
}

View file

@ -1,16 +1,17 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { Config } from './types' import type { Config } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
/**
* Fetches the frontend config from the backend.
* @return The frontend config.
*/
export const getConfig = async (): Promise<Config> => { export const getConfig = async (): Promise<Config> => {
const response = await fetch(getApiUrl() + 'config', { const response = await new GetApiRequestBuilder<Config>('config').sendRequest()
...defaultFetchConfig return response.asParsedJsonObject()
})
expectResponseCode(response)
return (await response.json()) as Promise<Config>
} }

View file

@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Config {
allowAnonymous: boolean
allowRegister: boolean
authProviders: AuthProvidersState
branding: BrandingConfig
customAuthNames: CustomAuthNames
useImageProxy: boolean
specialUrls: SpecialUrls
version: BackendVersion
plantumlServer: string | null
maxDocumentLength: number
iframeCommunication: iframeCommunicationConfig
}
export interface iframeCommunicationConfig {
editorOrigin: string
rendererOrigin: string
}
export interface BrandingConfig {
name: string
logo: string
}
export interface BackendVersion {
major: number
minor: number
patch: number
preRelease?: string
commit?: string
}
export interface AuthProvidersState {
facebook: boolean
github: boolean
twitter: boolean
gitlab: boolean
dropbox: boolean
ldap: boolean
google: boolean
saml: boolean
oauth2: boolean
local: boolean
}
export interface CustomAuthNames {
ldap: string
oauth2: string
saml: string
}
export interface SpecialUrls {
privacy?: string
termsOfUse?: string
imprint?: string
}

92
src/api/config/types.ts Normal file
View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Config {
allowAnonymous: boolean
allowRegister: boolean
authProviders: AuthProvider[]
branding: BrandingConfig
useImageProxy: boolean
specialUrls: SpecialUrls
version: BackendVersion
plantumlServer?: string
maxDocumentLength: number
iframeCommunication: iframeCommunicationConfig
}
export enum AuthProviderType {
DROPBOX = 'dropbox',
FACEBOOK = 'facebook',
GITHUB = 'github',
GOOGLE = 'google',
TWITTER = 'twitter',
GITLAB = 'gitlab',
OAUTH2 = 'oauth2',
LDAP = 'ldap',
SAML = 'saml',
LOCAL = 'local'
}
export type AuthProviderTypeWithCustomName =
| AuthProviderType.GITLAB
| AuthProviderType.OAUTH2
| AuthProviderType.LDAP
| AuthProviderType.SAML
export type AuthProviderTypeWithoutCustomName =
| AuthProviderType.DROPBOX
| AuthProviderType.FACEBOOK
| AuthProviderType.GITHUB
| AuthProviderType.GOOGLE
| AuthProviderType.TWITTER
| AuthProviderType.LOCAL
export const authProviderTypeOneClick = [
AuthProviderType.DROPBOX,
AuthProviderType.FACEBOOK,
AuthProviderType.GITHUB,
AuthProviderType.GITLAB,
AuthProviderType.GOOGLE,
AuthProviderType.OAUTH2,
AuthProviderType.SAML,
AuthProviderType.TWITTER
]
export interface AuthProviderWithCustomName {
type: AuthProviderTypeWithCustomName
identifier: string
providerName: string
}
export interface AuthProviderWithoutCustomName {
type: AuthProviderTypeWithoutCustomName
}
export type AuthProvider = AuthProviderWithCustomName | AuthProviderWithoutCustomName
export interface iframeCommunicationConfig {
editorOrigin: string
rendererOrigin: string
}
export interface BrandingConfig {
name?: string
logo?: string
}
export interface BackendVersion {
major: number
minor: number
patch: number
preRelease?: string
commit?: string
}
export interface SpecialUrls {
privacy?: string
termsOfUse?: string
imprint?: string
}

18
src/api/group/index.ts Normal file
View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { GroupInfo } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
/**
* Retrieves information about a group with a given name.
* @param groupName The name of the group.
* @return Information about the group.
*/
export const getGroup = async (groupName: string): Promise<GroupInfo> => {
const response = await new GetApiRequestBuilder<GroupInfo>('groups/' + groupName).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export interface GroupInfoDto { export interface GroupInfo {
name: string name: string
displayName: string displayName: string
special: boolean special: boolean

View file

@ -3,32 +3,20 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { HistoryEntry, HistoryEntryPutDto, HistoryEntryWithOrigin } from './types'
import { HistoryEntryOrigin } from './types'
import type { HistoryEntry } from '../../redux/history/types' export const addRemoteOriginToHistoryEntry = (entryDto: HistoryEntry): HistoryEntryWithOrigin => {
import { HistoryEntryOrigin } from '../../redux/history/types'
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => {
return { return {
origin: HistoryEntryOrigin.REMOTE, ...entryDto,
title: entryDto.title, origin: HistoryEntryOrigin.REMOTE
pinStatus: entryDto.pinStatus,
identifier: entryDto.identifier,
tags: entryDto.tags,
lastVisited: entryDto.lastVisited
} }
} }
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => { export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
return { return {
pinStatus: entry.pinStatus, pinStatus: entry.pinStatus,
lastVisited: entry.lastVisited, lastVisitedAt: entry.lastVisitedAt,
note: entry.identifier note: entry.identifier
} }
} }
export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => {
return {
pinStatus: entry.pinStatus
}
}

View file

@ -1,50 +1,59 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ChangePinStatusDto, HistoryEntry, HistoryEntryPutDto } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-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 { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' /**
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types' * Fetches the remote history for the user from the server.
* @return The remote history entries of the user.
export const getHistory = async (): Promise<HistoryEntryDto[]> => { */
const response = await fetch(getApiUrl() + 'me/history', { export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
...defaultFetchConfig const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history').sendRequest()
}) return response.asParsedJsonObject()
expectResponseCode(response)
return (await response.json()) as Promise<HistoryEntryDto[]>
} }
export const postHistory = async (entries: HistoryEntryPutDto[]): Promise<void> => { /**
const response = await fetch(getApiUrl() + 'me/history', { * Replaces the remote history of the user with the given history entries.
...defaultFetchConfig, * @param entries The history entries to store remotely.
method: 'POST', */
body: JSON.stringify(entries) export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
}) await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history').withJsonBody(entries).sendRequest()
expectResponseCode(response)
} }
export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise<void> => { /**
const response = await fetch(getApiUrl() + 'me/history/' + noteId, { * Updates a remote history entry's pin state.
...defaultFetchConfig, * @param noteIdOrAlias The note id for which to update the pinning state.
method: 'PUT', * @param pinStatus True when the note should be pinned, false otherwise.
body: JSON.stringify(entry) */
export const updateRemoteHistoryEntryPinStatus = async (
noteIdOrAlias: string,
pinStatus: boolean
): Promise<HistoryEntry> => {
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>('me/history/' + noteIdOrAlias)
.withJsonBody({
pinStatus
}) })
expectResponseCode(response) .sendRequest()
return response.asParsedJsonObject()
} }
export const deleteHistoryEntry = async (noteId: string): Promise<void> => { /**
const response = await fetch(getApiUrl() + 'me/history/' + noteId, { * Deletes a remote history entry.
...defaultFetchConfig, * @param noteIdOrAlias The note id or alias of the history entry to remove.
method: 'DELETE' */
}) export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
expectResponseCode(response) await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest()
} }
export const deleteHistory = async (): Promise<void> => { /**
const response = await fetch(getApiUrl() + 'me/history', { * Deletes the complete remote history.
...defaultFetchConfig, */
method: 'DELETE' export const deleteRemoteHistory = async (): Promise<void> => {
}) await new DeleteApiRequestBuilder('me/history').sendRequest()
expectResponseCode(response)
} }

View file

@ -1,23 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface HistoryEntryPutDto {
note: string
pinStatus: boolean
lastVisited: string
}
export interface HistoryEntryUpdateDto {
pinStatus: boolean
}
export interface HistoryEntryDto {
identifier: string
title: string
lastVisited: string
tags: string[]
pinStatus: boolean
}

31
src/api/history/types.ts Normal file
View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum HistoryEntryOrigin {
LOCAL = 'local',
REMOTE = 'remote'
}
export interface HistoryEntryPutDto {
note: string
pinStatus: boolean
lastVisitedAt: string
}
export interface HistoryEntry {
identifier: string
title: string
lastVisitedAt: string
tags: string[]
pinStatus: boolean
}
export interface HistoryEntryWithOrigin extends HistoryEntry {
origin: HistoryEntryOrigin
}
export interface ChangePinStatusDto {
pinStatus: boolean
}

View file

@ -1,43 +1,48 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { MediaUpload } from '../media/types'
import type { UserInfoDto } from '../users/types' import type { ChangeDisplayNameDto, LoginUserInfo } from './types'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { isMockMode } from '../../utils/test-modes' import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
/** /**
* Returns metadata about the currently signed-in user from the API. * Returns metadata about the currently signed-in user from the API.
* @throws Error when the user is not signed-in. * @throws Error when the user is not signed-in.
* @return The user metadata. * @return The user metadata.
*/ */
export const getMe = async (): Promise<UserInfoDto> => { export const getMe = async (): Promise<LoginUserInfo> => {
const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, { const response = await new GetApiRequestBuilder<LoginUserInfo>('me').sendRequest()
...defaultFetchConfig return response.asParsedJsonObject()
})
expectResponseCode(response)
return (await response.json()) as UserInfoDto
}
export const updateDisplayName = async (displayName: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'me', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
name: displayName
})
})
expectResponseCode(response)
} }
/**
* Deletes the current user from the server.
*/
export const deleteUser = async (): Promise<void> => { export const deleteUser = async (): Promise<void> => {
const response = await fetch(getApiUrl() + 'me', { await new DeleteApiRequestBuilder('me').sendRequest()
...defaultFetchConfig, }
method: 'DELETE'
}) /**
* Changes the display name of the current user.
expectResponseCode(response) * @param displayName The new display name to set.
*/
export const updateDisplayName = async (displayName: string): Promise<void> => {
await new PostApiRequestBuilder<void, ChangeDisplayNameDto>('me/profile')
.withJsonBody({
displayName
})
.sendRequest()
}
/**
* Retrieves a list of media belonging to the user.
* @return List of media object information.
*/
export const getMyMedia = async (): Promise<MediaUpload[]> => {
const response = await new GetApiRequestBuilder<MediaUpload[]>('me/media').sendRequest()
return response.asParsedJsonObject()
} }

15
src/api/me/types.ts Normal file
View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfo } from '../users/types'
export interface LoginUserInfo extends UserInfo {
authProvider: string
email: string
}
export interface ChangeDisplayNameDto {
displayName: string
}

View file

@ -1,49 +1,47 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ImageProxyRequestDto, ImageProxyResponse, MediaUpload } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { isMockMode, isTestMode } from '../../utils/test-modes' /**
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' * Requests an image-proxy URL from the backend for a given image URL.
* @param imageUrl The image URL which should be proxied.
export interface ImageProxyResponse { * @return The proxy URL for the image.
src: string */
}
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => { export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
const response = await fetch(getApiUrl() + 'media/proxy', { const response = await new PostApiRequestBuilder<ImageProxyResponse, ImageProxyRequestDto>('media/proxy')
...defaultFetchConfig, .withJsonBody({
method: 'POST', url: imageUrl
body: JSON.stringify({
src: imageUrl
}) })
}) .sendRequest()
expectResponseCode(response) return response.asParsedJsonObject()
return (await response.json()) as Promise<ImageProxyResponse>
} }
export interface UploadedMedia { /**
link: string * Uploads a media file to the backend.
* @param noteIdOrAlias The id or alias of the note from which the media is uploaded.
* @param media The binary media content.
* @return The URL of the uploaded media object.
*/
export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<MediaUpload> => {
const postData = new FormData()
postData.append('file', media)
const response = await new PostApiRequestBuilder<MediaUpload, void>('media')
.withHeader('Content-Type', 'multipart/form-data')
.withHeader('HedgeDoc-Note', noteIdOrAlias)
.withBody(postData)
.sendRequest()
return response.asParsedJsonObject()
} }
export const uploadFile = async (noteId: string, media: Blob): Promise<UploadedMedia> => { /**
const response = await fetch(`${getApiUrl()}media/upload${isMockMode() ? '-post' : ''}`, { * Deletes some uploaded media object.
...defaultFetchConfig, * @param mediaId The identifier of the media object to delete.
headers: { */
'Content-Type': media.type, export const deleteUploadedMedia = async (mediaId: string): Promise<void> => {
'HedgeDoc-Note': noteId await new DeleteApiRequestBuilder('media/' + mediaId).sendRequest()
},
method: isMockMode() ? 'GET' : 'POST',
body: isMockMode() ? undefined : media
})
if (isMockMode() && !isTestMode()) {
await new Promise((resolve) => {
setTimeout(resolve, 3000)
})
}
expectResponseCode(response, isMockMode() ? 200 : 201)
return (await response.json()) as Promise<UploadedMedia>
} }

19
src/api/media/types.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface MediaUpload {
url: string
noteId: string | null
createdAt: string
username: string
}
export interface ImageProxyResponse {
url: string
}
export interface ImageProxyRequestDto {
url: string
}

View file

@ -1,27 +1,65 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Note } from './types'
import type { MediaUpload } from '../media/types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' /**
import type { NoteDto } from './types' * Retrieves the content and metadata about the specified note.
import { isMockMode } from '../../utils/test-modes' * @param noteIdOrAlias The id or alias of the note.
* @return Content and metadata of the specified note.
export const getNote = async (noteId: string): Promise<NoteDto> => { */
// The "-get" suffix is necessary, because in our mock api (filesystem) the note id might already be a folder. export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
// TODO: [mrdrogdrog] replace -get with actual api route as soon as api backend is ready. const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
const response = await fetch(getApiUrl() + `notes/${noteId}${isMockMode() ? '-get' : ''}`, { return response.asParsedJsonObject()
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as Promise<NoteDto>
} }
export const deleteNote = async (noteId: string): Promise<void> => { /**
const response = await fetch(getApiUrl() + `notes/${noteId}`, { * Returns a list of media objects associated with the specified note.
...defaultFetchConfig, * @param noteIdOrAlias The id or alias of the note.
method: 'DELETE' * @return List of media object metadata associated with specified note.
}) */
expectResponseCode(response) export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUpload[]> => {
const response = await new GetApiRequestBuilder<MediaUpload[]>(`notes/${noteIdOrAlias}/media`).sendRequest()
return response.asParsedJsonObject()
}
/**
* Creates a new note with a given markdown content.
* @param markdown The content of the new note.
* @return Content and metadata of the new note.
*/
export const createNote = async (markdown: string): Promise<Note> => {
const response = await new PostApiRequestBuilder<Note, void>('notes')
.withHeader('Content-Type', 'text/markdown')
.withBody(markdown)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Creates a new note with a given markdown content and a defined primary alias.
* @param markdown The content of the new note.
* @param primaryAlias The primary alias of the new note.
* @return Content and metadata of the new note.
*/
export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: string): Promise<Note> => {
const response = await new PostApiRequestBuilder<Note, void>('notes/' + primaryAlias)
.withHeader('Content-Type', 'text/markdown')
.withBody(markdown)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Deletes the specified note.
* @param noteIdOrAlias The id or alias of the note to delete.
*/
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => {
await new DeleteApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest()
} }

View file

@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfoDto } from '../users/types'
import type { GroupInfoDto } from '../group/types'
export interface NoteDto {
content: string
metadata: NoteMetadataDto
editedByAtPosition: NoteAuthorshipDto[]
}
export interface NoteMetadataDto {
id: string
alias: string
version: number
title: string
description: string
tags: string[]
updateTime: string
updateUser: UserInfoDto
viewCount: number
createTime: string
editedBy: string[]
permissions: NotePermissionsDto
}
export interface NoteAuthorshipDto {
userName: string
startPos: number
endPos: number
createdAt: string
updatedAt: string
}
export interface NotePermissionsDto {
owner: UserInfoDto
sharedToUsers: NoteUserPermissionEntryDto[]
sharedToGroups: NoteGroupPermissionEntryDto[]
}
export interface NoteUserPermissionEntryDto {
user: UserInfoDto
canEdit: boolean
}
export interface NoteGroupPermissionEntryDto {
group: GroupInfoDto
canEdit: boolean
}

52
src/api/notes/types.ts Normal file
View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Alias } from '../alias/types'
export interface Note {
content: string
metadata: NoteMetadata
editedByAtPosition: NoteEdit[]
}
export interface NoteMetadata {
id: string
aliases: Alias[]
primaryAddress: string
title: string
description: string
tags: string[]
updatedAt: string
updateUsername: string | null
viewCount: number
createdAt: string
editedBy: string[]
permissions: NotePermissions
version: number
}
export interface NoteEdit {
username: string | null
startPos: number
endPos: number
createdAt: string
updatedAt: string
}
export interface NotePermissions {
owner: string | null
sharedToUsers: NoteUserPermissionEntry[]
sharedToGroups: NoteGroupPermissionEntry[]
}
export interface NoteUserPermissionEntry {
username: string
canEdit: boolean
}
export interface NoteGroupPermissionEntry {
groupName: string
canEdit: boolean
}

View file

@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NotePermissions } from '../notes/types'
import type { OwnerChangeDto, PermissionSetDto } from './types'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
/**
* Sets the owner of a note.
* @param noteId The id of the note.
* @param owner The username of the new owner.
* @return The updated note permissions.
*/
export const setNoteOwner = async (noteId: string, owner: string): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, OwnerChangeDto>(
`notes/${noteId}/metadata/permissions/owner`
)
.withJsonBody({
owner
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Sets a permission for one user of a note.
* @param noteId The id of the note.
* @param username The username of the user to set the permission for.
* @param canEdit true if the user should be able to update the note, false otherwise.
*/
export const setUserPermission = async (
noteId: string,
username: string,
canEdit: boolean
): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
`notes/${noteId}/metadata/permissions/users/${username}`
)
.withJsonBody({
canEdit
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Sets a permission for one group of a note.
* @param noteId The id of the note.
* @param groupName The name of the group to set the permission for.
* @param canEdit true if the group should be able to update the note, false otherwise.
*/
export const setGroupPermission = async (
noteId: string,
groupName: string,
canEdit: boolean
): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
`notes/${noteId}/metadata/permissions/groups/${groupName}`
)
.withJsonBody({
canEdit
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes the permissions of a note for a user.
* @param noteId The id of the note.
* @param username The name of the user to remove the permission of.
*/
export const removeUserPermission = async (noteId: string, username: string): Promise<NotePermissions> => {
const response = await new DeleteApiRequestBuilder<NotePermissions>(
`notes/${noteId}/metadata/permissions/users/${username}`
)
.withExpectedStatusCode(200)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes the permissions of a note for a group.
* @param noteId The id of the note.
* @param groupName The name of the group to remove the permission of.
*/
export const removeGroupPermission = async (noteId: string, groupName: string): Promise<NotePermissions> => {
const response = await new DeleteApiRequestBuilder<NotePermissions>(
`notes/${noteId}/metadata/permissions/groups/${groupName}`
)
.withExpectedStatusCode(200)
.sendRequest()
return response.asParsedJsonObject()
}

View file

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface OwnerChangeDto {
owner: string
}
export interface PermissionSetDto {
canEdit: boolean
}

View file

@ -1,34 +1,39 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { RevisionDetails, RevisionMetadata } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { Cache } from '../../components/common/cache/cache' /**
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' * Retrieves a note revision while using a cache for often retrieved revisions.
import type { Revision, RevisionListEntry } from './types' * @param noteId The id of the note for which to fetch the revision.
* @param revisionId The id of the revision to fetch.
const revisionCache = new Cache<string, Revision>(3600) * @return The revision.
*/
export const getRevision = async (noteId: string, timestamp: number): Promise<Revision> => { export const getRevision = async (noteId: string, revisionId: number): Promise<RevisionDetails> => {
const cacheKey = `${noteId}:${timestamp}` const response = await new GetApiRequestBuilder<RevisionDetails>(
if (revisionCache.has(cacheKey)) { `notes/${noteId}/revisions/${revisionId}`
return revisionCache.get(cacheKey) ).sendRequest()
} return response.asParsedJsonObject()
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions/${timestamp}`, {
...defaultFetchConfig
})
expectResponseCode(response)
const revisionData = (await response.json()) as Revision
revisionCache.put(cacheKey, revisionData)
return revisionData
} }
export const getAllRevisions = async (noteId: string): Promise<RevisionListEntry[]> => { /**
// TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data! * Retrieves a list of all revisions stored for a given note.
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions-list`, { * @param noteId The id of the note for which to look up the stored revisions.
...defaultFetchConfig * @return A list of revision ids.
}) */
expectResponseCode(response) export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[]> => {
return (await response.json()) as Promise<RevisionListEntry[]> const response = await new GetApiRequestBuilder<RevisionMetadata[]>(`notes/${noteId}/revisions`).sendRequest()
return response.asParsedJsonObject()
}
/**
* Deletes all revisions for a note.
* @param noteIdOrAlias The id or alias of the note to delete all revisions for.
*/
export const deleteRevisionsForNote = async (noteIdOrAlias: string): Promise<void> => {
await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`).sendRequest()
} }

View file

@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Revision {
content: string
timestamp: number
authors: string[]
}
export interface RevisionListEntry {
timestamp: number
length: number
authors: string[]
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteEdit } from '../notes/types'
export interface RevisionDetails extends RevisionMetadata {
content: string
patch: string
edits: NoteEdit[]
}
export interface RevisionMetadata {
id: number
createdAt: string
length: number
authorUsernames: string[]
anonymousAuthorCount: number
}

View file

@ -1,37 +1,42 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { AccessToken, AccessTokenWithSecret, CreateAccessTokenDto } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' /**
import type { AccessToken, AccessTokenWithSecret } from './types' * Retrieves the access tokens for the current user.
* @return List of access token metadata.
*/
export const getAccessTokenList = async (): Promise<AccessToken[]> => { export const getAccessTokenList = async (): Promise<AccessToken[]> => {
const response = await fetch(`${getApiUrl()}tokens`, { const response = await new GetApiRequestBuilder<AccessToken[]>('tokens').sendRequest()
...defaultFetchConfig return response.asParsedJsonObject()
})
expectResponseCode(response)
return (await response.json()) as AccessToken[]
} }
export const postNewAccessToken = async (label: string, expiryDate: string): Promise<AccessTokenWithSecret> => { /**
const response = await fetch(`${getApiUrl()}tokens`, { * Creates a new access token for the current user.
...defaultFetchConfig, * @param label The user-defined label for the new access token.
method: 'POST', * @param validUntil The user-defined expiry date of the new access token in milliseconds of unix time.
body: JSON.stringify({ * @return The new access token metadata along with its secret.
label: label, */
validUntil: expiryDate export const postNewAccessToken = async (label: string, validUntil: number): Promise<AccessTokenWithSecret> => {
const response = await new PostApiRequestBuilder<AccessTokenWithSecret, CreateAccessTokenDto>('tokens')
.withJsonBody({
label,
validUntil
}) })
}) .sendRequest()
expectResponseCode(response) return response.asParsedJsonObject()
return (await response.json()) as AccessTokenWithSecret
} }
/**
* Removes an access token from the current user account.
* @param keyId The key id of the access token to delete.
*/
export const deleteAccessToken = async (keyId: string): Promise<void> => { export const deleteAccessToken = async (keyId: string): Promise<void> => {
const response = await fetch(`${getApiUrl()}tokens/${keyId}`, { await new DeleteApiRequestBuilder('tokens/' + keyId).sendRequest()
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
} }

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,9 +9,14 @@ export interface AccessToken {
validUntil: string validUntil: string
keyId: string keyId: string
createdAt: string createdAt: string
lastUsed: string lastUsedAt: string | null
} }
export interface AccessTokenWithSecret extends AccessToken { export interface AccessTokenWithSecret extends AccessToken {
secret: string secret: string
} }
export interface CreateAccessTokenDto {
label: string
validUntil: number
}

View file

@ -1,24 +1,18 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Cache } from '../../components/common/cache/cache' import type { UserInfo } from './types'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import type { UserResponse } from './types'
const cache = new Cache<string, UserResponse>(600) /**
* Retrieves information about a specific user while using a cache to avoid many requests for the same username.
export const getUserById = async (userid: string): Promise<UserResponse> => { * @param username The username of interest.
if (cache.has(userid)) { * @return Metadata about the requested user.
return cache.get(userid) */
} export const getUser = async (username: string): Promise<UserInfo> => {
const response = await fetch(`${getApiUrl()}/users/${userid}`, { const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
...defaultFetchConfig return response.asParsedJsonObject()
})
expectResponseCode(response)
const userData = (await response.json()) as UserResponse
cache.put(userid, userData)
return userData
} }

View file

@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LoginProvider } from '../../redux/user/types'
export interface UserResponse {
id: string
name: string
photo: string
provider: LoginProvider
}
export interface UserInfoDto {
username: string
displayName: string
photo: string
email: string
}

11
src/api/users/types.ts Normal file
View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface UserInfo {
username: string
displayName: string
photo: string
}

View file

@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getGlobalState } from '../redux'
export const defaultFetchConfig: Partial<RequestInit> = {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: 'GET'
}
export const getApiUrl = (): string => {
return getGlobalState().apiUrl.apiUrl
}
export const expectResponseCode = (response: Response, code = 200): void => {
if (response.status !== code) {
throw new Error(`response code is not ${code}`)
}
}

View file

@ -6,10 +6,8 @@
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { Suspense } from 'react' import React, { Suspense } from 'react'
import { useBackendBaseUrl } from '../../hooks/common/use-backend-base-url'
import { createSetUpTaskList } from './initializers' import { createSetUpTaskList } from './initializers'
import { LoadingScreen } from './loading-screen/loading-screen' import { LoadingScreen } from './loading-screen/loading-screen'
import { useCustomizeAssetsUrl } from '../../hooks/common/use-customize-assets-url'
import { Logger } from '../../utils/logger' import { Logger } from '../../utils/logger'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'
import { ApplicationLoaderError } from './application-loader-error' import { ApplicationLoaderError } from './application-loader-error'
@ -17,11 +15,8 @@ import { ApplicationLoaderError } from './application-loader-error'
const log = new Logger('ApplicationLoader') const log = new Logger('ApplicationLoader')
export const ApplicationLoader: React.FC<PropsWithChildren<unknown>> = ({ children }) => { export const ApplicationLoader: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
const backendBaseUrl = useBackendBaseUrl()
const customizeAssetsUrl = useCustomizeAssetsUrl()
const { error, loading } = useAsync(async () => { const { error, loading } = useAsync(async () => {
const initTasks = createSetUpTaskList(customizeAssetsUrl, backendBaseUrl) const initTasks = createSetUpTaskList()
for (const task of initTasks) { for (const task of initTasks) {
try { try {
await task.task await task.task

View file

@ -1,12 +1,13 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { setMotd } from '../../../redux/motd/methods' import { setMotd } from '../../../redux/motd/methods'
import { defaultFetchConfig } from '../../../api/utils'
import { Logger } from '../../../utils/logger' import { Logger } from '../../../utils/logger'
import { customizeAssetsUrl } from '../../../utils/customize-assets-url'
import { defaultConfig } from '../../../api/common/default-config'
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
const log = new Logger('Motd') const log = new Logger('Motd')
@ -16,17 +17,15 @@ const log = new Logger('Motd')
* If the motd hasn't changed since the last time then the global application state won't be changed. * If the motd hasn't changed since the last time then the global application state won't be changed.
* To check if the motd has changed the "last modified" header from the request * To check if the motd has changed the "last modified" header from the request
* will be compared to the saved value from the browser's local storage. * will be compared to the saved value from the browser's local storage.
*
* @param customizeAssetsUrl the URL where the motd.md can be found.
* @return A promise that gets resolved if the motd was fetched successfully. * @return A promise that gets resolved if the motd was fetched successfully.
*/ */
export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => { export const fetchMotd = async (): Promise<void> => {
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY) const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
const motdUrl = `${customizeAssetsUrl}motd.md` const motdUrl = `${customizeAssetsUrl}motd.md`
if (cachedLastModified) { if (cachedLastModified) {
const response = await fetch(motdUrl, { const response = await fetch(motdUrl, {
...defaultFetchConfig, ...defaultConfig,
method: 'HEAD' method: 'HEAD'
}) })
if (response.status !== 200) { if (response.status !== 200) {
@ -39,7 +38,7 @@ export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => {
} }
const response = await fetch(motdUrl, { const response = await fetch(motdUrl, {
...defaultFetchConfig ...defaultConfig
}) })
if (response.status !== 200) { if (response.status !== 200) {

View file

@ -7,7 +7,6 @@
import { setUpI18n } from './setupI18n' import { setUpI18n } from './setupI18n'
import { refreshHistoryState } from '../../../redux/history/methods' import { refreshHistoryState } from '../../../redux/history/methods'
import { fetchMotd } from './fetch-motd' import { fetchMotd } from './fetch-motd'
import { setApiUrl } from '../../../redux/api-url/methods'
import { fetchAndSetUser } from '../../login-page/auth/utils' import { fetchAndSetUser } from '../../login-page/auth/utils'
import { fetchFrontendConfig } from './fetch-frontend-config' import { fetchFrontendConfig } from './fetch-frontend-config'
import { loadDarkMode } from './load-dark-mode' import { loadDarkMode } from './load-dark-mode'
@ -25,11 +24,7 @@ export interface InitTask {
task: Promise<void> task: Promise<void>
} }
export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl: string): InitTask[] => { export const createSetUpTaskList = (): InitTask[] => {
setApiUrl({
apiUrl: `${backendBaseUrl}api/private/`
})
return [ return [
{ {
name: 'Load dark mode', name: 'Load dark mode',
@ -49,7 +44,7 @@ export const createSetUpTaskList = (customizeAssetsUrl: string, backendBaseUrl:
}, },
{ {
name: 'Motd', name: 'Motd',
task: fetchMotd(customizeAssetsUrl) task: fetchMotd()
}, },
{ {
name: 'Load history state', name: 'Load history state',

View file

@ -12,7 +12,7 @@ import { Alert } from 'react-bootstrap'
export interface AsyncLoadingBoundaryProps { export interface AsyncLoadingBoundaryProps {
loading: boolean loading: boolean
error?: boolean error?: Error | boolean
componentName: string componentName: string
} }
@ -32,7 +32,7 @@ export const AsyncLoadingBoundary: React.FC<PropsWithChildren<AsyncLoadingBounda
children children
}) => { }) => {
useTranslation() useTranslation()
if (error === true) { if (error !== undefined && error !== false) {
return ( return (
<Alert variant={'danger'}> <Alert variant={'danger'}>
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} /> <Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} />

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Pagination } from 'react-bootstrap' import { Pagination } from 'react-bootstrap'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import { PagerItem } from './pager-item' import { PagerItem } from './pager-item'
@ -33,25 +33,34 @@ export const PagerPagination: React.FC<PaginationProps> = ({
onPageChange(pageIndex) onPageChange(pageIndex)
}, [onPageChange, pageIndex]) }, [onPageChange, pageIndex])
const correctedLowerPageIndex = Math.min( const correctedLowerPageIndex = useMemo(
() =>
Math.min(
Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0), Math.max(Math.min(wantedLowerPageIndex, wantedLowerPageIndex + lastPageIndex - wantedUpperPageIndex), 0),
lastPageIndex lastPageIndex
),
[wantedLowerPageIndex, lastPageIndex, wantedUpperPageIndex]
) )
const correctedUpperPageIndex = Math.max( const correctedUpperPageIndex = useMemo(
Math.min(Math.max(wantedUpperPageIndex, wantedUpperPageIndex - wantedLowerPageIndex), lastPageIndex), () =>
0 Math.max(Math.min(Math.max(wantedUpperPageIndex, wantedUpperPageIndex - wantedLowerPageIndex), lastPageIndex), 0),
[wantedUpperPageIndex, lastPageIndex, wantedLowerPageIndex]
) )
const paginationItemsBefore = Array.from(new Array(correctedPageIndex - correctedLowerPageIndex)).map((k, index) => { const paginationItemsBefore = useMemo(() => {
return new Array(correctedPageIndex - correctedLowerPageIndex).map((k, index) => {
const itemIndex = correctedLowerPageIndex + index const itemIndex = correctedLowerPageIndex + index
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} /> return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
}) })
}, [correctedPageIndex, correctedLowerPageIndex, setPageIndex])
const paginationItemsAfter = Array.from(new Array(correctedUpperPageIndex - correctedPageIndex)).map((k, index) => { const paginationItemsAfter = useMemo(() => {
return new Array(correctedUpperPageIndex - correctedPageIndex).map((k, index) => {
const itemIndex = correctedPageIndex + index + 1 const itemIndex = correctedPageIndex + index + 1
return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} /> return <PagerItem key={itemIndex} index={itemIndex} onClick={setPageIndex} />
}) })
}, [correctedUpperPageIndex, correctedPageIndex, setPageIndex])
return ( return (
<Pagination dir='ltr'> <Pagination dir='ltr'>

View file

@ -5,7 +5,7 @@
*/ */
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { Fragment, useEffect } from 'react' import React, { Fragment, useEffect, useMemo } from 'react'
export interface PagerPageProps { export interface PagerPageProps {
pageIndex: number pageIndex: number
@ -26,12 +26,12 @@ export const Pager: React.FC<PropsWithChildren<PagerPageProps>> = ({
onLastPageIndexChange(maxPageIndex) onLastPageIndexChange(maxPageIndex)
}, [children, maxPageIndex, numberOfElementsPerPage, onLastPageIndexChange]) }, [children, maxPageIndex, numberOfElementsPerPage, onLastPageIndexChange])
return ( const filteredChildren = useMemo(() => {
<Fragment> return React.Children.toArray(children).filter((value, index) => {
{React.Children.toArray(children).filter((value, index) => {
const pageOfElement = Math.floor(index / numberOfElementsPerPage) const pageOfElement = Math.floor(index / numberOfElementsPerPage)
return pageOfElement === correctedPageIndex return pageOfElement === correctedPageIndex
})} })
</Fragment> }, [children, numberOfElementsPerPage, correctedPageIndex])
)
return <Fragment>{filteredChildren}</Fragment>
} }

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { useAsync } from 'react-use'
import { getUser } from '../../../api/users'
import { customizeAssetsUrl } from '../../../utils/customize-assets-url'
import type { UserAvatarProps } from './user-avatar'
import { UserAvatar } from './user-avatar'
import type { UserInfo } from '../../../api/users/types'
import { useTranslation } from 'react-i18next'
import { AsyncLoadingBoundary } from '../async-loading-boundary'
export interface UserAvatarForUsernameProps extends Omit<UserAvatarProps, 'user'> {
username: string | null
}
/**
* Renders the user avatar for a given username.
* When no username is given, the guest user will be used as fallback.
*
* @see {UserAvatar}
*
* @param username The username for which to show the avatar or null to show the guest user avatar.
*/
export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ username, ...props }) => {
const { t } = useTranslation()
const { error, value, loading } = useAsync(async (): Promise<UserInfo> => {
if (username) {
return await getUser(username)
}
return {
displayName: t('common.guestUser'),
photo: `${customizeAssetsUrl}img/avatar.png`,
username: ''
}
}, [username, t])
if (!value) {
return null
}
return (
<AsyncLoadingBoundary loading={loading} error={error} componentName={'UserAvatarForUsername'}>
<UserAvatar user={value} {...props} />
</AsyncLoadingBoundary>
)
}

View file

@ -1,5 +1,5 @@
/* /*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,3 +9,7 @@
flex: 1 1; flex: 1 1;
overflow: hidden; overflow: hidden;
} }
.user-image {
color: transparent;
}

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -7,18 +7,25 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import Image from 'next/image'
import styles from './user-avatar.module.scss' import styles from './user-avatar.module.scss'
import type { UserInfo } from '../../../api/users/types'
export interface UserAvatarProps { export interface UserAvatarProps {
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
name: string
photo: string
additionalClasses?: string additionalClasses?: string
showName?: boolean showName?: boolean
user: UserInfo
} }
const UserAvatar: React.FC<UserAvatarProps> = ({ name, photo, size, additionalClasses = '', showName = true }) => { /**
* Renders the avatar image of a user, optionally altogether with their name.
*
* @param user The user object with the display name and photo.
* @param size The size in which the user image should be shown.
* @param additionalClasses Additional CSS classes that will be added to the container.
* @param showName true when the name should be displayed alongside the image, false otherwise. Defaults to true.
*/
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, size, additionalClasses = '', showName = true }) => {
const { t } = useTranslation() const { t } = useTranslation()
const imageSize = useMemo(() => { const imageSize = useMemo(() => {
@ -34,20 +41,18 @@ const UserAvatar: React.FC<UserAvatarProps> = ({ name, photo, size, additionalCl
return ( return (
<span className={'d-inline-flex align-items-center ' + additionalClasses}> <span className={'d-inline-flex align-items-center ' + additionalClasses}>
<Image {/* eslint-disable-next-line @next/next/no-img-element */}
src={photo} <img
className={`rounded`} src={user.photo}
alt={t('common.avatarOf', { name })} className={`rounded ${styles['user-image']}`}
title={name} alt={t('common.avatarOf', { name: user.displayName })}
title={user.displayName}
height={imageSize} height={imageSize}
width={imageSize} width={imageSize}
layout={'fixed'}
/> />
<ShowIf condition={showName}> <ShowIf condition={showName}>
<span className={`ml-2 mr-1 ${styles['user-line-name']}`}>{name}</span> <span className={`ml-2 mr-1 ${styles['user-line-name']}`}>{user.displayName}</span>
</ShowIf> </ShowIf>
</span> </span>
) )
} }
export { UserAvatar }

View file

@ -3,66 +3,38 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { DateTime } from 'luxon'
import React from 'react' import React from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { InternalLink } from '../common/links/internal-link' import { InternalLink } from '../common/links/internal-link'
import { ShowIf } from '../common/show-if/show-if' import { ShowIf } from '../common/show-if/show-if'
import {
DocumentInfoLineWithTimeMode,
DocumentInfoTimeLine
} from '../editor-page/document-bar/document-info/document-info-time-line'
import styles from './document-infobar.module.scss' import styles from './document-infobar.module.scss'
import { useCustomizeAssetsUrl } from '../../hooks/common/use-customize-assets-url' import { useApplicationState } from '../../hooks/common/use-application-state'
import { NoteInfoLineCreated } from '../editor-page/document-bar/note-info/note-info-line-created'
import { NoteInfoLineUpdated } from '../editor-page/document-bar/note-info/note-info-line-updated'
export interface DocumentInfobarProps { /**
changedAuthor: string * Renders an infobar with metadata about the current note.
changedTime: DateTime */
createdAuthor: string export const DocumentInfobar: React.FC = () => {
createdTime: DateTime
editable: boolean
noteId: string
viewCount: number
}
export const DocumentInfobar: React.FC<DocumentInfobarProps> = ({
changedAuthor,
changedTime,
createdAuthor,
createdTime,
editable,
noteId,
viewCount
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const assetsBaseUrl = useCustomizeAssetsUrl() const noteDetails = useApplicationState((state) => state.noteDetails)
// TODO Check permissions ("writability") of note and show edit link depending on that.
return ( return (
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}> <div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
<div className={'col-md'}>&nbsp;</div> <div className={'col-md'}>&nbsp;</div>
<div className={'d-flex flex-fill'}> <div className={'d-flex flex-fill'}>
<div className={'d-flex flex-column'}> <div className={'d-flex flex-column'}>
<DocumentInfoTimeLine <NoteInfoLineCreated />
mode={DocumentInfoLineWithTimeMode.CREATED} <NoteInfoLineUpdated />
time={createdTime}
userName={createdAuthor}
profileImageSrc={`${assetsBaseUrl}/img/avatar.png`}
/>
<DocumentInfoTimeLine
mode={DocumentInfoLineWithTimeMode.EDITED}
time={changedTime}
userName={changedAuthor}
profileImageSrc={`${assetsBaseUrl}/img/avatar.png`}
/>
<hr /> <hr />
</div> </div>
<span className={'ml-auto'}> <span className={'ml-auto'}>
{viewCount} <Trans i18nKey={'views.readOnly.viewCount'} /> {noteDetails.viewCount} <Trans i18nKey={'views.readOnly.viewCount'} />
<ShowIf condition={editable}> <ShowIf condition={true}>
<InternalLink <InternalLink
text={''} text={''}
href={`/n/${noteId}`} href={`/n/${noteDetails.primaryAddress}`}
icon={'pencil'} icon={'pencil'}
className={'text-primary text-decoration-none mx-1'} className={'text-primary text-decoration-none mx-1'}
title={t('views.readOnly.editNote')} title={t('views.readOnly.editNote')}

View file

@ -7,31 +7,25 @@
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
import { DocumentInfobar } from './document-infobar' import { DocumentInfobar } from './document-infobar'
import { RenderIframe } from '../editor-page/renderer-pane/render-iframe' import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter' import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
/**
* Renders the read-only version of a note with a header bar that contains information about the note.
*/
export const DocumentReadOnlyPageContent: React.FC = () => { export const DocumentReadOnlyPageContent: React.FC = () => {
useTranslation() useTranslation()
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter() const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
const noteDetails = useApplicationState((state) => state.noteDetails)
useSendFrontmatterInfoFromReduxToRenderer() useSendFrontmatterInfoFromReduxToRenderer()
// TODO Change todo values with real ones as soon as the backend is ready.
return ( return (
<Fragment> <Fragment>
<DocumentInfobar <DocumentInfobar />
changedAuthor={noteDetails.lastChange.username ?? ''}
changedTime={noteDetails.lastChange.timestamp}
createdAuthor={'Test'}
createdTime={noteDetails.createTime}
editable={true}
noteId={noteDetails.id}
viewCount={noteDetails.viewCount}
/>
<RenderIframe <RenderIframe
frameClasses={'flex-fill h-100 w-100'} frameClasses={'flex-fill h-100 w-100'}
markdownContentLines={markdownContentLines} markdownContentLines={markdownContentLines}

View file

@ -11,12 +11,15 @@ import { useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import Link from 'next/link' import Link from 'next/link'
/**
* Button that links to the read-only version of a note.
*/
export const ReadOnlyModeButton: React.FC = () => { export const ReadOnlyModeButton: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const id = useApplicationState((state) => state.noteDetails.id) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
return ( return (
<Link href={`/s/${id}`}> <Link href={`/s/${noteIdentifier}`}>
<a target='_blank'> <a target='_blank'>
<Button <Button
title={t('editor.documentBar.readOnlyMode')} title={t('editor.documentBar.readOnlyMode')}

View file

@ -11,12 +11,15 @@ import { useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import Link from 'next/link' import Link from 'next/link'
/**
* Button that links to the slide-show presentation of the current note.
*/
export const SlideModeButton: React.FC = () => { export const SlideModeButton: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const id = useApplicationState((state) => state.noteDetails.id) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
return ( return (
<Link href={`/p/${id}`}> <Link href={`/p/${noteIdentifier}`}>
<a target='_blank'> <a target='_blank'>
<Button <Button
title={t('editor.documentBar.slideMode')} title={t('editor.documentBar.slideMode')}

View file

@ -1,73 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DateTime } from 'luxon'
import React from 'react'
import { ListGroup, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import { DocumentInfoLine } from './document-info-line'
import { DocumentInfoLineWithTimeMode, DocumentInfoTimeLine } from './document-info-time-line'
import { UnitalicBoldText } from './unitalic-bold-text'
import { useCustomizeAssetsUrl } from '../../../../hooks/common/use-customize-assets-url'
import { DocumentInfoLineWordCount } from './document-info-line-word-count'
import { cypressId } from '../../../../utils/cypress-attribute'
export const DocumentInfoModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
const assetsBaseUrl = useCustomizeAssetsUrl()
useTranslation()
// TODO Replace hardcoded mock data with real/mock API requests
return (
<CommonModal
show={show}
onHide={onHide}
showCloseButton={true}
title={'editor.modal.documentInfo.title'}
{...cypressId('document-info-modal')}>
<Modal.Body>
<ListGroup>
<ListGroup.Item>
<DocumentInfoTimeLine
size={'2x'}
mode={DocumentInfoLineWithTimeMode.CREATED}
time={DateTime.local().minus({ days: 11 })}
userName={'Tilman'}
profileImageSrc={`${assetsBaseUrl}img/avatar.png`}
/>
</ListGroup.Item>
<ListGroup.Item>
<DocumentInfoTimeLine
size={'2x'}
mode={DocumentInfoLineWithTimeMode.EDITED}
time={DateTime.local().minus({ minutes: 3 })}
userName={'Philip'}
profileImageSrc={`${assetsBaseUrl}img/avatar.png`}
/>
</ListGroup.Item>
<ListGroup.Item>
<DocumentInfoLine icon={'users'} size={'2x'}>
<Trans i18nKey='editor.modal.documentInfo.usersContributed'>
<UnitalicBoldText text={'42'} />
</Trans>
</DocumentInfoLine>
</ListGroup.Item>
<ListGroup.Item>
<DocumentInfoLine icon={'history'} size={'2x'}>
<Trans i18nKey='editor.modal.documentInfo.revisions'>
<UnitalicBoldText text={'192'} />
</Trans>
</DocumentInfoLine>
</ListGroup.Item>
<ListGroup.Item>
<DocumentInfoLineWordCount />
</ListGroup.Item>
</ListGroup>
</Modal.Body>
</CommonModal>
)
}

View file

@ -1,56 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DateTime } from 'luxon'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import type { IconName } from '../../../common/fork-awesome/types'
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import { DocumentInfoLine } from './document-info-line'
import { TimeFromNow } from './time-from-now'
export interface DocumentInfoLineWithTimeProps {
size?: '2x' | '3x' | '4x' | '5x' | undefined
time: DateTime
mode: DocumentInfoLineWithTimeMode
userName: string
profileImageSrc: string
}
export enum DocumentInfoLineWithTimeMode {
CREATED,
EDITED
}
export const DocumentInfoTimeLine: React.FC<DocumentInfoLineWithTimeProps> = ({
time,
mode,
userName,
profileImageSrc,
size
}) => {
useTranslation()
const i18nKey =
mode === DocumentInfoLineWithTimeMode.CREATED
? 'editor.modal.documentInfo.created'
: 'editor.modal.documentInfo.edited'
const icon: IconName = mode === DocumentInfoLineWithTimeMode.CREATED ? 'plus' : 'pencil'
return (
<DocumentInfoLine icon={icon} size={size}>
<Trans i18nKey={i18nKey}>
<UserAvatar
photo={profileImageSrc}
additionalClasses={'font-style-normal bold font-weight-bold'}
name={userName}
size={size ? 'lg' : undefined}
/>
<TimeFromNow time={time} />
</Trans>
</DocumentInfoLine>
)
}

View file

@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute'
export interface UnitalicBoldTextProps extends PropsWithDataCypressId {
text: string | number
}
export const UnitalicBoldText: React.FC<UnitalicBoldTextProps> = ({ text, ...props }) => {
return (
<b className={'font-style-normal mr-1'} {...cypressId(props)}>
{text}
</b>
)
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans } from 'react-i18next'
import { UnitalicBoldContent } from './unitalic-bold-content'
import { NoteInfoLine } from './note-info-line'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Renders an info line about the number of contributors for the note.
*/
export const NoteInfoLineContributors: React.FC = () => {
const contributors = useApplicationState((state) => state.noteDetails.editedBy.length)
return (
<NoteInfoLine icon={'users'} size={'2x'}>
<Trans i18nKey={'editor.modal.documentInfo.usersContributed'}>
<UnitalicBoldContent text={contributors} />
</Trans>
</NoteInfoLine>
)
}

Some files were not shown because too many files have changed in this diff Show more